Searching Paged Data In Jetpack Compose
I've recently created a new Android app using the Jetpack Compose UI App Development Toolkit which consisted of a list of items which needed to be searchable. As the list was quite long, and the API I was talking to implemented support for pagination, I made use of the Paging library to lazy load items into the list as it scrolled.
Nice and easy so far. Now came the time to implement searching for items. I wanted to be able to toggle a search box at the top of the page and have the list of items filter itself based upon the search term as the user typed. After a little trial and error and a good while searching through Google's documentation given how rarely I work with Android, I came up with a nice and simple solution.
The end result that we're aiming for is something like this:
My ViewModel layers all use Kotlin Flows to store state, meaning my basic view model for the paged data looked something like the following:
@HiltViewModel
class ProductListViewModel @Inject constructor(
private val repository: Repository
): ViewModel() {
val products = Pager(
PagingConfig(
pageSize = 10,
enablePlaceholders = false,
)
) {
ProductPagingSource(
repository = repository,
search = query,
)
}.flow.cachedIn(viewModelScope)
}
Note, I use Hilt to inject dependencies into my view models, but that's not important for this post.
To add searching to this, I needed to add a few new properties and a couple of functions:
- A boolean to toggle whether the search box is visible.
- A string to contain the current search term.
- Functions to toggle the search box visibility, and to set the search term.
My view model now looked like this:
@HiltViewModel
class ProductListViewModel @Inject constructor(
private val repository: Repository
): ViewModel() {
private val _search = MutableStateFlow("")
val search = _search.asStateFlow()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = "",
)
private val _isSearchShowing = MutableStateFlow(false)
val isSearchShowing = _isSearchShowing.asStateFlow()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = false,
)
val products = Pager(
PagingConfig(
pageSize = 10,
enablePlaceholders = false,
)
) {
ProductPagingSource(
repository = repository,
search = query,
)
}.flow.cachedIn(viewModelScope)
fun setSearch(query: String) {
_search.value = query
}
fun toggleIsSearchShowing() {
_isSearchShowing.value = !_isSearchShowing.value
}
}
Now we just need to update the paged data when the search term changes. Luckily, this is extremely easy when working with flows as we can use a flow transformation (namely flatMapLatest
) so that when the search term changes a new flow is emitted:
@HiltViewModel
class ProductListViewModel @Inject constructor(
private val repository: Repository
): ViewModel() {
private val _search = MutableStateFlow("")
val search = _search.asStateFlow()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = "",
)
private val _isSearchShowing = MutableStateFlow(false)
val isSearchShowing = _isSearchShowing.asStateFlow()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = false,
)
val productsSearchResults = search.debounce(300.milliseconds).flatMapLatest { query ->
Pager(
PagingConfig(
pageSize = 10,
enablePlaceholders = false,
)
) {
ProductPagingSource(
repository = repository,
search = query,
)
}.flow.cachedIn(viewModelScope)
}
fun setSearch(query: String) {
_search.value = query
}
fun toggleIsSearchShowing() {
_isSearchShowing.value = !_isSearchShowing.value
}
}
We also apply the debounce
transformation so that as the user is typing we don't constantly refresh the paging data. In this case, I set the debounce interval to 300 milliseconds.
All that's left to do is to hook the view model up to a view - an exercise left to the reader, though you can see my implementation here.
A full sample project is available on GitHub, which ties this approach together with dummy product data coming from a web request.