Guides / Building Search UI / Getting started

Getting Started with Declarative UI

This guide explains, step by step, how to build a voice search experience using the libraries provided by Algolia and Jetpack Compose.

Prepare your project

To use Algolia with InstantSearch Android, 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 and add InstantSearch Android

In Android Studio, create a new project:

  • Select Phone and Tablet template
  • Select Empty Compose Activity screen

Add project dependencies

In your build.gradle file, under the app module, add the following in the dependencies block:

1
implementation 'com.algolia:instantsearch-compose:2.+'

This guide uses InstantSearch Android with Android Architecture Components, so you also need to add the following dependencies:

1
2
3
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.+'
implementation 'androidx.paging:paging-compose:1.+'
implementation 'androidx.compose.material:material-icons-extended:1.+'

To perform network operations in your application, AndroidManifest.xml must include the following permissions:

1
<uses-permission android:name="android.permission.INTERNET" />

Setup kotlinx.serialization by adding the serialization plugin to your project’s build.gradle:

1
2
3
4
dependencies {
  //...
  classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"
}

Add the serialization plugin to the plugins block in your app’s build.gradle:

1
2
3
4
plugins {
  // ...
  id 'kotlinx-serialization'
}

Implementation

Application architecture overview

  • MainActivity: this activity controls displayed views
  • MainViewModel: a ViewModel from Android Architecture Components. The business logic lives here
  • Search: composes the search UI

Define your data class

Define a structure that represents a record in your index. For simplicity’s sake, the below example structure only provides the name of the product. Add the following data class definition to the Product.kt file:

1
2
3
4
@Serializable
data class Product(
    val name: String
)

Add search business logic

You need three components for the basic search experience:

  • SearcherSingleIndex performs search requests and obtains search results.
  • SearchBoxConnector handles a textual query input and triggers search requests when needed.
  • SearcherSingleIndexPager displays hits and manages the pagination logic.

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

The central part of your search experience is the Searcher. The Searcher performs search requests and obtains search results. Most InstantSearch component connect with the Searcher. In this tutorial you are targeting one index, so instantiate a SearcherSingleIndex with the proper credentials.

Create a new MainViewModel.kt file and add the following:

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() {

    val client = ClientSearch(
        ApplicationID("latency"),
        APIKey("1f6fd3a6fb973cb08419fe7d288fa4db"),
        LogLevel.ALL
    )
    val index = client.initIndex(IndexName("bestbuy"))
    val searcher = SearcherSingleIndex(index)

    // Search Box
    val searchBox = SearchBoxCompose()
    val searchBoxConnector = SearchBoxConnector(searcher)

    // Hits
    val hitsPager = SearcherSingleIndexPager(searcher) { response ->
        response.hits.deserialize(Product.serializer())
    }
    
    val connections = ConnectionHandler(searchBoxConnector)

    init {
        connections += searchBoxConnector.connectView(searchBox)
    }

    override fun onCleared() {
        super.onCleared()
        searcher.cancel()
    }
}

Most InstantSearch components should connect and disconnect in accordance to the Android Lifecycle to avoid memory leaks. A ConnectionHandler handles a set of Connections for you: each += call with a component implementing the Connection interface connects it and makes it active. Whenever you want to free resources or deactivate a component, call the disconnect method.

Get an instance of your ViewModel in your MainActivity by adding the following:

1
2
3
4
class MainActivity : ComponentActivity() {
    val viewModel: MainViewModel by viewModels()
    //...
}

A ViewModel is a good place to put your data sources. This way, the data persists during configuration changes.

Create a SearchScreen.kt file that holds the search UI. Add a composable function ProductsList to display a list of products, the hit row represented by a column with a Text presenting the name of the item and a Divider:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Composable
fun ProductsList(
    modifier: Modifier = Modifier,
    searcherLazyPaging: SearcherLazyPaging<Product>
) {
    val (products, state) = searcherLazyPaging
    LazyColumn(modifier, state) {
        items(products) { item ->
            if (item == null) return@items
            Text(
                modifier = modifier
                    .fillMaxWidth()
                    .padding(14.dp),
                text = item.name,
                style = MaterialTheme.typography.body1
            )
            Divider(
                modifier = Modifier
                    .fillMaxWidth()
                    .width(1.dp)
            )
        }
    }
}

Complete your search experience by putting the SearchBox and ProductsList views together:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Composable
fun Search(
    modifier: Modifier = Modifier,
    searchBox: SearchBoxCompose,
    productPager: SearcherSingleIndexPager<Product>
) {
    val scope = rememberCoroutineScope()
    val searcherLazyPaging = productPager.collectAsSearcherLazyPaging(scope = scope)

    Column(modifier) {
        SearchBox(
            modifier = Modifier.fillMaxWidth().padding(12.dp),
            query = searchBox.query,
            onValueChange = { query, isSubmit ->
                searchBox.onValueChange(query, isSubmit)
                searcherLazyPaging.resetAsync()
            }
        )
        ProductsList(
            modifier = Modifier.fillMaxSize(),
            searcherLazyPaging = searcherLazyPaging
        )
    }
}

Add the Search composable into the setContent section in MainActivity and pass it your business logic components from MainViewModel:

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

    private val viewModel: MainViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            SearchAppTheme {
                Search(searchBox = viewModel.searchBox, productPager = viewModel.hitsPager)
            }
        }
    }
}

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

Search compose without query

Search compose with query

Displaying metadata: Stats

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. The StatsConnector extracts the metadata from the search response, and provides an interface to present it to the user. Add the StatsConnector to the MainViewModel and connect it to the Searcher.

1
2
3
4
5
6
7
8
9
10
11
12
class MainViewModel : ViewModel() {
    //...
    val statsText = StatsTextCompose()
    val statsConnector = StatsConnector(searcher)

    val connections = ConnectionHandler(searchBoxConnector, statsConnector)

    init {
        //...
        connections += statsConnector.connectView(statsText, StatsPresenterImpl())
    }
}

The StatsConnector receives the search statistics now, but doesn’t display it yet. Create a new composable Stats:

1
2
3
4
5
6
7
8
9
@Composable
fun Stats(modifier: Modifier = Modifier, stats: State<String>) {
    Text(
        modifier = modifier,
        text = stats.value,
        style = MaterialTheme.typography.caption,
        maxLines = 1
    )
}

Add the Stats composable into the Column, in the middle of the SearchBox and ProductsList:

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
@Composable
fun Search(
    modifier: Modifier = Modifier,
    searchBox: SearchBoxCompose,
    productPager: SearcherSingleIndexPager<Product>,
    statsText: StatsCompose<String>
) {
    val scope = rememberCoroutineScope()
    val searcherLazyPaging = productPager.collectAsSearcherLazyPaging(scope = scope)

    Column(modifier) {
        SearchBox(
            modifier = Modifier.fillMaxWidth().padding(12.dp),
            query = searchBox.query,
            onValueChange = { query, isSubmit ->
                searchBox.onValueChange(query, isSubmit)
                searcherLazyPaging.resetAsync()
            }
        )
        Stats(modifier = Modifier.padding(start = 12.dp), stats = statsText.stats)
        ProductsList(
            modifier = Modifier.fillMaxSize(),
            searcherLazyPaging = searcherLazyPaging
        )
    }
}

Update MainActivity to pass StatsCompose instance to Search:

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 {
            SearchAppTheme {
                Search(
                    searchBox = viewModel.searchBox,
                    productPager = viewModel.hitsPager,
                    statsText = viewModel.statsText
                )
            }
        }
    }
}

Rebuild your app. You should now see updated results and an updated hit count on each keystroke.

Search compose stats

Filter your results: FacetList

With your app, you can search more than 10,000 products. But, 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 FilterListConnector components. This section explains how to build a filter that allows to filter products by their category. First, add a FilterState component to the MainViewModel. This component provides a convenient way to manage the state of your filters. Add the manufacturer refinement attribute. Next, add the FilterListConnector, which stores the list of facets retrieved with search result. Add the connections between SearcherSingleIndex, FilterState and FilterListConnector.

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 MainViewModel : ViewModel() {
    // ...
    val facetList = FacetListCompose()
    val manufacturer = Attribute("manufacturer")
    val searcherForFacet = SearcherForFacets(index, manufacturer)
    val facetListConnector = FacetListConnector(
        searcher = searcher,
        filterState = FilterState(),
        attribute = manufacturer,
        selectionMode = SelectionMode.Multiple
    )

    val connections = ConnectionHandler(searchBoxConnector, statsConnector, facetListConnector)

    init {
        //...
        connections += searcher.connectFilterState(filterState)
        connections += facetListConnector.connectView(facetList)
        connections += facetListConnector.connectSearcherPager(hitsPager)

        searcherForFacet.searchAsync()
    }

    override fun onCleared() {
        //...
        searcherForFacet.cancel()
    }
}

Create the FacetRow composable to display a facet. The row represented by a column with two Texts for the facet value and count, plus an Icon to display a checkmark for selected facets:

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
@Composable
fun FacetRow(
    modifier: Modifier = Modifier,
    selectableFacet: SelectableItem<Facet>
) {
    val (facet, isSelected) = selectableFacet
    Row(
        modifier = modifier.height(56.dp),
        verticalAlignment = Alignment.CenterVertically
    ) {
        Row(modifier = Modifier.weight(1f)) {
            Text(
                modifier = Modifier.alignByBaseline(),
                text = facet.value,
                style = MaterialTheme.typography.body1
            )
            Text(
                modifier = Modifier
                    .padding(start = 8.dp)
                    .alignByBaseline(),
                text = facet.count.toString(),
                style = MaterialTheme.typography.body2,
                color = MaterialTheme.colors.onBackground.copy(alpha = 0.2f)
            )
        }
        if (isSelected) {
            Icon(
                imageVector = Icons.Default.Check,
                contentDescription = null,
            )
        }
    }
}

Create the FacetList composable to display facets list. Use a Text for the attribute and a LazyColumn to display FacetRows:

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
@Composable
fun FacetList(
    modifier: Modifier = Modifier,
    facetList: FacetListCompose
) {
    Column(modifier) {
        Text(
            text = "Categories",
            style = MaterialTheme.typography.body1.copy(fontWeight = FontWeight.Bold),
            modifier = Modifier.padding(14.dp)
        )
        LazyColumn(Modifier.background(MaterialTheme.colors.background)) {
            items(facetList.facets.value) { item ->
                FacetRow(
                    modifier = Modifier
                        .clickable { facetList.onSelection?.invoke(item.first) }
                        .padding(horizontal = 14.dp),
                    selectableFacet = item,
                )
                Divider(
                    modifier = Modifier.fillMaxWidth().width(1.dp)
                )
            }
        }
    }
}

Put it all together using a ModalBottomSheetLayout:

  • Inside content, put your earlier Search content, add clickable filter Icon to show the facets list
  • Create FacetList inside sheetContent to display your facets list.
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
@Composable
fun Search(
    modifier: Modifier = Modifier,
    searchBox: SearchBoxCompose,
    productPager: SearcherSingleIndexPager<Product>,
    statsText: StatsCompose<String>,
    facetList: FacetListCompose,
) {

    val scope = rememberCoroutineScope()
    val sheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden)
    val searcherLazyPaging = productPager.collectAsSearcherLazyPaging(scope = scope)

    ModalBottomSheetLayout(
        modifier = modifier,
        sheetState = sheetState,
        sheetContent = { FacetList(facetList = facetList) },
        content = {
            Column(modifier) {
                Row(
                    Modifier
                        .fillMaxWidth()
                        .padding(bottom = 12.dp)) {
                    SearchBox(
                        query = searchBox.query,
                        onValueChange = { query, isSubmit ->
                            searchBox.onValueChange(query, isSubmit)
                            searcherLazyPaging.resetAsync()
                        },
                        modifier = Modifier
                            .weight(1f)
                            .padding(top = 12.dp, start = 12.dp),
                    )

                    Card(Modifier.padding(top = 12.dp, end = 12.dp, start = 8.dp)) {
                        Icon(
                            modifier = Modifier
                                .clickable { scope.launch { sheetState.show() } }
                                .padding(horizontal = 12.dp)
                                .height(56.dp),
                            imageVector = Icons.Default.FilterList,
                            contentDescription = null,
                        )
                    }
                }
                Stats(modifier = Modifier.padding(start = 12.dp), stats = statsText.stats)
                ProductsList(
                    modifier = Modifier.fillMaxSize(),
                    searcherLazyPaging = searcherLazyPaging
                )
            }
        }
    )
}

Update Search in MainActivity to include the instance of FacetListCompose from your MainViewModel:

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

    private val viewModel: MainViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            SearchAppTheme {
                Search(
                    searchBox = viewModel.searchBox,
                    productPager = viewModel.hitsPager,
                    statsText = viewModel.statsText,
                    facetList = viewModel.facetList
                )
            }
        }
    }
}

Rebuild your app. Now you see a filter button on top right of your screen. Click it to show the refinements list and select one or more refinements. Dismiss the refinements list to see the changes happening live to your hits.

Search compose with filter icon

Search compose filters

Improving the user experience: Hightlighting

Highlighting enhances the user experience by putting emphasis on the parts of the result that match the query. It’s a visual indication of why a result is relevant to the query.

You can add highlighting by implementing the Highlightable interface on Product.

First, define a highlightedName field to retrieve the highlighted value for the name attribute.

1
2
3
4
5
6
7
8
9
@Serializable
data class Product(
    val name: String,
    override val _highlightResult: JsonObject?
) : Highlightable {

    val highlightedName: HighlightedString?
        get() = getHighlight(Attribute("name"))
}

Use the .toAnnotatedString() extension function to convert an HighlightedString into a AnnotatedString assignable to a Text to display the highlighted names.

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
@Composable
fun ProductsList(
    modifier: Modifier = Modifier,
    searcherLazyPaging: SearcherLazyPaging<Product>
) {
    val (products, state) = searcherLazyPaging
    LazyColumn(modifier, state) {
        items(products) { item ->
            if (item == null) return@items
            TextAnnotated(
                modifier = modifier
                    .fillMaxWidth()
                    .padding(14.dp),
                annotatedString = item.highlightedName?.toAnnotatedString(),
                default = item.name,
                style = MaterialTheme.typography.body1
            )
            Divider(
                modifier = Modifier
                    .fillMaxWidth()
                    .width(1.dp)
            )
        }
    }
}

@Composable
fun TextAnnotated(modifier: Modifier, annotatedString: AnnotatedString?, default: String, style: TextStyle) {
    if (annotatedString != null) {
        Text(modifier = modifier, text = annotatedString, style = style)
    } else {
        Text(modifier = modifier, text = default, style = style)
    }
}

Search compose with highlight

Going further

You now have a fully working search experience: your users can search for products, refine their results, and understand how many records are returned and why they’re relevant to the query.

You can find the full source code in the GitHub repository.

Did you find this page helpful?