Guides / Building Search UI / Getting started

Getting Started with SwiftUI

This guide describes how to start a SwiftUI project with InstantSearch iOS and create a full search experience from scratch.

This search experience includes:

  • A list to display search results
  • A search bar to type your query
  • Statistics about the current search
  • A facet list for filtering results

Prepare your project

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: 1f6fd3a6fb973cb08419fe7d288fa4db
  • Index name: bestbuy

These credentials give access to a preloaded dataset of products appropriate for this guide.

Create a new project

In Xcode, create a new Project:

Open Xcode, and select File -> New -> Project in the menu bar.

Project creation

Select iOS -> App template and click Next.

Project template selection

Give your application a name. Make sure that you have selected SwiftUI option in the Interface field and click Next.

Project name input

You should see the ContentView.swift file opened with a Hello World project and the live preview canvas.

Swiftui helloworld

Add project dependencies

This tutorial uses Swift Package Manager to integrate the InstantSearch library. If you prefer to use another dependency manager (Cocoapods, Carthage) please checkout the corresponding installation guides for InstantSearch.

In the menu bar select File -> Swift Packages -> Add Package Dependency.

Spm xcode menu

Paste the GitHub link for the InstantSearch library: https://github.com/algolia/instantsearch-ios

Spm url input

Pick the latest library version on the next screen, and select the InstantSearch product from the following list:

Spm products list

The InstantSearch dependency is installed and you’re all set to work on your application.

Implementation

Start by creating a classic search interface with search bar and results list. In your Xcode project, open the ContentView.swift file and import the InstantSearch library.

1
import InstantSearch

Define your record structure

Define a structure that represent a record in your index. For simplicity’s sake, the structure only provides the name of the product. It must conform to the Codable protocol to work with InstantSearch. Add the following structure definition to the ContentView.swift file:

1
2
3
struct StockItem: Codable {
  let name: String
}

Business logic

Add the AlgoliaController class containing the InstantSearch business logic components to the ContentView.swift file.

You need three components coupled with the corresponding UI controllers for the basic search experience:

  • SingleIndexSearcher performs search requests and obtains search results.
  • QueryInputInteractor handles a textual query input and triggers search requests when needed.
  • HitsInteractor stores hits and manages the pagination logic.

The setupConnections method establishes the connections between these components and their UI controllers to make them work together seamlessly.

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 AlgoliaController {
  
  let searcher: SingleIndexSearcher

  let queryInputInteractor: QueryInputInteractor
  let queryInputController: QueryInputObservableController

  let hitsInteractor: HitsInteractor<StockItem>
  let hitsController: HitsObservableController<StockItem>
  
  init() {
    self.searcher = SingleIndexSearcher(appID: "latency",
                                        apiKey: "1f6fd3a6fb973cb08419fe7d288fa4db",
                                        indexName: "bestbuy")
    self.queryInputInteractor = .init()
    self.queryInputController = .init()
    self.hitsInteractor = .init()
    self.hitsController = .init()
    setupConnections()
  }
  
  func setupConnections() {
    queryInputInteractor.connectSearcher(searcher)
    queryInputInteractor.connectController(queryInputController)
    hitsInteractor.connectSearcher(searcher)
    hitsInteractor.connectController(hitsController)
  }
      
}

The business logic is all set. It’s time to work on the UI. Focus on the ContentView structure declaration. Add QueryInputObservableController and HitsObservableController properties to the ContentView structure with an @ObservedObject property wrapper, so the view is automatically notified when the state of the search text or the hits list changed.

1
2
3
4
5
6
7
8
9
10
11
struct ContentView: View {

  @ObservedObject var queryInputController: QueryInputObservableController
  @ObservedObject var hitsController: HitsObservableController<StockItem>

  var body: some View {
      Text("Hello, world!")
          .padding()
  }

}

Add the isEditing property that binds the editing state of the search bar.

1
2
3
4
5
6
7
8
9
10
11
12
13
struct ContentView: View {

  @ObservedObject var queryInputController: QueryInputObservableController
  @ObservedObject var hitsController: HitsObservableController<StockItem>

  @State private var isEditing = false

  var body: some View {
      Text("Hello, world!")
          .padding()
  }

}

Then alter the body declaration. Replace the “Hello, world!” text view with a vertical stack containing the SearchBar configured with QueryInputObservableController properties and isEditing state binding.

1
2
3
4
5
6
7
var body: some View {
  VStack(spacing: 7) {
    SearchBar(text: $queryInputController.query,
              isEditing: $isEditing,
              onSubmit: queryInputController.submit)
  }
}

Insert the HitsList component configured with HitsObservableController and a closure constructing the hit row. The hit row is represented by a vertical stack with a text block presenting the name of the item and a Divider.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var body: some View {
  VStack(spacing: 7) {
    SearchBar(text: $queryInputController.query,
              isEditing: $isEditing,
              onSubmit: queryInputController.submit)
    HitsList(hitsController) { hit, _ in
      VStack(alignment: .leading, spacing: 10) {
        Text(hit?.name ?? "")
          .padding(.all, 10)
        Divider()
      }
    }
  }
}

Complete your search experience with a noResults trailing closure in the HitsList, that constructs the view presented in case of an empty result set, and add the navigationBarTitle string to show the navigation header on top of you search screen.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var body: some View {
  VStack(spacing: 7) {
    SearchBar(text: $queryInputController.query,
              isEditing: $isEditing,
              onSubmit: queryInputController.submit)
    HitsList(hitsController) { (hit, _) in
      VStack(alignment: .leading, spacing: 10) {
        Text(hit?.name ?? "")
          .padding(.all, 10)
        Divider()
      }
    } noResults: {
      Text("No Results")
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
  }
  .navigationBarTitle("Algolia & SwiftUI")
}

The business logic and view are ready. Connect them and try out the search experience in the live preview. Add a static instance of the AlgoliaController in the PreviewProvider. Then, in the previews declaration, instantiate the ContentView with the UI controller references in the AlgoliaController class, and embed it in the NavigationView. Launch the initial search inside the onAppear closure of the NavigationView. The resulting ContentView_Previews structure content should look as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct ContentView_Previews: PreviewProvider {
  
  static let algoliaController = AlgoliaController()
  
  static var previews: some View {
    NavigationView {
      ContentView(queryInputController: algoliaController.queryInputController,
                  hitsController: algoliaController.hitsController)
    }.onAppear {
      algoliaController.searcher.search()
    }
  }
  
}

Launch your preview to see the basic search experience in action. You should see that the results are changing on each key stroke.

Search basic input

Search basic noresults

Adding statistics

To make the search experience more user-friendly, you can give more context about the search results to your users. You can do this with different InstantSearch modules. First, add a statistics component. This component shows the hit count and the request processing time. This helps give the user a complete understanding about their search, without the need for extra interaction. Then create a StatsInteractor, which extracts the metadata from the search response, and provides an interface to present it to the user. Add StatsInteractor and StatsObservableController to AlgoliaController and connect the StatsInteractor to the Searcher in the setupConnections method. Complete the setupConnections method of the PreviewProvider with the connection between the StatsInteractor and the StatsObservableController.

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
class AlgoliaController {
  
  let searcher: SingleIndexSearcher

  let queryInputInteractor: QueryInputInteractor
  let queryInputController: QueryInputObservableController

  let hitsInteractor: HitsInteractor<StockItem>
  let hitsController: HitsObservableController<StockItem>

  let statsInteractor: StatsInteractor
  let statsController: StatsObservableController
  
  init() {
    self.searcher = SingleIndexSearcher(appID: "latency",
                                        apiKey: "1f6fd3a6fb973cb08419fe7d288fa4db",
                                        indexName: "bestbuy")
    self.queryInputInteractor = .init()
    self.hitsInteractor = .init()
    self.statsInteractor = .init()
    self.queryInputController = .init()
    self.hitsController = .init()
    self.statsController = .init()
    setupConnections()
  }
  
  func setupConnections() {
    queryInputInteractor.connectSearcher(searcher)
    queryInputInteractor.connectController(queryInputController)
    hitsInteractor.connectSearcher(searcher)
    hitsInteractor.connectController(hitsController)
    statsInteractor.connectSearcher(searcher)
    statsInteractor.connectController(statsController)
  }
      
}

The StatsInteractor receives the search statistics now, but doesn’t display it yet. Add StatsObservableController property to the ContentView and add the Text with its stats property into the stack in the middle of the SearchBar and the HitsList.

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
struct ContentView: View {
  
  @ObservedObject var queryInputController: QueryInputObservableController
  @ObservedObject var hitsController: HitsObservableController<StockItem>
  @ObservedObject var statsController: StatsObservableController

  @State private var isEditing = false
  
  var body: some View {
    VStack(spacing: 7) {
      SearchBar(text: $queryInputController.query,
                isEditing: $isEditing,
                onSubmit: queryInputController.submit)
      Text(statsController.stats)
        .fontWeight(.medium)
      HitsList(hitsController) { (hit, _) in
        VStack(alignment: .leading, spacing: 10) {
          Text(hit?.name ?? "")
            .padding(.all, /*@START_MENU_TOKEN@*/10/*@END_MENU_TOKEN@*/)
          Divider()
        }
      } noResults: {
        Text("No Results")
          .frame(maxWidth: .infinity, maxHeight: .infinity)
      }
    }
    .navigationBarTitle("Algolia & SwiftUI")
  }
      
}

Alter the PreviewProvider structure by adding a StatsObservableController in the ContentView initializer.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct ContentView_Previews: PreviewProvider {
  
  static let algoliaController = AlgoliaController()
  
  static var previews: some View {
    NavigationView {
      ContentView(queryInputController: algoliaController.queryInputController,
                  hitsController: algoliaController.hitsController,
                  statsController: algoliaController.statsController)
    }.onAppear {
      algoliaController.searcher.search()
    }
  }
  
}

Update your live preview. You should now see updated results and an updated hit count on each keystroke.

Search stats

Filter your results

With your app, you can search more than 10,000 products. However, you don’t want to scroll to the bottom of the list to find the exact product you’re looking for. One can more accurately filter the results by making use of the RefinementList components. This section explains how to build a filter that allows to filter products by their category. First, add a FilterState component to the AlgoliaController. This component provides a convenient way to manage the state of your filters. Add the manufacturer refinement attribute. Next, add the FacetListInteractor, which stores the list of facets retrieved with search result. Finally, add the connections between SingleIndexSearcher, FilterState and FacetListInteractor in the setupConnections method. Complete the setupConnections method of the PreviewProvider with the connection between the FacetListInteractor and the FacetListObservableController. To improve the user experience, the connection includes the FacetListPresenter parameter that pins the selected facets to the top of the list and uses facet count value as the second ranking criteria, so that the facets with the most hits show up higher.

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
class AlgoliaController {
  
  let searcher: SingleIndexSearcher

  let queryInputInteractor: QueryInputInteractor
  let queryInputController: QueryInputObservableController

  let hitsInteractor: HitsInteractor<StockItem>
  let hitsController: HitsObservableController<StockItem>

  let statsInteractor: StatsInteractor
  let statsController: StatsObservableController

  let filterState: FilterState
  
  let facetListInteractor: FacetListInteractor
  let facetListController: FacetListObservableController

  init() {
    self.searcher = SingleIndexSearcher(appID: "latency",
                                        apiKey: "1f6fd3a6fb973cb08419fe7d288fa4db",
                                        indexName: "bestbuy")
    self.queryInputInteractor = .init()
    self.queryInputController = .init()
    self.hitsInteractor = .init()
    self.hitsController = .init()
    self.statsInteractor = .init()
    self.statsController = .init()
    self.filterState = .init()
    self.facetListInteractor = .init()
    self.facetListController = .init()
    setupConnections()
  }
  
  func setupConnections() {
    queryInputInteractor.connectSearcher(searcher)
    queryInputInteractor.connectController(queryInputController)
    hitsInteractor.connectSearcher(searcher)
    hitsInteractor.connectController(hitsController)
    statsInteractor.connectSearcher(searcher)
    statsInteractor.connectController(statsController)
    searcher.connectFilterState(filterState)
    facetListInteractor.connectSearcher(searcher, with: "manufacturer")
    facetListInteractor.connectFilterState(filterState, with: "manufacturer", operator: .or)
    facetListInteractor.connectController(facetListController, with: FacetListPresenter(sortBy: [.isRefined, .count(order: .descending)]))
  }
      
}

In the ContentView add a new state flag isPresentingFacets, which defines if the facet list is presented.

1
@State private var isPresentingFacets = false

Then declare a function that constructs the button which triggers the appearance of the facet list by toggling the isPresentingFacets flag.

1
2
3
4
5
6
7
8
9
private func facetsButton() -> some View {
  Button(action: {
    isPresentingFacets.toggle()
  },
  label: {
    Image(systemName: "line.horizontal.3.decrease.circle")
      .font(.title)
  })
}

Add the FacetListObservableController to the ContentView and a facets function constructing the facet list view.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@ObservedObject var facetListController: FacetListObservableController

@ViewBuilder
private func facets() -> some View {
  NavigationView {
    FacetList(facetListController) { facet, isSelected in
      VStack {
        FacetRow(facet: facet, isSelected: isSelected)
        Divider()
      }
    } noResults: {
      Text("No facet found")
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
    .navigationBarTitle("Brand")
  }
}

The resulting ContentView code should look 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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
  struct ContentView: View {
    
    @ObservedObject var queryInputController: QueryInputObservableController
    @ObservedObject var hitsController: HitsObservableController<StockItem>
    @ObservedObject var statsController: StatsObservableController
    @ObservedObject var facetListController: FacetListObservableController

    @State private var isEditing = false
    @State private var isPresentingFacets = false
    
    var body: some View {
      VStack(spacing: 7) {
        SearchBar(text: $queryInputController.query,
                  isEditing: $isEditing,
                  onSubmit: queryInputController.submit)
        Text(statsController.stats)
          .fontWeight(.medium)
        HitsList(hitsController) { (hit, _) in
          VStack(alignment: .leading, spacing: 10) {
            Text(hit?.name ?? "")
              .padding(.all, /*@START_MENU_TOKEN@*/10/*@END_MENU_TOKEN@*/)
            Divider()
          }
        } noResults: {
          Text("No Results")
            .frame(maxWidth: .infinity, maxHeight: .infinity)
        }
      }
      .navigationBarTitle("Algolia & SwiftUI")
      .navigationBarItems(trailing: facetsButton())
      .sheet(isPresented: $isPresentingFacets, content: facets)
    }
    
    @ViewBuilder
    private func facets() -> some View {
      NavigationView {
        FacetList(facetListController) { facet, isSelected in
          VStack {
            FacetRow(facet: facet, isSelected: isSelected)
            Divider()
          }
        } noResults: {
          Text("No facet found")
            .frame(maxWidth: .infinity, maxHeight: .infinity)
        }
        .navigationBarTitle("Brand")
      }
    }
    
    private func facetsButton() -> some View {
      Button(action: {
        isPresentingFacets.toggle()
      },
      label: {
        Image(systemName: "line.horizontal.3.decrease.circle")
          .font(.title)
      })
    }
    
}

Complete the initializer of the ContentView with the FacetListObservableController.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct ContentView_Previews: PreviewProvider {
  
  static let algoliaController = AlgoliaController()
  
  static var previews: some View {
    NavigationView {
      ContentView(queryInputController: algoliaController.queryInputController,
                  hitsController: algoliaController.hitsController,
                  statsController: algoliaController.statsController,
                  facetListController: algoliaController.facetListController)
    }.onAppear {
      algoliaController.searcher.search()
    }
  }
  
}

Update the live preview. Now you see a filter button on top right of your screen. Click it to present the refinements list, select one or more refinements, and then dismiss the refinements list by swiping it down to see the filtered result.

Search filter button

Search facet list

Going further

Your users can enter a query, and your application shows them results as they type. It also provides a possibility to filter the results even further using RefinementList. This is a great start, but you can go even further and improve on that.

Did you find this page helpful?