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:
When users tap the search box, a list of query suggestions are shown (the most popular for an empty query):
On each keystroke, the list of suggestions is updated:
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:
- 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 theSearchViewModel
to display search results, query suggestions, and other relevant information. TheSearchView
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 theCodable
protocol, theItem
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
andsuggestionsSearcher
, of typeHitsSearcher
. These searchers will be responsible for querying the Algolia indices for searchable items and query suggestions, respectively. - In the
init
method, theappID
andapiKey
are set to the appropriate values for your Algolia app. Then, theitemsSearcher
andsuggestionsSearcher
instances are created, passing theappID
,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 ofQuerySuggestion
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 aCancellable
object to hold the subscription to thesuggestionsSearcher
results. - Inside the init method, the
suggestionsSubscription
is assigned the subscription to thesuggestionsSearcher
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, thecancel()
method is called onsuggestionsSubscription
to unsubscribe and cancel the subscription when theSearchViewModel
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.