Implement modern Search functionality on Android with Compose, MVVM, Clean Architecture & JUnit5 (Part 1 — Introduction, data models, view model sketch)

A Poplawski
5 min readAug 5, 2023

--

Searching is a fundamental aspect of many mobile applications, and creating a seamless search experience is crucial for keeping users engaged. In this article, step-by-step, layer-by-layer, we’ll develop modern, maintainable, scalable and testable search functionality with Compose, MVVM, Clean Architecture & JUnit5, sticking only to best coding practices (according to my best knowledge of course :P).

The article will contain code samples with explanations, so you can learn along. There is also a GitHub repository with working solution, that you’ll be able to run on your device/emulator.

Here’s a short demo of what we’ll create:

Part 2 — Search bar UI with Jetpack Compose
Part 3 — Search bar actions wrap-up
Part 4 — Domain interactor
Part 5 — Debounce, handle and display results

At first, we’ll focus on creating living, interactive search bar, that will react to user input. In this part, we will sketch data models and view model class.

1. Data models

Let’s start with a simple domain model, that will represent single search result.

data class SearchResult(
val id: Int,
val title: String,
val subtitle: String,
)

Nothing too crazy. Now, let’s sketch our UI state models. Objects of these models will be exposed to the view as state inside view model. View model will update the state, handling events triggered by user interaction. This pattern is called unidirectional data flow. It’s also much simpler than it sounds, so let’s see the actual example.

class SearchViewModel : ViewModel() {

sealed interface ViewState {
object Loading : ViewState
object Error : ViewState
object NoResults : ViewState
data class SearchResultsFetched(val results: List<SearchResult>) : ViewState
}
}

The ViewState model is pretty self explanatory. We want to indicate when the results are being fetched with Loading. If there are no results for given input, or we encountered an exception from the backend, we emit NoResults or Error objects. Finally, there is SearchResultsFetched that holds the list of results.

If you’re unfamiliar with the concept of sealed classes/interfaces, you can read about it here.

class SearchViewModel : ViewModel() {

sealed interface ViewState {
object Idle : ViewState
object Loading : ViewState
object Error : ViewState
object NoResults : ViewState
data class SearchResultsFetched(val results: List<SearchResult>) : ViewState
}

sealed interface SearchFieldState {
object Idle : SearchFieldState
object EmptyActive : SearchFieldState
object WithInputActive : SearchFieldState
}
}

As you might notice on the demo, our search bar can also be defined by different state objects. It looks different when it’s idle, when activated, and with input. Also, the search bar state is independent of the view state, that we’ve just defined.

2. Exposing state

class SearchViewModel : ViewModel() {

sealed interface ViewState {
object Idle : ViewState
object Loading : ViewState
object Error : ViewState
object NoResults : ViewState
data class SearchResultsFetched(val results: List<SearchResult>) : ViewState
}

sealed interface SearchFieldState {
object Idle : SearchFieldState
object EmptyActive : SearchFieldState
object WithInputActive : SearchFieldState
}

private val _searchFieldState: MutableStateFlow<SearchFieldState> =
MutableStateFlow(SearchFieldState.Idle)
val searchFieldState: StateFlow<SearchFieldState> = _searchFieldState

private val _viewState: MutableStateFlow<ViewState> =
MutableStateFlow(ViewState.Idle)
val viewState: StateFlow<ViewState> = _viewState
}

We exposed both states using StateFlow, so that our view can collect it.

Creating private mutable val, and exposing public immutable val is a good practice of not exposing mutable types. Read more here.

Let’s also hold text that user inputs as state inside of our view model. Later we will expose a function that will take user input from keyboard and update the state with it.

class SearchViewModel : ViewModel() {

sealed interface ViewState {
object Loading : ViewState
object Error : ViewState
object NoResults : ViewState
data class SearchResultsFetched(val results: List<SearchResult>) : ViewState
}

sealed interface SearchFieldState {
object Idle : SearchFieldState
object EmptyActive : SearchFieldState
object WithInputActive : SearchFieldState
}

private val _searchFieldState: MutableStateFlow<SearchFieldState> =
MutableStateFlow(SearchFieldState.Idle)
val searchFieldState: StateFlow<SearchFieldState> = _searchFieldState

private val _viewState: MutableStateFlow<ViewState> =
MutableStateFlow(ViewState.Loading)
val viewState: StateFlow<ViewState> = _viewState

private val _inputText: MutableStateFlow<String> =
MutableStateFlow("")
val inputText: StateFlow<String> = _inputText
}

3. Handling user interactions

Let’s now create functions that will capture user interactions (such as clicking on the search bar, clearing input, etc.) and mutate state of the search bar.

Take a look at the available actions:

  1. Adding input to the search bar should display “clear” icon on the left. We called it WithInputActive state. Removing input will revert the state from WithInputActive to EmptyActive.
  2. Touching idle search bar should activate it, change its color and replace the action icon. We called it EmptyActive state.
  3. Clicking chevron icon in the active state should revert search bar to the initial state.
  4. Clicking clear icon should clear the input.
// Called when user types on the keyboard (Action 1)
fun updateInput(inputText: String) {
_inputText.update { inputText }
activateSearchField()
}

// Called when user taps on the search bar (Action 2)
fun searchFieldActivated() {
activateSearchField()
}

// Called when user taps on the "chevron" icon (Action 3)
fun revertToInitialState() {
_inputText.update { "" }
_searchFieldState.update { NewSearchViewModel.SearchFieldState.Idle }
}

// Called when user taps on the "clear" icon (Action 4)
fun clearInput() {
_inputText.update { "" }
_searchFieldState.update { NewSearchViewModel.SearchFieldState.EmptyActive }
}

// Notice how we update our state differently, based on the input state.
private fun activateSearchField() {
if (inputText.value.blankOrEmpty().not()) {
_searchFieldState.update { NewSearchViewModel.SearchFieldState.WithInputActive }
} else {
_searchFieldState.update { NewSearchViewModel.SearchFieldState.EmptyActive }
}
}

SearchViewModel will expose these functions to be called from the view. Later, we will implement user interactions on the search bar and connect them with our view model.

Here’s what we have for now:

class NewSearchViewModel : ViewModel() {

sealed interface ViewState {
object IdleScreen: ViewState
object Loading : ViewState
object Error : ViewState
object NoResults : ViewState
data class SearchResultsFetched(val results: List<SearchResult>) : ViewState
}

sealed interface SearchFieldState {
object Idle : SearchFieldState
object EmptyActive : SearchFieldState
object WithInputActive : SearchFieldState
}

private val _searchFieldState: MutableStateFlow<SearchFieldState> =
MutableStateFlow(SearchFieldState.Idle)
val searchFieldState: StateFlow<SearchFieldState> = _searchFieldState

private val _viewState: MutableStateFlow<ViewState> =
MutableStateFlow(ViewState.IdleScreen)
val viewState: StateFlow<ViewState> = _viewState

private val _inputText: MutableStateFlow<String> =
MutableStateFlow("")
val inputText: StateFlow<String> = _inputText

fun updateInput(inputText: String) {
_inputText.update { inputText }
activateSearchField()
}

fun searchFieldActivated() {
activateSearchField()
}

fun clearInput() {
_inputText.update { "" }
_searchFieldState.update { SearchFieldState.EmptyActive }
}

fun revertToInitialState() {
_inputText.update { "" }
_searchFieldState.update { SearchFieldState.Idle }
}

private fun activateSearchField() {
if (inputText.value.blankOrEmpty().not()) {
_searchFieldState.update { SearchFieldState.WithInputActive }
} else {
_searchFieldState.update { SearchFieldState.EmptyActive }
}
}

private fun String.blankOrEmpty() = this.isBlank() || this.isEmpty()
}

And that’s it, we can stop here for now. Don’t worry if you’re confused. Follow next parts and soon you’ll end up with working solution. :)

In part 2, we’ll create the actual search bar layout in Compose and implement user interactions.

No, there won’t be any cringe gif.

Cheers!

--

--

A Poplawski

Creating Android apps since 2019 - Android, Android TV & Kotlin Multiplatform. Trying to share useful information in a digestible manner.