SwiftUI Search: Getting Started

Learn how to use the searchable modifier to quickly add search capability to your SwiftUI apps. By Mina H. Gerges.

See course reviews 5 (4) · 3 Reviews

Download materials
Save for later
Share

Learn how to use the searchable modifier to quickly add search capability to your SwiftUI apps.

Think about one of your favorite big apps whether it’s an e-commerce, social media, or booking app. How do you reach the content you want? Right, you go first to the search bar. Search is faster than scrolling through the long pages hoping to find your target.

In SwiftUI apps before the launch of SwiftUI 3, you had to create the whole search experience from scratch. At Apple’s Worldwide Developers Conference (WWDC) 2021, Apple introduced the searchable(text:placement:prompt:) modifier as a native solution to perform search queries in SwiftUI. It also comes with a variety of helper properties to improve the search experience.

In this tutorial, you’ll use this modifier to help Swifty search for his favorite meals’ recipes in an app that displays open-source recipes with their images.

Swifty cooking an egg in a frying pan

Along the way, you’ll learn:

  • How to use the searchable modifier.
  • Two ways to filter data using the search query.
  • How to give your users suggestions to improve their search experience.
  • Different positions where you can put your search field.
Note: This tutorial assumes you’re comfortable with developing SwiftUI apps using Xcode. If you’re not familiar with SwiftUI, check out SwiftUI: Getting Started first. You need Xcode 13 installed to follow this tutorial.

Getting Started

Click Download Materials at the top or bottom of this tutorial to download the starter project. Unzip it and open Chef Secrets.xcodeproj in the starter folder:

Chef Secrets Xcode File

This app displays a list of open-source recipes. When you select a recipe, you’ll see its image and ingredients.

Build and run on iPhone 12 Pro. Check the details of different recipes yourself:

List of recipes and recipe details

In Xcode, here are the files you’ll work on:

  • ContentView.swift: Contains NavigationView, which helps you navigate through the app views. It controls the split view in the iPad that displays recipes in the side view and the details of each recipe in the detail view.
  • RecipesView.swift: Displays the list of recipes in ChefRecipesModel.swift.
  • RecipeDetailView.swift: Displays the selected recipe’s details.
  • ChefRecipesModel.swift: Contains struct representing the recipes model. struct contains a function that decodes Recipes.json and loads it into recipes. It has two arrays you’ll use later to give your user search suggestions.
Note: If you don’t see a preview on the right of any file, that’s because you need to start/resume the preview feature. You can resume by clicking Resume next to Automatic preview updating paused or press Option-Command-P (⌥ + ⌘ + P).

Before you dig into the code, you should understand how the search process worked before Apple introduced searchable(text:placement:prompt:).

Understanding the History of Searching in SwiftUI

Before SwiftUI 3, UIKit didn’t have any view like UISearchBar. If you wanted to add a search experience to your app, you’d have to create the whole view from scratch.

Search Bar views numbered from 1 to 6

The search view would include the following views:

  1. Text: Where users type their search query.
  2. Image: Shows the magnifying glass.
  3. Button: Clears the search query.
  4. View: Contains the previously mentioned views.
  5. Button: Cancels the search process.
  6. HStack: Contains all the previous views.

Next, you’d handle all the events related to the search views, like:

  • Filtering the list according to the search query.
  • Showing and hiding the Clear button.
  • Clearing the search query when the user taps Clear.
  • Showing and hiding the Cancel button.

After covering the basics, you could start adding advanced features like search suggestions. You’d create an overlay to display the suggestions and handle what to show when in those suggestions.

With SwiftUI, you can create the whole previous search experience fast. But, SwiftUI 3 made it easier by offering a native solution for a search experience that does most of the previous tasks. You’ll learn about this in the next section.

Using the Searchable Modifier

SwiftUI now offers a native search experience using searchable(text:placement:prompt:). This modifier allows you to mark view content as being searchable, which means it can:

  • Display all the search views, including the search field and Cancel button.
  • Offer parameters/modifiers to handle the different events happening with those views.

In SwiftUI 3, you can add searchable(text:placement:prompt:) only to NavigationView. SwiftUI displays the search bar under the navigation bar title and above the list that you’ll filter. In multi-column view, you can choose in which view to display your search bar.

An iPhone screen shows a navigation bar with a search bar in its native position and list of animals below it

You’ve kept Swifty waiting for a little bit, but now you’ll help him search the Chef Secrets app for his next meal. :]

Searching for a Meal

Open RecipesView.swift. At the top of the view, add this line after the one which defines chefRecipesModel:

@State var searchQuery = ""

You’ll use this property to hold a search query entered by a user.

Next, add the searchable modifier to VStack، right before navigationBarTitleDisplayMode(_:):

.searchable(text: $searchQuery)

text is the text your user types in the search field. You bind it with searchQuery, which you’ll use later to filter the recipes according to the search query.

Note: Adding searchable(text:placement:prompt:) here gives the same effect as adding it to NavigationView inside ContentView.swift. You’ll use the second approach later in this tutorial.

Now, build and run. You’ll see that the recipes page doesn’t change. Where’s the search field, then? To answer this question, pull down the recipe list. Voila, you’ve found the missing search field.

A list of recipes with search field below the title

The default value for placement of the search field is .automatic. With the current view hierarchy, that means the search field is off-screen, and you have to pull down the list to display it. Later in this tutorial, you’ll learn more about other placement options.

You offer Swifty a place to type his search query, but you still haven’t performed the search query and displayed the filtered results. That’s what you’ll do next.

Performing a Search Query

Add the following property to RecipesView.swift:

@State var filteredRecipes = ChefRecipesModel().recipes

This variable will hold the list of recipes as filtered by the user’s search query.

Then, add the following method to RecipesView:

func filterRecipes() {
  if searchQuery.isEmpty {
    // 1
    filteredRecipes = chefRecipesModel.recipes
  } else {
    // 2
    filteredRecipes = chefRecipesModel.recipes.filter {
      $0.name
        .localizedCaseInsensitiveContains(searchQuery)
    }
  }
}

Here’s what this code does:

  1. If the search query is empty, filteredRecipes contains all the recipes.
  2. If the search query isn’t empty, you filter the recipes by name according to the search query. You ignore case sensitivity when filtering the recipes.

Now, you’ll use this new variable as data in the list. Inside ForEach in List, replace:

chefRecipesModel.recipes

With:

filteredRecipes

Next, add this code below searchable(text:placement:prompt:):

.onSubmit(of: .search) {
  filterRecipes()
}

.onSubmit(of:_:) tracks a chosen view, then triggers an action when a specific change happens to the value of this view. Here, you’re tracking the search field. The trigger fires when the user taps the Search button on the keyboard and then performs the action in the trailing block.

Note: When you put the cursor on the search field, SwiftUI displays Search as the text of the return key on the keyboard.

Finally, add this code as the second parameter inside searchable(text:placement:prompt:) after text:

prompt: "Search By Meal Name"

This sets the prompt inside the search bar, instructing the user on how to use it.

Build and run. Pull down the list to display the search field. You’ll see Search By Meal Name in the search bar’s placeholder:

The search field displays the Search By Meal Name prompt

Now, type Roast in the search field. Tap Search in the simulator keyboard. You’ll see the list filtered, displaying only the recipes with “Roast” in their names.

Recipes with titles filtered by the word Roast

Amazing — in just a few lines of code, you’ve filtered the recipe list!

Now, clear the search field by clicking × in the circle on the right side of the search box. Oh no, the list doesn’t change!

Filtered recipes without a search term

Finally, tap Cancel next to the search box. It disappears, but the list still doesn’t change.

Filtered recipes still show even when canceling a search

This isn’t the action you expected, is it? The recipe list should change when you clear the search field or tap Cancel. But that doesn’t happen because you only update the list when the user submits a search query. You’ll fix this in the next section.

Clearing and Canceling a Search

In the previous section, you gave your user a search field where they can enter and submit a search query. Then, you filter the recipe list according to the query. However, when the user clears the query, the list doesn’t change. SwiftUI has .onChange(of:perform:) for this reason.

Inside RecipesView.swift, add this modifier to VStack before .onSubmit(of:_:):

.onChange(of: searchQuery) { _ in
  filterRecipes()
}

Now, any change to the search query triggers an update to the filtered list. This means that not only does clearing the search box or canceling your search reset the recipe’s list, but the list is actually filtered as the user types a query!

Build and run. Reveal the search field and start typing Roast, one letter at a time, without pressing Enter or tapping Search. Now you’re updating the list with each change in the search query.

The recipe list filters results as a user types

Now, clear the search query in the search bar and notice how it updates the recipe list, displaying the full list again. Next, try a new search, and then tap Cancel. See how the full list returns, as expected.

Note: You might feel a bit of redundancy in the code of .onChange(of:perform:) or .onSubmit(of:_:). Later in this tutorial, you’ll use an alternative approach to update the recipe list without both modifiers.

You can now offer Swifty an error-free search experience. But Swifty is special, and you’re a special developer who only offers the best. So, you’ll add advanced features in the following sections, starting with displaying search suggestions.

Swifty shows his love with hearts

Offering Search Suggestions

Setting the right prompt in the search field helps your user know what type of input they can search by. But offering search suggestions makes the process faster and simpler. searchable(text:placement:prompt:) has a parameter called suggestions that shows custom suggestions and controls what happens when the user selects any suggestion.

The suggestions appear as an overlay below the search bar, covering the list. You can display each suggestion as text, an image or any view you offer in the suggestions block. There are two ways to decide what happens when the user taps any suggestion. You’ll try them both now in your code.

Note: On tvOS, searchable modifiers only support suggestions of type Text.

Open RecipesView.swift. Add this code as the third parameter inside searchable(text:placement:prompt:) after prompt:

suggestions: {
  // 1
  Button("Pizza") {
    searchQuery = "pizza"
  }
  // 2
  Text("Chicken Salad")
    .searchCompletion("Chicken Salad")
}

With this code:

  1. You add this suggestion as Button view with the text “Pizza”. So you choose what happens when the user taps this suggestion in the action block of Button. Here, you change searchQuery to pizza.
  2. In the non-interactive views like Text, you associate it with searchCompletion(_:). When the user taps this suggestion, SwiftUI replaces the text inside the search field with the string inside searchCompletion(_:).

Build and run. Put the cursor in the search field and notice how the two search suggestions appear before you type anything. Select both suggestions, and check what happens in each case.

Search suggestion options, one using a button action and another using searchCompletion

Can you spot the difference between selecting each of the two suggestions?

Selecting either of them changes searchQuery, either directly or by changing the text inside the search field. Since .onChange(of:perform:) responds to changes in searchQuery, it filters the list in both selections by the recipe name using the new search text.

Because you tap the suggestion button in the first suggestion, it dismisses the suggestion list. But, when you select the second suggestion, searchCompletion(_:) removes the selected choice from the suggestion overlay, keeping the suggestion list onscreen.

Press Enter to dismiss the suggestions view and display the list behind it in the second selection.

Search field shows Chicken Salad and list below it filtered by name with this search query

Making Suggestions Dynamic

Now to make the suggestion list dynamic, replace the code inside suggestions from searchable(text:placement:prompt:) with the following:

// 1
ForEach(
  chefRecipesModel.nameSuggestions,
    id: \.self
) { suggestion in
  // 2
  Text(suggestion)
    .searchCompletion(suggestion)
}

Here’s what’s going on here:

  1. Instead of adding individual suggestions one by one, ForEach loops over the nameSuggestions array from ChefRecipesModel.swift.
  2. You display each suggestion as Text and associate it with searchCompletion(_:), like you did before.

Build and run. Check the new suggestions, select any one of them, then press Enter to make sure everything works as expected.

Selecting a search suggestion removes the item from the list, hitting Enter filters the recipes by the suggestion

Note: If the search query you type matches the string of any searchCompletion(_:), then SwiftUI removes the item from the suggestion list.

Swifty likes the suggestion feature you added, but he wants to search the recipes by their ingredients too. You’ll enable him to do this in the next sections.

Improving the Search Experience

Swifty would like the option to toggle between searching the list by a recipe’s name and by a recipe’s ingredients. You’ll start by creating a toggle switch.

First, add this @State property inside RecipesView.swift :

 @State var isSearchingIngredient = false

This sets a variable to track whether or not you’re searching by ingredients, setting it to false by default.

Next, add this Toggle inside VStack before List:

Toggle("**Search By Ingredients**", isOn:
  $isSearchingIngredient)
    .tint(Color("rw-green"))
    .foregroundColor(Color("rw-green"))
    .font(.body)
    .padding([.leading, .trailing])

This creates a toggle switch in your UI, the value of which tracks isSearchingIngredient‘s state.

Now, update prompt inside searchable(text:placement:prompt:) to show Swifty what he’s searching with the following code:

isSearchingIngredient ? "Search By Ingredient" :
  "Search By Meal Name"

Now, the prompt inside the search box changes based on if the user toggles on Search By Ingredients.

Build and run. Tap the toggle and see how the search field’s prompt changes:

Toggling Search By Ingredients changes the prompt in the search box

Now, you’ll create search lists according to the status of Search By Ingredients toggle. You’ll also create custom views to handle different suggestions views.

Creating Search Lists

Right-click the Recipe View folder and select New File…. Choose SwiftUI View, then click Next. Name the file as IngredientSuggestionView. Click Create.

Add this property to IngredientSuggestionView:

var chefRecipesModel = ChefRecipesModel()

This creates a new ChefRecipesModel() in your struct.

Next, replace body‘s content with the code below:

ForEach(
  chefRecipesModel.ingredientSuggestions,
  id: \.self) { ingredient in
    Text(ingredient)
      .searchCompletion(ingredient)
      .padding()
      .font(.title)
}

For each ingredientSuggestions, you create a Text view to represent that suggestion in the search completion list.

Similarly, create a new SwiftUI View in the Recipe View folder called NameSuggestionView. Then, add this property:

var chefRecipesModel = ChefRecipesModel()

And replace body‘s content with the code below:

ForEach(
  chefRecipesModel.nameSuggestions,
  id: \.self) { suggestion in
    Text(suggestion)
      .searchCompletion(suggestion)
}

Return to RecipesView.swift. In searchable(text:placement:prompt:), replace suggestions with the following code:

// 1
if searchQuery.isEmpty {
  if isSearchingIngredient {
    // 2
    IngredientSuggestionView()
  } else {
    // 3
    NameSuggestionView()
  }
}

Here’s what the code does:

  1. Display the suggestion list when the search field is empty.
  2. If the toggle is on, the suggestion view shows IngredientSuggestionView. You’ll create its implementation in a new file.
  3. If the toggle is off, the suggestion view shows NameSuggestionView. You’ll refactor the old implementation for the name suggestion view into a new file.

Finally, replace the else block inside filterRecipes() with the code below:

if isSearchingIngredient {
  // 1
  filteredRecipes = chefRecipesModel.recipes.filter {
    !$0.ingredients.filter { ingredient in
      ingredient.emoji == searchQuery
    }.isEmpty
  }
} else {
  // 2
  filteredRecipes = chefRecipesModel.recipes.filter {
    $0.name
      .localizedCaseInsensitiveContains(searchQuery)
  }
}

Here’s what this code does:

  1. When the user searches by ingredient, you filter the recipes by emoji inside each recipe.
  2. When the user searches by meal name, you filter the recipes by name, as before.

Build and run. Switch Toggle and help Swifty search for meals with egg in their ingredients.

Toggling between searching by ingredients with emojis and searching by meal names with text

Note: After you add the toggle, notice how the search field stays onscreen permanently according to the view hierarchy. This is because placement is automatic by default.

Setting Placement in Different Platforms

searchable(text:placement:prompt:) offers placement to choose the location of the search bar in the view hierarchy. SwiftUI offers four options for this parameter:

  • .automatic: SwiftUI places the search field automatically according to the view hierarchy. This is the default option.
  • .navigationBarDrawer: SwiftUI places the search field under the navigation title. This option has displayMode, which displays the search field permanently onscreen or automatically, like the previous option.
  • .sidebar: SwiftUI places the search field in the sidebar of a navigation view.
  • .toolbar: SwiftUI places the search field in the toolbar.

The last two options provide the same output as navigationBarDrawer. Hopefully, in future versions of SwiftUI, these options might show a significant change.

Note: placement is the preferred placement you choose. Sometimes the view hierarchy can’t fulfill the chosen placement.

In iOS, all those options position the search field under the navigation title. The only difference is whether the search field display is permanently onscreen or automatic.

In iPadOS and macOS, you can especially spot the difference between placement options in multi-column views. Here are a couple of example code blocks with pictures showing how the search bar renders:

NavigationView {
  FirstView()
    .navigationTitle("First")
  SecondView()
    .navigationTitle("Second")
}
.searchable(text: $searchQuery)

In this first example, searchable(text:placement:prompt:) is attached to a NavigationView with two columns. Here, SwiftUI places the search field in the first column.

Search bar located under the title of the first column in two-column view.

Here’s another example:

NavigationView {
  FirstView()
    .navigationTitle("First")
  SecondView()
    .navigationTitle("Second")
    .searchable(text: $searchQuery)
}

In this code, SecondView has searchable(text:placement:prompt:). Since this navigation has only two columns, SwiftUI places the search field on the top trailing of SecondView by default.

Search bar located in the top trailing of the second column in two-column view.

Now, consider this example:

NavigationView {
  FirstView()
    .navigationTitle("First")
  SecondView()
    .navigationTitle("Second")
    .searchable(text: $searchQuery,
      placement:
        .navigationBarDrawer(displayMode: .always))
}

In the code above, SecondView has searchable(text:placement:prompt:), but placement‘s value is .navigationBarDrawer. Now, SwiftUI places the search field under the navigation title of this column.

Search bar located under the title of the second column in two-column view.

Finally, check out this placement option with a three-column view:

NavigationView {
  FirstView()
    .navigationTitle("First")
  SecondView()
    .navigationTitle("Second")
  ThirdView()
    .navigationTitle("Third")
}
.searchable(text: $searchQuery)

In the code above, NavigationView has searchable(text:placement:prompt:). Here, SwiftUI places the search field under the title of the second column.

Search bar located under the title of the second column in three-column view.

You can see that where you place searchable(text:placement:prompt:) determines where SwiftUI draws the search box.

Now, you’ll check the placement of the search field in iPad in more depth within the Chef Secrets app. You’ll also use the second approach to filter the list using the search query.

Searching on an iPad

Choose iPad Pro 9.7″ as your simulator, then build and run. Tap the back button and check the two-column view. SwiftUI places the search field on the first column under the navigation title.

Chef Secrets app in iPad shows a side column that has the list and search bar

Now, you’ll implement the second approach to filter the recipe list without using .onChange(of:perform:) and .onSubmit(of:_:). You’ll start with some refactoring.

First, add the lines below to the beginning of ContentView.swift:

@State var searchQuery = ""
@State var isSearchingIngredient = false

This allows ContentView to manage the search query state.

Next, still in ContentView.swift, replace RecipesView() with the code below:

RecipesView(
  searchQuery: $searchQuery,
  isSearchingIngredient: $isSearchingIngredient)

This passes @State properties from ContentView down to RecipesView.

Now, open RecipesView.swift. Replace the searchQuery and isSearchingIngredient declarations to be @Binding:

@Binding var searchQuery: String
@Binding var isSearchingIngredient: Bool

This allows RecipesView to accept @State properties from ContentView.

Then, still in RecipesView.swift, cut the entire implementation of searchable(text:placement:prompt:), and paste it into ContentView.swift just below accentColor(_:).

Finally, open RecipesView.swift. Inside RecipesView_Previews, replace previews‘s content with the following code:

RecipesView(
  searchQuery: .constant(""),
  isSearchingIngredient: .constant(false))
    .previewDevice("iPhone SE (2nd generation)")

RecipesView(
  searchQuery: .constant(""),
  isSearchingIngredient: .constant(false))
    .previewDevice("iPad Pro (12.9-inch) (2nd generation)")

This tells the SwiftUI preview to render two versions of the view: one as an iPhone SE and the other as an iPad Pro.

Click the run icon in the canvas inside ContentView.swift to start the live preview. Check that this refactoring doesn’t break anything in the app.

Live preview of the iPad screen in Xcode showing the Chef Secrets app two-column view

The real change starts now.

First, open RecipesView.swift, then remove .onChange(of:perform:) and .onSubmit(of:_:). Next, completely remove filterRecipes().

Finally, replace:

@State var filteredRecipes = ChefRecipesModel().recipes

With the following:

var filteredRecipes: [Recipe] {
  if searchQuery.isEmpty {
    return chefRecipesModel.recipes
  } else {
    if isSearchingIngredient {
      let filteredRecipes = chefRecipesModel.recipes.filter {
        !$0.ingredients.filter { ingredient in
          ingredient.emoji == searchQuery
        }.isEmpty
      }
      return filteredRecipes
    } else {
      return chefRecipesModel.recipes.filter {
        $0.name.localizedCaseInsensitiveContains(searchQuery)
      }
    }
  }
}

Here, you’ve changed filteredRecipes to be a computed property and used the values of the bound variables searchQuery and isSearchingIngredient to update the value of the computed property as the value of the bound variables change.

As a result, while typing or when the user selects a suggestion, SwiftUI filters the recipe list.

Build and run. Try searching and switch between searching by meal name and by ingredient. Notice how SwiftUI filters the list while you type in the search field.

Searching recipes in the side menu of iPad by meal name and by ingredient.

Wonderful! You added an amazing search experience to the Chef Secrets app. But Swifty loves dessert too. So now, it’s the time for the icing on the cake. :]

Understanding Searchable Environment Properties

SwiftUI introduces two environment variables with searchable(text:placement:prompt:):

  • .isSearching: To check if the user is using the search field.
  • .dismissSearch: To dismiss the current search process.

You’ll use .isSearching to display how many recipes match the user’s search query.

In RecipesView.swift, add the following declaration:

@Environment(\.isSearching) var isSearching

This sets the local isSearching variable to the value of isSearching‘s environment variable.

Next, add these lines in body under Toggle:

if isSearching {
  Text("""
    Search Results: \(filteredRecipes.count) \
    of \(chefRecipesModel.recipes.count)
    """)
    .foregroundColor(Color("rw-green"))
    .opacity(0.5)
}

This code shows Text with the number of filtered recipes that match the search query, but only when the user is interacting with the search field.

Build and run. Try searching, and check that Text shows the number of filtered recipes:

Number of filtered recipes out of the total number of recipes

Note: If a parent view has searchable(text:placement:prompt:), isSearching works only inside any of its sub-views but can’t work inside this parent view itself. Since ContentView.swift has searchable(text:placement:prompt:), then its child, RecipesView.swift, uses isSearching, but ContentView.swift itself can’t use it.

Swifty’s now ready to cook his meal. After helping him like you did today, you might be his guest at dinner! :]

Swifty cool wearing his sunglasses

Where to Go From Here?

You can download the completed project files by clicking Download Materials at the top or bottom of the tutorial.

You’ve learned a lot about managing the search experience in SwiftUI apps. This includes:

  • Using searchable(text:placement:prompt:) to search any list.
  • Filtering your list with the search query using .onChange(of:perform:), .onSubmit(of:_:) or @Binding.
  • Using suggestions to offer text and non-text search suggestions.
  • Using placement to change the search field placement in the view hierarchy.

Drag and drop is another feature that becomes simple with SwiftUI. To learn more about it, check out the Drag and Drop Tutorial for SwiftUI.

If you want to learn how to make your SwiftUI app safer by using the async/await method over the old GCD concurrency, check out async/await in SwiftUI.

Swifty enjoys the new search feature you added to the Chef Secrets app, and I hope you enjoyed implementing it while reading this tutorial. If you have any comments or questions, feel free to join in the forum discussion below!