Guides / Building Search UI / UI & UX patterns

Multi-index search, or federated search, lets you search multiple data sources at once with the same query and gather results in a single search experience. This is a common pattern when building autocomplete search experiences, but also to offer centralized access to multiple sources of content developed and curated independently.

Multi-index search can also help you achieve complex UIs that display the content of the same index in several ways, for example to surface top-rated items before the list of results.

Synchronize two InstantSearch indices

The following example uses a single search view controller to search in two indices. This is achieved through the aggregation of two HitsSearchers by the MultiSearcher. Each of them target a specific index: the first one is mobile_demo_actors and the second is mobile_demo_movies. The results are presented in the dedicated sections of a UITableViewController instance. The source code of this example is on GitHub.

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
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
struct Movie: Codable {
  let title: String
}

struct Actor: Codable {
  let name: String
}

class SearchViewController: UIViewController {
  
  let searchController: UISearchController
  
  let searchBoxConnector: SearchBoxConnector
  let textFieldController: TextFieldController

  let searcher: MultiSearcher
  let actorHitsInteractor: HitsInteractor<Hit<Actor>>
  let movieHitsInteractor: HitsInteractor<Hit<Movie>>
  
  let searchResultsController: SearchResultsController
    
  override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
    searcher = .init(appID: "latency",
                      apiKey: "1f6fd3a6fb973cb08419fe7d288fa4db")
    searchResultsController = .init()
    actorHitsInteractor = .init(infiniteScrolling: .off)
    movieHitsInteractor = .init(infiniteScrolling: .off)
    searchController = .init(searchResultsController: searchResultsController)
    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")
  }
  
  override func viewDidLoad() {
    super.viewDidLoad()
    
    configureUI()
    
    let actorsSearcher = searcher.addHitsSearcher(indexName: "mobile_demo_actors")
    actorHitsInteractor.connectSearcher(actorsSearcher)
    searchResultsController.actorsHitsInteractor = actorHitsInteractor
    
    let moviesSearcher = searcher.addHitsSearcher(indexName: "mobile_demo_movies")
    movieHitsInteractor.connectSearcher(moviesSearcher)
    searchResultsController.moviesHitsInteractor = movieHitsInteractor
    
    searcher.search()
  }
  
  override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    searchController.isActive = true
  }
  
  func configureUI() {
    view.backgroundColor = .white
    definesPresentationContext = true
    searchController.hidesNavigationBarDuringPresentation = false
    searchController.showsSearchResultsController = true
    searchController.automaticallyShowsCancelButton = false
    navigationItem.searchController = searchController
  }
  
}

class SearchResultsController: UITableViewController {
  
  enum Section: Int, CaseIterable {
    
    case actors
    case movies
    
    var title: String {
      switch self {
      case .actors:
        return "Actors"
      case .movies:
        return "Movies"
      }
    }
                          
    var image: UIImage? {
      switch self {
      case .movies:
        return UIImage(systemName: "film")
      case .actors:
        return UIImage(systemName: "person.circle")
      }
    }
    
    init?(section: Int) {
      self.init(rawValue: section)
    }
    
    init?(indexPath: IndexPath) {
      self.init(section: indexPath.section)
    }
    
  }

  let cellReuseIdentifier = "cellID"
  
  func numberOfHits(in section: Section) -> Int {
    switch section {
    case .actors:
      return actorsHitsInteractor?.numberOfHits() ?? 0
    case .movies:
      return moviesHitsInteractor?.numberOfHits() ?? 0
    }
  }
  
  func cellLabel(forRowIndex rowIndex: Int, at section: Section) -> NSAttributedString? {
    switch section {
    case .actors:
      return actorsHitsInteractor?.hit(atIndex: rowIndex)?.hightlightedString(forKey: "name").map { highlightedString in
        NSAttributedString(highlightedString: highlightedString, attributes: [.font: UIFont.systemFont(ofSize: 17, weight: .bold)])
      }
    case .movies:
      return moviesHitsInteractor?.hit(atIndex: rowIndex)?.hightlightedString(forKey: "title").map { highlightedString in
        NSAttributedString(highlightedString: highlightedString, attributes: [.font: UIFont.systemFont(ofSize: 17, weight: .bold)])
      }
    }
  }
      
  weak var actorsHitsInteractor: HitsInteractor<Hit<Actor>>? {
    didSet {
      oldValue?.onResultsUpdated.cancelSubscription(for: tableView)
      guard let interactor = actorsHitsInteractor else { return }
      interactor.onResultsUpdated.subscribe(with: tableView) { tableView, _ in
        tableView.reloadData()
      }.onQueue(.main)
    }
  }
  
  weak var moviesHitsInteractor: HitsInteractor<Hit<Movie>>? {
    didSet {
      oldValue?.onResultsUpdated.cancelSubscription(for: tableView)
      guard let interactor = moviesHitsInteractor else { return }
      interactor.onResultsUpdated.subscribe(with: tableView) { tableView, _ in
        tableView.reloadData()
      }.onQueue(.main)
    }
  }
  
  override func viewDidLoad() {
    super.viewDidLoad()
    tableView.register(UITableViewCell.self, forCellReuseIdentifier: cellReuseIdentifier)
  }
  
  override func numberOfSections(in tableView: UITableView) -> Int {
    return Section.allCases.count
  }
  
  override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    guard let section = Section(section: section) else { return 0 }
    return numberOfHits(in: section)
  }
  
  override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseIdentifier, for: indexPath)
    guard let section = Section(indexPath: indexPath) else { return cell }
    cell.tintColor = .lightGray
    cell.imageView?.image = section.image
    cell.textLabel?.attributedText = cellLabel(forRowIndex: indexPath.row, at: section)
    return cell
  }
  
  override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
    guard let section = Section(section: section), numberOfHits(in: section) != 0 else { return nil }
    return section.title
  }
  
  override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    guard let section = Section(indexPath: indexPath) else { return }
    switch section {
    case .actors:
      if let _ = actorsHitsInteractor?.hit(atIndex: indexPath.row) {
        // Handle actor selection
      }
    case .movies:
      if let _ = moviesHitsInteractor?.hit(atIndex: indexPath.row) {
        // Handle movie selection
      }
    }
  }
  
}

You can find the complete example on GitHub.

Combine search for hits and facets values

This example uses a single search view controller to search in the index and facet values for attribute of the same index. This is achieved through the aggregation of the HitsSearcher and the FacetSearcher by the MultiSearcher. The results are presented in the dedicated sections of a UITableViewController instance. The source code of this example is on GitHub.

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
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
struct Item: Codable {
  let name: String
}

class SearchViewController: UIViewController {
  
  let searchController: UISearchController
  
  let searchBoxConnector: SearchBoxConnector
  let textFieldController: TextFieldController

  let searcher: MultiSearcher
  let categoriesInteractor: FacetListInteractor
  let hitsInteractor: HitsInteractor<Item>
  
  let searchResultsController: SearchResultsController
    
  override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
    searcher = .init(appID: "latency",
                      apiKey: "6be0576ff61c053d5f9a3225e2a90f76")
    searchResultsController = .init(style: .plain)
    categoriesInteractor = .init()
    hitsInteractor = .init(infiniteScrolling: .off)
    searchController = .init(searchResultsController: searchResultsController)
    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")
  }
  
  override func viewDidLoad() {
    super.viewDidLoad()
    
    configureUI()
    
    let facetsSearcher = searcher.addFacetsSearcher(indexName: "instant_search",
                                                    attribute: "categories")
    categoriesInteractor.connectFacetSearcher(facetsSearcher)
    searchResultsController.categoriesInteractor = categoriesInteractor
    
    let hitsSearchers = searcher.addHitsSearcher(indexName: "instant_search")
    hitsInteractor.connectSearcher(hitsSearchers)
    searchResultsController.hitsInteractor = hitsInteractor
    
    searcher.search()
  }
  
  override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    searchController.isActive = true
  }
  
  func configureUI() {
    view.backgroundColor = .white
    definesPresentationContext = true
    searchController.hidesNavigationBarDuringPresentation = false
    searchController.showsSearchResultsController = true
    searchController.automaticallyShowsCancelButton = false
    navigationItem.searchController = searchController
  }
  
}

class SearchResultsController: UITableViewController {
  
  var didSelectSuggestion: ((String) -> Void)?
  
  enum Section: Int, CaseIterable {
    case categories
    case hits
    
    var title: String {
      switch self {
      case .categories:
        return "Categories"
      case .hits:
        return "Hits"
      }
    }
    
    var cellID: String {
      switch self {
      case .categories:
        return "categories"
      case .hits:
        return "hits"
      }
    }
                
  }

  weak var categoriesInteractor: FacetListInteractor? {
    didSet {
      oldValue?.onResultsUpdated.cancelSubscription(for: tableView)
      guard let interactor = categoriesInteractor else { return }
      interactor.onResultsUpdated.subscribe(with: tableView) { tableView, _ in
        tableView.reloadData()
      }.onQueue(.main)
    }
  }
  
  weak var hitsInteractor: HitsInteractor<Item>? {
    didSet {
      oldValue?.onResultsUpdated.cancelSubscription(for: tableView)
      guard let interactor = hitsInteractor else { return }
      interactor.onResultsUpdated.subscribe(with: tableView) { tableView, _ in
        tableView.reloadData()
      }.onQueue(.main)
    }
  }
  
  override func viewDidLoad() {
    super.viewDidLoad()
    tableView.register(UITableViewCell.self, forCellReuseIdentifier: Section.categories.cellID)
  }
  
  override func numberOfSections(in tableView: UITableView) -> Int {
    return Section.allCases.count
  }
  
  override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    guard let section = Section(rawValue: section) else { return 0 }
    switch section {
    case .categories:
      return categoriesInteractor?.items.count ?? 0
    case .hits:
      return hitsInteractor?.numberOfHits() ?? 0
    }
  }
  
  override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    guard let section = Section(rawValue: indexPath.section) else { return UITableViewCell() }

    let cell = tableView.dequeueReusableCell(withIdentifier: Section.categories.cellID, for: indexPath)
    
    switch section {
    case .categories:
      if let category = categoriesInteractor?.items[indexPath.row] {
        cell.textLabel?.text = category.value
      }
    case .hits:
      if let hit = hitsInteractor?.hit(atIndex: indexPath.row) {
        cell.textLabel?.text = hit.name
      }
    }
    
    return cell
  }
  
  override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
    guard let section = Section(rawValue: section) else { return nil }
    switch section {
    case .categories where categoriesInteractor?.items.count ?? 0 == 0:
      return nil
    case .hits where hitsInteractor?.numberOfHits() ?? 0 == 0:
      return nil
    default:
      return section.title
    }
  }
  
  override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    guard let section = Section(rawValue: indexPath.section) else { return }
    switch section {
    case .hits:
      // Handle hit selection
      break

    case .categories:
      // Handle category selection
      break
    }
  }

}
Did you find this page helpful?