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 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 LazyColumn. The source code of this example is on GitHub.

Search data models

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Serializable
data class Movie(
    val title: String,
    override val objectID: ObjectID,
    override val _highlightResult: JsonObject?
) : Indexable, Highlightable {

    val highlightedTitle
        get() = getHighlight(Attribute("title"))
}

@Serializable
data class Actor(
    val name: String,
    override val objectID: ObjectID,
    override val _highlightResult: JsonObject?
) : Indexable, Highlightable {

    val highlightedName
        get() = getHighlight(Attribute("name"))
}

Search view model

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
class MainViewModel : ViewModel() {

    private val multiSearcher = MultiSearcher(
        applicationID = ApplicationID("latency"),
        apiKey = APIKey("1f6fd3a6fb973cb08419fe7d288fa4db")
    )
    private val actorsSearcher = multiSearcher.addHitsSearcher(IndexName("mobile_demo_actors"))
    private val moviesSearcher = multiSearcher.addHitsSearcher(IndexName("mobile_demo_movies"))
    private val searchBoxConnector = SearchBoxConnector(multiSearcher)
    private val connections = ConnectionHandler(searchBoxConnector)

    val searchBoxState = SearchBoxState()
    val actorsState = HitsState<Actor>()
    val moviesState = HitsState<Movie>()

    init {
        connections += searchBoxConnector.connectView(searchBoxState)
        connections += actorsSearcher.connectHitsView(actorsState) { it.hits.deserialize(Actor.serializer()) }
        connections += moviesSearcher.connectHitsView(moviesState) { it.hits.deserialize(Movie.serializer()) }
        multiSearcher.searchAsync()
    }

    override fun onCleared() {
        super.onCleared()
        multiSearcher.cancel()
        connections.clear()
    }
}

Search UI

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
@Composable
fun SearchScreen(
    modifier: Modifier = Modifier,
    searchBoxState: SearchBoxState,
    actorsState: HitsState<Actor>,
    moviesState: HitsState<Movie>,
) {
    Scaffold(modifier = modifier, topBar = {
        SearchBox(
            modifier = Modifier
                .fillMaxWidth()
                .padding(12.dp),
            searchBoxState = searchBoxState,
        )
    }) { paddings ->
        LazyColumn(
            Modifier
                .padding(paddings)
                .fillMaxSize()
        ) {
            stickyHeader { SectionTitle(title = "Actors") }
            items(actorsState.hits) { actor -> ActorItem(actor = actor) }

            stickyHeader { SectionTitle(title = "Movies") }
            items(moviesState.hits) { movie -> MovieItem(movie = movie) }
        }
    }
}

Search view activity

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class MainActivity : ComponentActivity() {

    private val viewModel: MainViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            AppTheme {
                SearchScreen(
                    searchBoxState = viewModel.searchBoxState,
                    actorsState = viewModel.actorsState,
                    moviesState = viewModel.moviesState,
                )
            }
        }
    }
}

Combine search for hits and facets values

This example uses a single search view 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 LazyColumn. The source code of this example is on GitHub.

Search data model

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Serializable
data class Product(
    val name: String,
    val description: String,
    val image: String,
    override val objectID: ObjectID,
    override val _highlightResult: JsonObject?
) : Indexable, Highlightable {

    val highlightedName
        get() = getHighlight(Attribute("name"))

    val highlightedDescription
        get() = getHighlight(Attribute("description"))
}

Search view model

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
class MainViewModel : ViewModel() {

    private val multiSearcher = MultiSearcher(
        applicationID = ApplicationID("latency"),
        apiKey = APIKey("6be0576ff61c053d5f9a3225e2a90f76")
    )
    private val indexName = IndexName("instant_search")
    private val attribute = Attribute("categories")
    private val productsSearcher = multiSearcher.addHitsSearcher(indexName)
    private val categoriesSearcher = multiSearcher.addFacetsSearcher(indexName, attribute)
    private val searchBoxConnector = SearchBoxConnector(multiSearcher)
    private val connections = ConnectionHandler(searchBoxConnector)

    val searchBoxState = SearchBoxState()
    val categoriesState = HitsState<Facet>()
    val productsState = HitsState<Product>()

    init {
        connections += searchBoxConnector.connectView(searchBoxState)
        connections += categoriesSearcher.connectHitsView(categoriesState) { it.facets }
        connections += productsSearcher.connectHitsView(productsState) { it.hits.deserialize(Product.serializer()) }
        multiSearcher.searchAsync()
    }

    override fun onCleared() {
        super.onCleared()
        multiSearcher.cancel()
        connections.clear()
    }
}

Search UI 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
@Composable
fun SearchScreen(
    modifier: Modifier = Modifier,
    searchBoxState: SearchBoxState,
    categoriesState: HitsState<Facet>,
    productsState: HitsState<Product>,
) {
    Scaffold(modifier = modifier, topBar = {
        SearchBox(
            modifier = Modifier
                .fillMaxWidth()
                .padding(12.dp),
            searchBoxState = searchBoxState,
        )
    }) { paddings ->
        LazyColumn(
            Modifier
                .padding(paddings)
                .fillMaxSize()
        ) {
            stickyHeader { SectionTitle(title = "Categories") }
            items(categoriesState.hits) { category -> CategoryItem(category = category) }

            stickyHeader { SectionTitle(title = "Products") }
            items(productsState.hits) { product -> ProductItem(product = product) }
        }
    }
}

Search view activity

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class MainActivity : ComponentActivity() {

    private val viewModel: MainViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            AppTheme {
                SearchScreen(
                    searchBoxState = viewModel.searchBoxState,
                    productsState = viewModel.productsState,
                    categoriesState = viewModel.categoriesState,
                )
            }
        }
    }
}
Did you find this page helpful?