Building Query Suggestions UI
On this page
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:
- Create a Query Suggestions index from your main index.
- Implement a Multi-Index search experience using both indices.
- When clicking on a suggestion, set the query to the chosen suggestion.
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.
Once the user taps the search bar, it presents a list of Query Suggestions for an empty query (the most popular queries).
On each keystroke the list of suggestions is updated simultaneously.
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.
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.