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

Building Query Suggestions UI

Overview

When your user interacts with a SearchBox, you can help them discover what they could search for by providing Query suggestions.

This is a specific kind of multi-index interface: your main search interface will be using a regular index, while you will display suggestions as the user types from your Query Suggestions index.

To display the suggestions in your iOS app, you can leverage view models with our MultiHitsViewModel component. Follow along for a full example on how to display a search bar with instant results and the Query Suggestions list as you type a query.

Usage

To display the suggestions:

Before we start

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

  • Application ID: latency
  • Search API key: afc3dd66dd1293e2e2736a5a51b05c0a
  • Results index name: instant_search
  • Suggestions index name: instantsearch_query_suggestions

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

Project structure

Our search experience with Query Suggestions uses three view controllers:

  • SearchViewController: main view controller presenting the search experience,
  • QuerySuggestionsViewController: child view controller presenting the Query Suggestions,
  • ResultsViewController: child view controller presenting the search results.

Expected behavior

The initial screen shows the search bar and search results for an empty query.

Initial

Once the user taps the search bar, it presents a list of Query Suggestions for an empty query (the most popular queries).

Suggestions

On each keystroke the list of suggestions is updated simultaneously.

Suggestions search

When user selects a suggestion from the list, it replaces the query in the search bar, suggestions viewcontroller disappears. Results viewcontroller presents search results for a query from a selected suggestion.

Results

Basic query suggestions viewcontroller goes out-of-box with InstantSearch library. It will be used in this example. Query suggestions view controller is a usual Hits viewcontroller which uses QuerySuggestion object provided by InstantSearch Core library as a search record.

Results view controller

We don’t provide a ready-to-use results view controller, but you can create one with the tools we provide in the InstantSearch Core library. We won’t focus too much on this part since it’s not directly related to Query Suggestions. Instead, you can copy/paste the following code to your project. You can read more about Hits in the API reference. It requires the SDWebImage library which you need to import in your project.

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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
import Foundation
import UIKit
import InstantSearchCore
import SDWebImage

struct ShopItem: Codable {
  let name: String
  let description: String
  let brand: String
  let image: URL
}

class ShopItemTableViewCell: UITableViewCell {
  
  let itemImageView: UIImageView
  let titleLabel: UILabel
  let subtitleLabel: UILabel
  
  override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
    itemImageView = .init(frame: .zero)
    titleLabel = .init(frame: .zero)
    subtitleLabel = .init(frame: .zero)
    super.init(style: style, reuseIdentifier: reuseIdentifier)
    layout()
  }
  
  required init?(coder aDecoder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }
  
  private func layout() {
    
    itemImageView.translatesAutoresizingMaskIntoConstraints = false
    itemImageView.clipsToBounds = true
    itemImageView.contentMode = .scaleAspectFit
    itemImageView.layer.masksToBounds = true
    
    titleLabel.translatesAutoresizingMaskIntoConstraints = false
    titleLabel.font = .systemFont(ofSize: 12, weight: .bold)
    titleLabel.numberOfLines = 0
    
    subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
    subtitleLabel.font = .systemFont(ofSize: 10, weight: .regular)
    subtitleLabel.textColor = .gray
    subtitleLabel.numberOfLines = 0
        
    let mainStackView = UIStackView()
    mainStackView.axis = .horizontal
    mainStackView.translatesAutoresizingMaskIntoConstraints = false
    mainStackView.spacing = 5
    
    let labelsStackView = UIStackView()
    labelsStackView.axis = .vertical
    labelsStackView.translatesAutoresizingMaskIntoConstraints = false
    labelsStackView.spacing = 3
    
    labelsStackView.addArrangedSubview(titleLabel)
    labelsStackView.addArrangedSubview(subtitleLabel)
    labelsStackView.addArrangedSubview(UIView())
    
    let itemImageContainer = UIView()
    itemImageContainer.translatesAutoresizingMaskIntoConstraints = false
    itemImageContainer.layoutMargins = UIEdgeInsets(top: 4, left: 4, bottom: 4, right: 4)
    itemImageContainer.addSubview(itemImageView)
    NSLayoutConstraint.activate([
      itemImageView.topAnchor.constraint(equalTo: itemImageContainer.layoutMarginsGuide.topAnchor),
      itemImageView.bottomAnchor.constraint(equalTo: itemImageContainer.layoutMarginsGuide.bottomAnchor),
      itemImageView.leadingAnchor.constraint(equalTo: itemImageContainer.layoutMarginsGuide.leadingAnchor),
      itemImageView.trailingAnchor.constraint(equalTo: itemImageContainer.layoutMarginsGuide.trailingAnchor),
    ])
    
    mainStackView.addArrangedSubview(itemImageContainer)
    mainStackView.addArrangedSubview(labelsStackView)
    
    contentView.addSubview(mainStackView)
    
    itemImageView.widthAnchor.constraint(equalTo: itemImageView.heightAnchor).isActive = true
    
    NSLayoutConstraint.activate([
      mainStackView.topAnchor.constraint(equalTo: contentView.topAnchor),
      mainStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
      mainStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
      mainStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
    ])
  }
  
}

class ResultsViewController: UITableViewController, HitsController {
    
  var hitsSource: HitsInteractor<ShopItem>?
  
  let cellID = "cellID"
  
  override init(style: UITableView.Style) {
    super.init(style: style)
    tableView.register(ShopItemTableViewCell.self, forCellReuseIdentifier: cellID)
  }
  
  required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }
  
  func reload() {
    tableView.reloadData()
  }
  
  func scrollToTop() {
    tableView.scrollToFirstNonEmptySection()
  }
  
  override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return hitsSource?.numberOfHits() ?? 0
  }
  
  override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    guard
      let cell = tableView.dequeueReusableCell(withIdentifier: cellID) as? ShopItemTableViewCell,
      let item = hitsSource?.hit(atIndex: indexPath.row) else {
        return .init()
    }
    cell.itemImageView.sd_setImage(with: item.image)
    cell.titleLabel.text = item.name
    cell.subtitleLabel.text = item.brand
    return cell
  }
  
  override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
    return 80
  }
  
}

Building SearchViewController

First of all, we need to declare and initialize all the necessary components. You can find the explanations in the code comments.

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
51
52
53
54
55
56
57
import Foundation
import UIKit
import InstantSearch

public class SearchViewController: UIViewController {
  
  // Constants
  let appID = "latency"
  let apiKey = "afc3dd66dd1293e2e2736a5a51b05c0a"
  let suggestionsIndex = "instantsearch_query_suggestions"
  let resultsIndex = "instant_search"
  
  // Search controller responsible for the presentation of suggestions
  let searchController: UISearchController
  
  // Query input interactor + controller
  let queryInputInteractor: QueryInputInteractor
  let textFieldController: TextFieldController
    
  // Search suggestions interactor + controller  
  let suggestionsHitsInteractor: HitsInteractor<Hit<QuerySuggestion>>
  let suggestionsViewController: QuerySuggestionsViewController
  
  // Search results interactor + controller
  let resultsHitsInteractor: HitsInteractor<ShopItem>
  let resultsViewController: ResultsViewController
  
  // Connector which aggregates all results and the suggestions fetching logic
  let multiIndexHitsConnector: MultiIndexHitsConnector
  
  override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
    
    suggestionsHitsInteractor = .init(infiniteScrolling: .off, showItemsOnEmptyQuery: true)
    suggestionsViewController = .init(style: .plain)
    
    resultsHitsInteractor = .init(infiniteScrolling: .on(withOffset: 10), showItemsOnEmptyQuery: true)
    resultsViewController = .init(style: .plain)
    
    searchController = .init(searchResultsController: suggestionsViewController)
        
    queryInputInteractor = .init()
    textFieldController = .init(searchBar: searchController.searchBar)
    
    multiIndexHitsConnector = .init(appID: appID, apiKey: apiKey, indexModules: [
        .init(indexName: suggestionsIndex, hitsInteractor: suggestionsHitsInteractor),
        .init(indexName: resultsIndex, hitsInteractor: resultsHitsInteractor)
    ])
        
    super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)    
  }
  
  required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }
      
}

Business logic

We can now add a setup function which creates the necessary connections between the components and sets them up, establishing the business logic of our view controller. It must be called after the initialization of these components.

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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
import Foundation
import UIKit
import InstantSearch

public class SearchViewController: UIViewController {
  
  // Constants
  let appID = "latency"
  let apiKey = "afc3dd66dd1293e2e2736a5a51b05c0a"
  let suggestionsIndex = "instantsearch_query_suggestions"
  let resultsIndex = "instant_search"
  
  // Search controller responsible for presentation of suggestions
  let searchController: UISearchController
  
  // Query input interactor + controller
  let queryInputInteractor: QueryInputInteractor
  let textFieldController: TextFieldController
    
  // Search suggestions interactor + controller  
  let suggestionsHitsInteractor: HitsInteractor<Hit<QuerySuggestion>>
  let suggestionsViewController: QuerySuggestionsViewController
  
  // Search results interactor + controller
  let resultsHitsInteractor: HitsInteractor<ShopItem>
  let resultsViewController: ResultsViewController
  
  // Connector which aggregates all results and suggestions fetching logic
  let multiIndexHitsConnector: MultiIndexHitsConnector
  
  override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
    
    suggestionsHitsInteractor = .init(infiniteScrolling: .off, showItemsOnEmptyQuery: true)
    suggestionsViewController = .init(style: .plain)
    
    resultsHitsInteractor = .init(infiniteScrolling: .on(withOffset: 10), showItemsOnEmptyQuery: true)
    resultsViewController = .init(style: .plain)
    
    searchController = .init(searchResultsController: suggestionsViewController)
        
    queryInputInteractor = .init()
    textFieldController = .init(searchBar: searchController.searchBar)
    
    multiIndexHitsConnector = .init(appID: appID, apiKey: apiKey, indexModules: [
        .init(indexName: suggestionsIndex, hitsInteractor: suggestionsHitsInteractor),
        .init(indexName: resultsIndex, hitsInteractor: resultsHitsInteractor)
    ])
        
    super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
    
    setup()
  }
  
  required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }
  
  private func setup() {
    
    navigationItem.searchController = searchController
    navigationItem.hidesSearchBarWhenScrolling = false
    
    queryInputInteractor.connectSearcher(multiIndexHitsConnector.searcher)
    queryInputInteractor.connectController(textFieldController)
    queryInputInteractor.connectController(suggestionsViewController)
    
    queryInputInteractor.onQuerySubmitted.subscribe(with: searchController) { (searchController, _) in
      searchController.dismiss(animated: true, completion: .none)
    }
        
    suggestionsHitsInteractor.connectController(suggestionsViewController)
    resultsHitsInteractor.connectController(resultsViewController)
    
    suggestionsViewController.isHighlightingInverted = true
    multiIndexHitsConnector.searcher.search()
  }
  
}

Setup layout

Finally, we need to add the subviews to the view controller’s view, and specify the Auto Layout constraints so that the layout looks good on any device. Add the configureUI() function to your file and call it from viewDidLoad:

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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
import Foundation
import UIKit
import InstantSearch

public class SearchViewController: UIViewController {
  
  // Constants
  let appID = "latency"
  let apiKey = "afc3dd66dd1293e2e2736a5a51b05c0a"
  let suggestionsIndex = "instantsearch_query_suggestions"
  let resultsIndex = "instant_search"
  
  // Search controller responsible for presentation of suggestions
  let searchController: UISearchController
  
  // Query input interactor + controller
  let queryInputInteractor: QueryInputInteractor
  let textFieldController: TextFieldController
    
  // Search suggestions interactor + controller  
  let suggestionsHitsInteractor: HitsInteractor<Hit<QuerySuggestion>>
  let suggestionsViewController: QuerySuggestionsViewController
  
  // Search results interactor + controller
  let resultsHitsInteractor: HitsInteractor<ShopItem>
  let resultsViewController: ResultsViewController
  
  // Connector which aggregates all results and suggestions fetching logic
  let multiIndexHitsConnector: MultiIndexHitsConnector
  
  override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
    
    suggestionsHitsInteractor = .init(infiniteScrolling: .off, showItemsOnEmptyQuery: true)
    suggestionsViewController = .init(style: .plain)
    
    resultsHitsInteractor = .init(infiniteScrolling: .on(withOffset: 10), showItemsOnEmptyQuery: true)
    resultsViewController = .init(style: .plain)
    
    searchController = .init(searchResultsController: suggestionsViewController)
        
    queryInputInteractor = .init()
    textFieldController = .init(searchBar: searchController.searchBar)
    
    multiIndexHitsConnector = .init(appID: appID, apiKey: apiKey, indexModules: [
        .init(indexName: suggestionsIndex, hitsInteractor: suggestionsHitsInteractor),
        .init(indexName: resultsIndex, hitsInteractor: resultsHitsInteractor)
    ])
        
    super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
    
    setup()
  }
  
  required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }
  
  override public func viewDidLoad() {
    super.viewDidLoad()
    configureUI()
  }
  
  private func setup() {
    
    navigationItem.searchController = searchController
    navigationItem.hidesSearchBarWhenScrolling = false
    
    queryInputInteractor.connectSearcher(multiIndexHitsConnector.searcher)
    queryInputInteractor.connectController(textFieldController)
    queryInputInteractor.connectController(suggestionsViewController)
    
    queryInputInteractor.onQuerySubmitted.subscribe(with: searchController) { (searchController, _) in
      searchController.dismiss(animated: true, completion: .none)
    }
        
    suggestionsHitsInteractor.connectController(suggestionsViewController)
    resultsHitsInteractor.connectController(resultsViewController)
    
    suggestionsViewController.isHighlightingInverted = true
    multiIndexHitsConnector.searcher.search()
  }

  private func configureUI() {
    definesPresentationContext = true
    searchController.showsSearchResultsController = true
    view.backgroundColor = .white
    addChild(resultsViewController)
    resultsViewController.didMove(toParent: self)
    resultsViewController.view.translatesAutoresizingMaskIntoConstraints = false
    view.addSubview(resultsViewController.view)
    NSLayoutConstraint.activate([
      resultsViewController.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
      resultsViewController.view.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
      resultsViewController.view.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
      resultsViewController.view.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
    ])
  }

}

Presentation

You are all set, your search experience is ready to use with Query Suggestions. Initialize your SuggestionsDemoViewController and push it in your navigation controller hierarchy.

1
2
let searchViewController = SearchViewController()
navigationController?.pushViewController(searchViewController, animated: true)

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

Did you find this page helpful?