Building a search screen with Jetpack Compose
In this article, we’ll be discussing the recommended approach for building a search screen in Jetpack Compose and the motivations behind each design decision.
This article assumes a good understanding of Jetpack Compose fundamentals and a familiarity with the recommended Android architecture components.
Getting started
To perform a search, we first need a search query. A conventional way to store this query would be to create a private MutableStateFlow
in a ViewModel
and expose a read-only StateFlow
for observation within a Compose screen.
In the majority of cases, this solution works as expected. Unfortunately, using a reactive stream like StateFlow
to store the text of a Compose TextField
can lead to unexpected behaviour — such as characters appearing in the wrong places or being skipped entirely.
This strange behaviour is caused by the async nature of StateFlow
producing inconsistencies between the multiple internal states of a TextField
.
If you would like to delve further into this issue, please refer to the Medium article below.
Search query solution
How can we store the text of a TextField
if a MutableStateFlow
cannot be used? The suggested method is to instead create a MutableState
variable with a private setter.
This approach ensures that only the ViewModel
is allowed to modify the search query, while still making it available for observation as a read-only state within a Compose screen. This matches the functionality and pattern of the previously invalid MutableStateFlow
solution.
Search list
With the search query handled, we now need a list of items to perform the search logic on. In this article, a search will be performed on a list of movies. To model this, we create a simple data class.
We then create a list of movies in the ViewModel
using this data class. In a production Android app, this data is typically supplied by a repository function or a domain use case in the form of a flow. For the sake of clarity in this example, we instead create a moviesFlow
to emulate this data retrieval.
Producing search results
With a search query and a list of movies, the next task is to perform the search logic. For this, we create a searchResults
value in the ViewModel
that contains a list of movie search results based on the search query.
To start, we use the snapshotFlow
API to transform the MutableState
search query into a flow.
The combine
operator is then used with the moviesFlow
to obtain the most recently emitted values from each flow. The combine
operator also waits for both flows to emit at least one value before it starts combining them.
We can now implement the following search logic:
- If the search query has a value, filter the movie list by names matching the search query
- If the search query is empty, do not filter the movie list
To transform the search results into a state value that is optimised for collection in a Compose screen, we use the stateIn
operator. To use this operator, we specify which coroutine scope to start the flow in and the initial value of the state.
We also set the strategy that controls when sharing is started and stopped. In this case, we set the flow to only remain active when there is at least one subscribed collector. When the last collector of the flow unsubscribes, we wait five seconds before stopping the upstream flow in case the collector only went away temporarily — this avoids restarting the upstream flow in certain situations such as configuration changes.
Updating the search query
The last task the ViewModel
should perform is to handle requests from the screen to update the search query value.
Stateful search screen
With the ViewModel
set up, the next step is to create the search screen. It is a recommended practice to create both a stateful and a stateless version of each screen in your Compose app, as it makes them more reusable and easier to test.
The stateful screen retrieves state and state updating functions from the ViewModel
, which are then passed into the stateless screen. This technique is known as state hoisting.
We first create the stateful version of the search screen.
The collectAsStateWithLifecycle
API is used to collect searchResults
in a lifecycle-aware manner. To make use of this API, add the following dependency to your project.
This API and the advantages behind it are explained in the article below — the key benefit is a boost to performance by not keeping app resources alive unnecessarily.
Stateless search screen
We then create the stateless version of the search screen.
To create the search bar, we make use of the SearchBar
composable offered by Compose Material 3. We supply the SearchBar
with the state values and state update functions, alongside adding a search icon and some hint text. For the sake of clarity in this example, the search hint has not been extracted as a string resource.
Search results
The next step is to display the search results to the user. To start, we create a MovieListItem
composable to display the details of each movie.
We then use this MovieListItem
within a LazyColumn
to display the search results in a vertical list, alongside applying some padding to improve the list’s appearance. The movie data is pulled from the supplied searchResults
variable.
To improve the performance of the LazyColumn
, a count of the number of items is provided, alongside a unique key. Providing a count and a stable key for each item in the LazyColumn
allows Compose to avoid unnecessary recompositions and offers a performance boost.
The last step is to pass this LazyColumn
composable to the content parameter of the SearchBar
.
Navigating to the screen
The stateful screen composable now needs to be reachable within the app. As this is a new project, we can simply add the screen to the main activity class.
If this screen is being implemented as part of a pre-existing app, you can instead integrate it into your existing navigation logic.
Previewing the screen
Having a stateless screen also makes creating a Compose preview of the screen much easier, as we simply pass in the required state.
We then build the project and open the preview pane to view the created search screen.
Running the app
With the app completed, it can now be deployed to an emulator or physical device. The app’s functionality is shown below — typing in the search bar filters the movie list by movie name. If the search text is empty, a hint is shown to the user and all movies are displayed instead.
Further improvements
There are a few additional improvements we can make to enhance the user experience of the search screen.
The first is to add a button to the search bar to clear the query, instead of making the user manually delete each character. We can use the previously defined state and event functions to achieve this functionality.
The functionality of this new button is shown below.
Next, we can display an empty state to the user when there are no movies that match the search query, instead of displaying a potentially confusing blank screen. For this, we define two text styles and use them to create an empty state composable.
We then update the search results list to display the empty state to the user if there are no results.
With this addition, we have removed a potential source of confusion for the user.
Finally, we can improve the feel of the search screen by hiding the software keyboard if the user presses the keyboard search action. To add this functionality, we obtain the current keyboard controller and use it to hide the keyboard when the onSearch
function is called.
The user now has a more natural way to close the keyboard when they have finished typing a search query.
With these three improvements implemented, the search screen now offers a much smoother experience for the user.
Conclusion
That wraps up this article! I hope it has given you a better understanding of the recommended approach for building a search screen in Jetpack Compose.
You can find my Jetpack Compose projects on GitHub — feel free to reach out with any questions or feedback.
Happy coding!