Getting started with declarative UI
On this page
This guide explains, step by step, how to build a voice search experience using the libraries provided by Algolia and Compose UI.
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:
instant_search
These credentials give access to a pre-loaded 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
2
implementation 'com.algolia:instantsearch-compose:3.+'
implementation 'com.algolia:instantsearch-android-paging3:3.+'
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-viewmodel-ktx:2.+'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.+'
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 build.gradle
:
1
2
3
4
plugins {
// ...
id 'org.jetbrains.kotlin.plugin.serialization' version '1.6.10'
}
Implementation
Application architecture overview
MainActivity
: this activity controls displayed viewsMainViewModel
: aViewModel
from Android Architecture Components. The business logic lives hereSearch
: 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:
HitsSearcher
performs search requests and obtains search results.SearchBoxConnector
handles a textual query input and triggers search requests when needed.Paginator
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 HitsSearcher
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
class MainViewModel : ViewModel() {
val searcher = HitsSearcher(
applicationID = ApplicationID("latency"),
apiKey = APIKey("1f6fd3a6fb973cb08419fe7d288fa4db"),
indexName = IndexName("instant_search")
)
// Search Box
val searchBoxState = SearchBoxState()
val searchBoxConnector = SearchBoxConnector(searcher)
// Hits
val hitsPaginator = Paginator(searcher) { it.deserialize(Product.serializer()) }
val connections = ConnectionHandler(searchBoxConnector)
init {
connections += searchBoxConnector.connectView(searchBoxState)
connections += searchBoxConnector.connectPaginator(hitsPaginator)
}
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 Connection
s 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 basic search experience: SearchBox
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,
pagingHits: LazyPagingItems<Product>,
listState: LazyListState
) {
LazyColumn(modifier, listState) {
items(pagingHits) { 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
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
@Composable
fun SearchBox(
modifier: Modifier = Modifier,
searchBoxState: SearchBoxState = SearchBoxState(),
onValueChange: (String) -> Unit = {}
) {
TextField(
modifier = modifier,
// set query as text value
value = searchBoxState.query,
// update text on value change
onValueChange = {
searchBoxState.setText(it)
onValueChange(it)
},
// set ime action to "search"
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
// set text as query submit on search action
keyboardActions = KeyboardActions(
onSearch = { searchBoxState.setText(searchBoxState.query, true)}
)
)
}
@Composable
fun Search(
modifier: Modifier = Modifier,
searchBoxState: SearchBoxState,
paginator: Paginator<Product>
) {
val scope = rememberCoroutineScope()
val pagingHits = paginator.flow.collectAsLazyPagingItems()
val listState = rememberLazyListState()
Column(modifier) {
SearchBox(
modifier = Modifier
.weight(1f)
.padding(top = 12.dp, start = 12.dp),
searchBoxState = searchBoxState,
onValueChange = { scope.launch { listState.scrollToItem(0) } },
)
ProductsList(
modifier = Modifier.fillMaxSize(),
pagingHits = pagingHits,
listState = listState,
)
}
}
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(searchBoxState = viewModel.searchBoxState, paginator = viewModel.hitsPaginator)
}
}
}
}
Launch your app to see the basic search experience in action. You should see that the results are changing on each key stroke.
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 gives users a complete understanding of 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 users.
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 = StatsTextState()
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: String) {
Text(
modifier = modifier,
text = stats,
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
27
@Composable
fun Search(
modifier: Modifier = Modifier,
searchBoxState: SearchBoxState,
paginator: Paginator<Product>,
statsText: StatsState<String>,
) {
val scope = rememberCoroutineScope()
val pagingHits = paginator.flow.collectAsLazyPagingItems()
val listState = rememberLazyListState()
Column(modifier) {
SearchBox(
modifier = Modifier
.weight(1f)
.padding(top = 12.dp, start = 12.dp),
searchBoxState = searchBoxState,
onValueChange = { scope.launch { listState.scrollToItem(0) } },
)
Stats(modifier = Modifier.padding(start = 12.dp), stats = statsText.stats)
ProductsList(
modifier = Modifier.fillMaxSize(),
pagingHits = pagingHits,
listState = listState,
)
}
}
Update MainActivity
to pass StatsState
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(
searchBoxState = viewModel.searchBoxState,
productPager = viewModel.hitsPaginator,
statsText = viewModel.statsText
)
}
}
}
}
Rebuild your app. You should now see updated results and an updated hit count on each keystroke.
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 HitsSearcher
, 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
30
class MainViewModel : ViewModel() {
// ...
val facetList = FacetListState()
val filterState = FilterState()
val manufacturer = Attribute("category")
val searcherForFacet = SearcherForFacets(index, manufacturer)
val facetListConnector = FacetListConnector(
searcher = searcherForFacet,
filterState = filterState,
attribute = manufacturer,
selectionMode = SelectionMode.Multiple
)
val connections = ConnectionHandler(searchBoxConnector, statsConnector, facetListConnector)
init {
//...
connections += searcher.connectFilterState(filterState)
connections += facetListConnector.connectView(facetList)
connections += facetListConnector.connectPaginator(hitsPaginator)
searcherForFacet.searchAsync()
}
override fun onCleared() {
//...
searcherForFacet.cancel()
}
}
Create the FacetRow
composable to display a facet. The row represented by a column with two Text
s 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 FacetRow
s:
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: FacetListState
) {
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.items) { 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 earlierSearch
content, addclickable
filterIcon
to show the facets list - Create
FacetList
insidesheetContent
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,
searchBoxState: SearchBoxState,
paginator: Paginator<Product>,
statsText: StatsState<String>,
facetList: FacetListState,
) {
val scope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden)
val listState = rememberLazyListState()
val pagingHits = paginator.flow.collectAsLazyPagingItems()
ModalBottomSheetLayout(
modifier = modifier,
sheetState = sheetState,
sheetContent = { FacetList(facetList = facetList) },
content = {
Column(modifier) {
Row(
Modifier
.fillMaxWidth()
.padding(bottom = 12.dp)
) {
SearchBox(
modifier = Modifier
.weight(1f)
.padding(top = 12.dp, start = 12.dp),
searchBoxState = searchBoxState,
onValueChange = { scope.launch { listState.scrollToItem(0) } },
)
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(),
pagingHits = pagingHits,
listState = listState
)
}
}
)
}
Update Search
in MainActivity
to include the instance of FacetListState
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(
searchBoxState = viewModel.searchBoxState,
productPager = viewModel.hitsPaginator,
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.
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,
pagingHits: LazyPagingItems<Product>,
listState: LazyListState
) {
LazyColumn(modifier, listState) {
items(pagingHits) { 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)
}
}
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.