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

Build a Query Suggestions UI with InstantSearch iOS and UIKit

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

Query suggestions are a specific kind of multi-index interface:

  • The main search interface will use a regular index.
  • As users type a phrase, suggestions from your Query Suggestions index are displayed.

To display the suggestions in your iOS app, use Algolia’s MultiHitsViewModel component. Read on for an example of how to display a search bar with instant results and query suggestions “as you type”.

Usage

To display the suggestions:

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: 927c3fe76d4b52c5a2912973f35a3077
  • Results index name: STAGING_native_ecom_demo_products
  • Suggestions index name: STAGING_native_ecom_demo_products_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 select a suggestion from the list, it replaces the query in the search box, and the suggestions viewcontroller disappears. The results viewcontroller presents search results for the selected suggestion:

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

  • A basic query suggestions viewcontroller is provided out-of-box with the InstantSearch library.
  • The query suggestions view controller is a hits viewcontroller that uses the QuerySuggestion object provided by the InstantSearch Core library as a search record.

Project structure

Algolia’s query suggestions uses three view controllers:

  • QuerySuggestionsDemoViewController: main view controller presenting the search experience,
  • SuggestionsTableViewController: child view controller presenting the Query Suggestions,
  • StoreItemsTableViewController: child view controller presenting the search results.

Model object

Start with declaration of the StoreItem model object which represents the items in the index.

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
struct StoreItem: Codable {

  let name: String
  let brand: String?
  let description: String?
  let images: [URL]
  let price: Double?

  enum CodingKeys: String, CodingKey {
    case name
    case brand
    case description
    case images = "image_urls"
    case price
  }

  enum PriceCodingKeys: String, CodingKey {
    case value
  }

  init(from decoder: Decoder) throws {
    let container = try decoder.container(keyedBy: CodingKeys.self)
    self.name = try container.decode(String.self, forKey: .name)
    self.brand = try? container.decode(String.self, forKey: .brand)
    self.description = try? container.decode(String.self, forKey: .description)
    if let rawImages = try? container.decode([String].self, forKey: .images) {
      self.images = rawImages.compactMap(URL.init)
    } else {
      self.images = []
    }
    if
      let priceContainer = try? container.nestedContainer(keyedBy: PriceCodingKeys.self, forKey: .price),
      let price = try? priceContainer.decode(Double.self, forKey: .value) {
        self.price = price
    } else {
      self.price = .none
    }
  }

  func encode(to encoder: Encoder) throws {
    var container = encoder.container(keyedBy: CodingKeys.self)
    try container.encode(name, forKey: .name)
    try container.encode(brand, forKey: .brand)
    try container.encode(description, forKey: .description)
    try container.encode(images, forKey: .images)
    try container.encode(price, forKey: .price)
  }

}

Result views

ProductTableViewCell is a subclass of UITableViewCell for visually displaying store items in the list of results. This view uses the SDWebImage library for asynchronous image loading. To use ProductTableViewCell, you need to add the SDWebImage library to 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
import Foundation
import UIKit
import SDWebImage

class ProductTableViewCell: UITableViewCell {

  let itemImageView: UIImageView
  let titleLabel: UILabel
  let subtitleLabel: UILabel
  let priceLabel: UILabel

  let mainStackView: UIStackView
  let labelsStackView: UIStackView

  override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
    itemImageView = .init()
    titleLabel = .init()
    subtitleLabel = .init()
    mainStackView = .init()
    labelsStackView = .init()
    priceLabel = .init()
    super.init(style: style, reuseIdentifier: reuseIdentifier)
    layout()
    backgroundColor = .white
  }

  required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }

  private func layout() {
    itemImageView.sd_imageIndicator = SDWebImageActivityIndicator.gray
    itemImageView.translatesAutoresizingMaskIntoConstraints = false
    itemImageView.clipsToBounds = true
    itemImageView.contentMode = .scaleAspectFit
    itemImageView.layer.masksToBounds = true

    titleLabel.translatesAutoresizingMaskIntoConstraints = false
    titleLabel.font = .systemFont(ofSize: 15, weight: .bold)
    titleLabel.numberOfLines = 1

    subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
    subtitleLabel.font = .systemFont(ofSize: 13, weight: .regular)
    subtitleLabel.textColor = .gray
    subtitleLabel.numberOfLines = 1

    priceLabel.translatesAutoresizingMaskIntoConstraints = false
    priceLabel.font = .systemFont(ofSize: 14)

    labelsStackView.axis = .vertical
    labelsStackView.translatesAutoresizingMaskIntoConstraints = false
    labelsStackView.spacing = 3
    labelsStackView.addArrangedSubview(titleLabel)
    labelsStackView.addArrangedSubview(subtitleLabel)
    labelsStackView.addArrangedSubview(priceLabel)
    labelsStackView.addArrangedSubview(UIView())

    mainStackView.axis = .horizontal
    mainStackView.translatesAutoresizingMaskIntoConstraints = false
    mainStackView.spacing = 20
    mainStackView.addArrangedSubview(itemImageView)
    mainStackView.addArrangedSubview(labelsStackView)

    contentView.addSubview(mainStackView)
    contentView.layoutMargins = .init(top: 5, left: 3, bottom: 5, right: 3)

    mainStackView.pin(to: contentView.layoutMarginsGuide)
    itemImageView.widthAnchor.constraint(equalTo: itemImageView.heightAnchor).isActive = true
  }

}

Define a ProductTableViewCell extension. Its setup method configures a cell with a StoreItem instance:

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
extension ProductTableViewCell {

  func setup(with productHit: Hit<StoreItem>) {
    let product = productHit.object
    itemImageView.sd_setImage(with: product.images.first)

    if let highlightedName = productHit.hightlightedString(forKey: "name") {
      titleLabel.attributedText = NSAttributedString(highlightedString: highlightedName,
                                                     attributes: [
                                                      .foregroundColor: UIColor.tintColor])
    } else {
      titleLabel.text = product.name
    }

    if let highlightedDescription = productHit.hightlightedString(forKey: "brand") {
      subtitleLabel.attributedText = NSAttributedString(highlightedString: highlightedDescription,
                                                        attributes: [
                                                          .foregroundColor: UIColor.tintColor
                                                        ])
    } else {
      subtitleLabel.text = product.brand
    }

    if let price = product.price {
      priceLabel.text = "\(price) €"
    }

  }

}

Results view controller

Algolia doesn’t provide a ready-to-use results view controller, but you can create one with the tools in the InstantSearch library by copying and pasting the following code to your project.

Read more about Hits in the API reference.

Add StoreItemsTableViewController, a subclass of UITableViewController, which implements the HitsController protocol. This view controller presents the search results using the previously declared ProductTableViewCell.

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
import Foundation
import UIKit
import InstantSearch

class StoreItemsTableViewController: UITableViewController, HitsController {

  var hitsSource: HitsInteractor<Hit<StoreItem>>?

  var didSelect: ((Hit<StoreItem>) -> Void)?

  let cellIdentifier = "cellID"

  override func viewDidLoad() {
    super.viewDidLoad()
    tableView.register(ProductTableViewCell.self, forCellReuseIdentifier: cellIdentifier)
  }

  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: cellIdentifier, for: indexPath) as? ProductTableViewCell else {
      return UITableViewCell()
    }
    guard let hit = hitsSource?.hit(atIndex: indexPath.row) else {
      return cell
    }
    cell.setup(with: hit)
    return cell
  }

  override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
    return 80
  }

  override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    if let hit = hitsSource?.hit(atIndex: indexPath.row) {
      didSelect?(hit)
    }
  }

}

Suggestions

The suggestion model is provided by InstantSearch iOS and called QuerySuggestion. Define the SearchSuggestionTableViewCell displaying a search suggestion.

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
class SearchSuggestionTableViewCell: UITableViewCell {

  var didTapTypeAheadButton: (() -> Void)?

  private func typeAheadButton() -> UIButton {
    let typeAheadButton = UIButton()
    typeAheadButton.setImage(UIImage(systemName: "arrow.up.left"), for: .normal)
    typeAheadButton.sizeToFit()
    typeAheadButton.addTarget(self, action: #selector(typeAheadButtonTap), for: .touchUpInside)
    return typeAheadButton
  }

  override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
    super.init(style: style, reuseIdentifier: reuseIdentifier)
    accessoryView = typeAheadButton()
    imageView?.image = UIImage(systemName: "magnifyingglass")
    tintColor = .lightGray
  }

  required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }

  @objc func typeAheadButtonTap(_ sender: UIButton) {
    didTapTypeAheadButton?()
  }

}

Define an extension of SearchSuggestionsTableViewCell to setup the cell with a QuerySuggestion instance.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
extension SearchSuggestionTableViewCell {

  func setup(with querySuggestion: QuerySuggestion) {
    guard let textLabel = textLabel else { return }
    textLabel.attributedText = querySuggestion
      .highlighted
      .flatMap(HighlightedString.init)
      .flatMap { NSAttributedString(highlightedString: $0,
                                    inverted: true,
                                    attributes: [.font: UIFont.boldSystemFont(ofSize: textLabel.font.pointSize)])
    }
  }

}

Define the SuggestionsTableViewController, a subclass of UITableViewController implementing HitsController protocol which displays the list of suggestions. As a selection of a suggestion or type ahead button click might trigger the textual query change, this view controller also conform to SearchBoxController protocol, so it can be easily connected to SearchBoxInteractor or SearchBoxConnector.

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
class SuggestionsTableViewController: UITableViewController, HitsController, SearchBoxController {

  var onQueryChanged: ((String?) -> Void)?
  var onQuerySubmitted: ((String?) -> Void)?

  public var hitsSource: HitsInteractor<QuerySuggestion>?

  let cellID = "сellID"

  public override init(style: UITableView.Style) {
    super.init(style: style)
    tableView.register(SearchSuggestionTableViewCell.self, forCellReuseIdentifier: cellID)
  }

  required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }

  func setQuery(_ query: String?) {
    // not applicable
  }

  public override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return hitsSource?.numberOfHits() ?? 0
  }

  public override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    guard let cell = tableView.dequeueReusableCell(withIdentifier: cellID) as? SearchSuggestionTableViewCell else { return .init() }

    if let suggestion = hitsSource?.hit(atIndex: indexPath.row) {
      cell.setup(with: suggestion)
      cell.didTapTypeAheadButton = {
        self.onQueryChanged?(suggestion.query)
      }
    }

    return cell
  }

  public override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    guard let suggestion = hitsSource?.hit(atIndex: indexPath.row) else { return }
    onQuerySubmitted?(suggestion.query)
  }

}

Building the main view controller

The last step is the declaration of the QuerySuggestionsDemoViewController. It includes the business logic performing the multi-index search in the products and suggestions indices simultaneously. First, declare, and initialize all the necessary 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
import Foundation
import UIKit
import InstantSearch

public class QuerySuggestionsDemoViewController: UIViewController {

  let searchController: UISearchController
  let searcher: MultiSearcher

  let searchBoxConnector: SearchBoxConnector
  let textFieldController: TextFieldController

  let suggestionsHitsConnector: HitsConnector<QuerySuggestion>
  let suggestionsViewController: SuggestionsTableViewController

  let resultsHitsConnector: HitsConnector<Hit<StoreItem>>
  let resultsViewController: StoreItemsTableViewController

  override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {

    searcher = .init(appID: "latency",
                     apiKey: "927c3fe76d4b52c5a2912973f35a3077")

    let suggestionsSearcher = searcher.addHitsSearcher(indexName: "STAGING_native_ecom_demo_products_query_suggestions")
    suggestionsViewController = .init(style: .plain)
    suggestionsHitsConnector = HitsConnector(searcher: suggestionsSearcher,
                                             interactor: .init(infiniteScrolling: .off),
                                             controller: suggestionsViewController)

    let resultsSearcher = searcher.addHitsSearcher(indexName: "STAGING_native_ecom_demo_products")
    resultsViewController = .init(style: .plain)
    resultsHitsConnector = HitsConnector(searcher: resultsSearcher,
                                         interactor: .init(),
                                         controller: resultsViewController)

    searchController = .init(searchResultsController: suggestionsViewController)

    textFieldController = .init(searchBar: searchController.searchBar)
    searchBoxConnector = .init(searcher: searcher,
                                controller: textFieldController)

    super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
  }

  required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }

}

Business logic

Now add a setup function to create the necessary connections between the components and set them up, establishing the business logic of your 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
import Foundation
import UIKit
import InstantSearch

public class QuerySuggestionsDemoViewController: UIViewController {

  let searchController: UISearchController
  let searcher: MultiSearcher

  let searchBoxConnector: SearchBoxConnector
  let textFieldController: TextFieldController

  let suggestionsHitsConnector: HitsConnector<QuerySuggestion>
  let suggestionsViewController: SuggestionsTableViewController

  let resultsHitsConnector: HitsConnector<Hit<StoreItem>>
  let resultsViewController: StoreItemsTableViewController

  override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {

    searcher = .init(appID: "latency",
                     apiKey: "927c3fe76d4b52c5a2912973f35a3077")

    let suggestionsSearcher = searcher.addHitsSearcher(indexName: "STAGING_native_ecom_demo_products_query_suggestions")
    suggestionsViewController = .init(style: .plain)
    suggestionsHitsConnector = HitsConnector(searcher: suggestionsSearcher,
                                             interactor: .init(infiniteScrolling: .off),
                                             controller: suggestionsViewController)

    let resultsSearcher = searcher.addHitsSearcher(indexName: "STAGING_native_ecom_demo_products")
    resultsViewController = .init(style: .plain)
    resultsHitsConnector = HitsConnector(searcher: resultsSearcher,
                                         interactor: .init(),
                                         controller: resultsViewController)

    searchController = .init(searchResultsController: suggestionsViewController)

    textFieldController = .init(searchBar: searchController.searchBar)
    searchBoxConnector = .init(searcher: searcher,
                               controller: textFieldController)

    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

    searchController.showsSearchResultsController = true

    addChild(resultsViewController)
    resultsViewController.didMove(toParent: self)

    searchBoxConnector.connectController(suggestionsViewController)
    searchBoxConnector.interactor.onQuerySubmitted.subscribe(with: searchController) { (searchController, _) in
      searchController.dismiss(animated: true, completion: .none)
    }

    searcher.search()
  }

}

Setup layout

Finally, add the subviews to the view controller, and specify the Auto Layout constraints so that the display 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
import Foundation
import UIKit
import InstantSearch

public class QuerySuggestionsDemoViewController: UIViewController {

  let searchController: UISearchController
  let searcher: MultiSearcher

  let searchBoxConnector: SearchBoxConnector
  let textFieldController: TextFieldController

  let suggestionsHitsConnector: HitsConnector<QuerySuggestion>
  let suggestionsViewController: SuggestionsTableViewController

  let resultsHitsConnector: HitsConnector<Hit<StoreItem>>
  let resultsViewController: StoreItemsTableViewController

  override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {

    searcher = .init(appID: "latency",
                     apiKey: "927c3fe76d4b52c5a2912973f35a3077")

    let suggestionsSearcher = searcher.addHitsSearcher(indexName: "STAGING_native_ecom_demo_products")
    suggestionsViewController = .init(style: .plain)
    suggestionsHitsConnector = HitsConnector(searcher: suggestionsSearcher,
                                             interactor: .init(infiniteScrolling: .off),
                                             controller: suggestionsViewController)

    let resultsSearcher = searcher.addHitsSearcher(indexName: "STAGING_native_ecom_demo_products_query_suggestions")
    resultsViewController = .init(style: .plain)
    resultsHitsConnector = HitsConnector(searcher: resultsSearcher,
                                         interactor: .init(),
                                         controller: resultsViewController)

    searchController = .init(searchResultsController: suggestionsViewController)

    textFieldController = .init(searchBar: searchController.searchBar)
    searchBoxConnector = .init(searcher: searcher,
                               controller: textFieldController)

    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

    searchController.showsSearchResultsController = true

    addChild(resultsViewController)
    resultsViewController.didMove(toParent: self)

    searchBoxConnector.connectController(suggestionsViewController)
    searchBoxConnector.interactor.onQuerySubmitted.subscribe(with: searchController) { (searchController, _) in
      searchController.dismiss(animated: true, completion: .none)
    }

    searcher.search()
  }

  private func configureUI() {
    title = "Query Suggestions"
    view.backgroundColor = .white
    let resultsView = resultsViewController.view!
    resultsView.translatesAutoresizingMaskIntoConstraints = false
    view.addSubview(resultsView)
    NSLayoutConstraint.activate([
      resultsView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
      resultsView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
      resultsView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
      resultsView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
    ])
  }

}

Presentation

Your query suggestions search experience is now ready to use. Initialize your QuerySuggestionsDemoViewController and push it in your navigation controller hierarchy.

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

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

Going further

Checkout advanced query suggestions experiences:

Query Suggestions with hits

Find the source code of this example on GitHub.

Query Suggestions with categories

Find the source code of this example on GitHub.

Query Suggestions with recent searches

Find the source code of this example on GitHub.

Did you find this page helpful?