Guides / Building Search UI / UI & UX patterns / Query Suggestions / Building Query Suggestions UI

Build a Query Suggestions UI with InstantSearch iOS and SwiftUI

One way to enhance the user experience with a search box is by offering Query Suggestions, which help users explore relevant search terms.

Query suggestions can be implemented as a specialized type of multi-index interface, consisting of the following components:

  • The primary search interface utilizes a standard index.
  • As users start typing a phrase, suggestions from your Query Suggestions index are dynamically presented.

Usage

To effectively display the query suggestions

  • Create a Query Suggestions index specifically for storing and retrieving query suggestions.
  • Integrate your main index and the Query Suggestions index into the search experience. This lets you fetch search results from the main index while simultaneously fetching and displaying relevant query suggestions from the Query Suggestions index.
  • When a user selects a suggestion from the displayed options, update the search query to match the chosen suggestion. This ensures that the search results align with the selected suggestion, providing a seamless and efficient search experience.

Before you begin

To use InstantSearch iOS, you need an Algolia account. You can [create a new account][algolia_sign_up], or use the following credentials:

  • Application ID: latency
  • Search API key: af044fb0788d6bb15f807e4420592bc5
  • Results index name: instant_search
  • Suggestions index name: query_suggestions

These credentials give you access to pre-existing datasets of products and Query Suggestions appropriate for this guide.

Expected behavior

The initial screen shows the search box and results for an empty query:

Initial screen for Query Suggestions on iOS showing the results of an empty query

When users tap the search box, a list of query suggestions are shown (the most popular for an empty query):

When you type on iOS, Query Suggestions show the most popular search queries

On each keystroke, the list of suggestions is updated:

Query Suggestions on iOS update the list of suggestions dynamically as you type

When users selects a suggestion from the list, it replaces the query in the search box, and suggestions disappear. The results list presents search results for the selected suggestion:

When accepting a suggestion, the results of the search replace the suggestions

  • To implement search and suggestions in your app, SwiftUI offers a convenient set of components that can be utilized. These components will play a crucial role in the upcoming steps of this guide.
  • To represent a suggestions search record, the QuerySuggestion model object is available in the InstantSearch Core library.

Project structure

Algolia’s query suggestions feature relies on two essential components:

  • SearchViewModel: This view model encapsulates all the search logic for your app. It handles tasks such as handling user input, querying the Algolia API, managing search results, and processing query suggestions.
  • SearchView: This SwiftUI view is responsible for presenting the search interface to users. It uses the SearchViewModel to display search results, query suggestions, and other relevant information. The SearchView acts as the user-facing component that allows users to interact with the search.

Model object

To represent the items in your index, you can declare the Item model object with the following code:

1
2
3
4
struct Item: Codable {
  let name: String
  let image: URL
}

The Item struct is defined with two properties:

  • name: A String property that represents the name of the item.
  • image: A URL property that holds the URL for the image associated with the item. By conforming to the Codable protocol, the Item struct can be easily encoded and decoded from JSON.

Result views

The ItemHitRow is a view that represents a row in the results list, rendering an item. Here’s the code for the ItemHitRow view:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
struct ItemHitRow: View {

  let itemHit: Hit<Item>

  init(_ itemHit: Hit<Item>) {
    self.itemHit = itemHit
  }

  var body: some View {
    HStack(spacing: 14) {
      AsyncImage(url: itemHit.object.image, content: { image in
        image
          .resizable()
          .aspectRatio(contentMode: .fit)
      }, placeholder: {
        ProgressView()
      })
      .frame(width: 40, height: 40)
      if let highlightedName = itemHit.hightlightedString(forKey: "name") {
        Text(highlightedString: highlightedName,
             highlighted: { Text($0).bold() })
      } else {
        Text(itemHit.object.name)
      }
      Spacer()
    }
  }

}

The item hit parameter is of type Hit<Item>, representing a search hit containing an Item object.

Search view model

To create a view model that encompasses all the logic for the search interface with query suggestions, subclass ObservableObject and define the necessary properties and the init method.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
final class SearchViewModel: ObservableObject {

  private var itemsSearcher: HitsSearcher
  private var suggestionsSearcher: HitsSearcher

  init() {
    let appID: ApplicationID = "latency"
    let apiKey: APIKey = "af044fb0788d6bb15f807e4420592bc5"
    self.itemsSearcher = HitsSearcher(appID: appID,
                                      apiKey: apiKey,
                                      indexName: "instant_search")
    self.suggestionsSearcher = HitsSearcher(appID: appID,
                                            apiKey: apiKey,
                                            indexName: "query_suggestions")
  }
}

In this example, the SearchViewModel class:

  • Inherits from ObservableObject to enable SwiftUI to observe and update the view when the underlying data changes.
  • Declares two properties, itemsSearcher and suggestionsSearcher, of type HitsSearcher. These searchers will be responsible for querying the Algolia indices for searchable items and query suggestions, respectively.
  • In the init method, the appID and apiKey are set to the appropriate values for your Algolia app. Then, the itemsSearcher and suggestionsSearcher instances are created, passing the appID, apiKey, and the relevant index names.

To add an Infinite Scroll view model that manages the appearance of infinite search results hits, you can update the SearchViewModel as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
final class SearchViewModel: ObservableObject {

  var hits: PaginatedDataViewModel<AlgoliaHitsPage<Hit<Item>>>
  private var itemsSearcher: HitsSearcher
  private var suggestionsSearcher: HitsSearcher

  init() {
    let appID: ApplicationID = "latency"
    let apiKey: APIKey = "af044fb0788d6bb15f807e4420592bc5"
    let itemsSearcher = HitsSearcher(appID: appID,
                                     apiKey: apiKey,
                                     indexName: "instant_search")
    self.itemsSearcher = itemsSearcher
    self.suggestionsSearcher = HitsSearcher(appID: appID,
                                            apiKey: apiKey,
                                            indexName: "query_suggestions")
    self.hits = itemsSearcher.paginatedData(of: Hit<Item>.self)
  }
}

In the init method, after initializing itemsSearcher, the paginatedData(of:) method is called on itemsSearcher. It creates an PaginatedDataViewModel specifically for Hit<Item> objects. This view model is then assigned to the hits property.

To define the published properties searchQuery and suggestions in the SearchViewModel that will be used in the SwiftUI view, you can update the class as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
final class SearchViewModel: ObservableObject {

  @Published var searchQuery: String

  @Published var suggestions: [QuerySuggestion]

  var hits: PaginatedDataViewModel<AlgoliaHitsPage<Hit<Item>>>

  private var itemsSearcher: HitsSearcher

  private var suggestionsSearcher: HitsSearcher

  init() {
    let appID: ApplicationID = "latency"
    let apiKey: APIKey = "af044fb0788d6bb15f807e4420592bc5"
    let itemsSearcher = HitsSearcher(appID: appID,
                                     apiKey: apiKey,
                                     indexName: "instant_search")
    self.itemsSearcher = itemsSearcher
    self.suggestionsSearcher = HitsSearcher(appID: appID,
                                            apiKey: apiKey,
                                            indexName: "query_suggestions")
    self.hits = itemsSearcher.paginatedData(of: Hit<Item>.self)
    searchQuery = ""
    suggestions = []
  }
}

In this updated version of SearchViewModel:

  • The searchQuery property is marked with @Published to make it observable and automatically update the SwiftUI view when its value changes.
  • The suggestions property is also marked with @Published to make it observable and update the SwiftUI view when its value changes. It is an array of QuerySuggestion objects, which will serve as the storage for the suggestions list to be displayed.

To update the suggestions list whenever a search result is received by the suggestionsSearcher, and to include the necessary subscription logic, you can modify the SearchViewModel as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
final class SearchViewModel: ObservableObject {

  @Published var searchQuery: String {
    didSet {
      notifyQueryChanged()
    }
  }

  @Published var suggestions: [QuerySuggestion]

  var hits: PaginatedDataViewModel<AlgoliaHitsPage<Hit<Item>>>

  private var itemsSearcher: HitsSearcher

  private var suggestionsSearcher: HitsSearcher

  init() {
    let appID: ApplicationID = "latency"
    let apiKey: APIKey = "af044fb0788d6bb15f807e4420592bc5"
    let itemsSearcher = HitsSearcher(appID: appID,
                                     apiKey: apiKey,
                                     indexName: "instant_search")
    self.itemsSearcher = itemsSearcher
    self.suggestionsSearcher = HitsSearcher(appID: appID,
                                            apiKey: apiKey,
                                            indexName: "query_suggestions")
    self.hits = itemsSearcher.paginatedData(of: Hit<Item>.self)
    searchQuery = ""
    suggestions = []
    suggestionsSearcher.onResults.subscribe(with: self) { _, response in
      do {
        self.suggestions = try response.extractHits()
      } catch _ {
        self.suggestions = []
      }
    }.onQueue(.main)
    suggestionsSearcher.search()
  }

  deinit {
    suggestionsSearcher.onResults.cancelSubscription(for: self)
  }

}

In this updated version of SearchViewModel:

  • The suggestionsSubscription property is introduced as a Cancellable object to hold the subscription to the suggestionsSearcher results.
  • Inside the init method, the suggestionsSubscription is assigned the subscription to the suggestionsSearcher results. The closure within the subscription updates the suggestions property by extracting the hits from the latest search response. If an error occurs during extraction, an empty suggestions array is assigned.
  • In the deinit method, the cancel() method is called on suggestionsSubscription to unsubscribe and cancel the subscription when the SearchViewModel destroyed.

Create the main view of the search app with the instance of SearchViewModel declared as a StateObject:

1
2
3
4
5
6
7
8
9
public struct SearchView: View {

  @StateObject var viewModel = SearchViewModel()

  public var body: some View {
    // ...
  }

}

The @StateObject property wrapper ensures that the view model instance is preserved across view updates.

Add search results list using the InfiniteList view coming with InstantSearch SwiftUI library to show the search results for empty query. Launch the preview to see the results list.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public struct SearchView: View {

  @StateObject var viewModel = SearchViewModel()

  public var body: some View {
    InfiniteList(viewModel.hits, itemView: { hit in
      ItemHitRow(hit)
        .padding()
      Divider()
    }, noResults: {
      Text("No results found")
    })
    .navigationTitle("Query suggestions")
  }

}

Add .searchable modifier to the InfiniteList using the searchQuery published property of the view model and set the search prompt.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public struct SearchView: View {

  @StateObject var viewModel = SearchViewModel()

  public var body: some View {
    InfiniteList(viewModel.hits, itemView: { hit in
      ItemHitRow(hit)
        .padding()
      Divider()
    }, noResults: {
      Text("No results found")
    })
    .searchable(text: $viewModel.searchQuery,
                prompt: "Laptop, smartphone, tv")
    .navigationTitle("Query suggestions")
  }

}

In the preview a search bar appears, but its changes doesn’t trigger a search. Update the SearchViewModel by adding the didSet property observer to searchQuery and add the notifyQueryChanged function that launches the search.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
final class SearchViewModel: ObservableObject {

  @Published var searchQuery: String {
    didSet {
      notifyQueryChanged()
    }
  }

  @Published var suggestions: [QuerySuggestion]

  var hits: PaginatedDataViewModel<AlgoliaHitsPage<Hit<Item>>>

  private var itemsSearcher: HitsSearcher

  private var suggestionsSearcher: HitsSearcher

  init() {
    let appID: ApplicationID = "latency"
    let apiKey: APIKey = "af044fb0788d6bb15f807e4420592bc5"
    let itemsSearcher = HitsSearcher(appID: appID,
                                     apiKey: apiKey,
                                     indexName: "instant_search")
    self.itemsSearcher = itemsSearcher
    self.suggestionsSearcher = HitsSearcher(appID: appID,
                                            apiKey: apiKey,
                                            indexName: "query_suggestions")
    self.hits = itemsSearcher.paginatedData(of: Hit<Item>.self)
    searchQuery = ""
    suggestions = []
    suggestionsSearcher.onResults.subscribe(with: self) { _, response in
      do {
        self.suggestions = try response.extractHits()
      } catch _ {
        self.suggestions = []
      }
    }.onQueue(.main)
    suggestionsSearcher.search()
  }


  private func notifyQueryChanged() {
    itemsSearcher.request.query.query = searchQuery
    itemsSearcher.search()
  }

  deinit {
    suggestionsSearcher.onResults.cancelSubscription(for: self)
  }

}

Now in the preview the search works as expected. Add a suggestions view using the suggestions parameter of the searchable modifier. Use the ForEach structure with the SuggestionRow provided by the InstantSearch SwiftUI.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public struct SearchView: View {

  @StateObject var viewModel = SearchViewModel()

  public var body: some View {
    InfiniteList(viewModel.hits, itemView: { hit in
      ItemHitRow(hit)
        .padding()
      Divider()
    }, noResults: {
      Text("No results found")
    })
    .navigationTitle("Query suggestions")
    .searchable(text: $viewModel.searchQuery,
                prompt: "Laptop, smartphone, tv",
                suggestions: {
                    ForEach(viewModel.suggestions, id: \.query) { suggestion in
                    SuggestionRow(suggestion: suggestion)
                }})
  }
}

Launch the preview. It will now display a list of suggestions when the search bar is tapped. However, modifying the search input text does not currently update the list of suggestions. It is necessary to alter the logic of the view as follows:

When a user begins searching and changes the query, the suggestions list should be updated, not the results list. If a user selects a suggestion, it will trigger a search submission and present the search results list. If a user clicks the arrow button in the suggestions row, it may auto-complete the search query but should not submit the search. When a user submits the search by tapping the search/return button on the keyboard, it should trigger a search submission and present the search results list.

Modify the SearchViewModel accordingly. Add submitSearch method which clear suggestions list to make it disappear and launches the search on the itemsSearcher.

1
2
3
4
5
func submitSearch() {
  suggestions = []
  itemsSearcher.request.query.query = searchQuery
  itemsSearcher.search()
}

Then, add the didSubmitSuggestion flag, which is set when a suggestion from the list has just been submitted. Update the notifyQueryChanged method to submit search if a suggestion has been submitted and toggle the didSubmitSuggestion flag. If the suggestions hasn’t been submitted, it triggers the search on both searchers.

1
2
3
4
5
6
7
8
9
10
11
private func notifyQueryChanged() {
  if didSubmitSuggestion {
    didSubmitSuggestion = false
    submitSearch()
  } else {
    suggestionsSearcher.request.query.query = searchQuery
    itemsSearcher.request.query.query = searchQuery
    suggestionsSearcher.search()
    itemsSearcher.search()
  }
}

Add completeSuggestion and submitSuggestion methods to handle actions from the suggestion row:

1
2
3
4
5
6
7
8
func completeSuggestion(_ suggestion: String) {
  searchQuery = suggestion
}

func submitSuggestion(_ suggestion: String) {
  didSubmitSuggestion = true
  searchQuery = suggestion
}

Finish the SearchView implementation by assigning the SuggestionRow actions callbacks and adding onSubmit modifier.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public struct SearchView: View {

  @StateObject var viewModel = SearchViewModel()

  public var body: some View {
    InfiniteList(viewModel.hits, itemView: { hit in
      ItemHitRow(hit)
        .padding()
      Divider()
    }, noResults: {
      Text("No results found")
    })
    .navigationTitle("Query suggestions")
    .searchable(text: $viewModel.searchQuery,
                prompt: "Laptop, smartphone, tv",
                suggestions: {
      ForEach(viewModel.suggestions, id: \.query) { suggestion in
        SuggestionRow(suggestion: suggestion,
                      onSelection: viewModel.submitSuggestion,
                      onTypeAhead: viewModel.completeSuggestion)
      }
    })
    .onSubmit(of: .search, viewModel.submitSearch)
  }

}

Your query suggestions search experience is now ready to use. Run the preview to test it.

You can find a complete project in the iOS examples repository.

Did you find this page helpful?