Euan's Blog

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:

Demo GIF of paged data being searched with live filtering of results.

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:

  1. A boolean to toggle whether the search box is visible.
  2. A string to contain the current search term.
  3. 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.

#Android #Compose #Kotlin