Implement modern Search functionality on Android with Compose, MVVM, Clean Architecture & JUnit5 (Part 5— Debounce, handle and display results)

A Poplawski
6 min readAug 6, 2023

--

This series of articles focuses on implementing modern search functionality on Android. In case you missed it, check part 1 with introduction and demo of implemented functionality.

Part 1 — Introduction, data models, view model sketch
Part 2 — Search bar UI with Jetpack Compose
Part 3 — Search bar actions wrap-up
Part 4 — Domain interactor

In previous parts, we managed to create interactable UI for the search bar. We also implemented a domain layer — use/case interactor class that we’ll use in this part. Additionally, we’ll introduce a concept of debounce.

1. Observing user input and reactive approach

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

private val isInitialized = AtomicBoolean(false)

fun updateInput(inputText: String) {
if (isInitialized.compareAndSet(false, true)) {
_inputText.update { inputText }
activateSearchField()


if (inputText.blankOrEmpty().not()) {
_viewState.update { ViewState.Loading }
}
}
}

As implemented in part 1, user input is kept inside of a view model as StateFlow<String> object. Since StateFlow API supports standard Flow operators, we’re able to collect and use it in a reactive way.

fun initialize() {
viewModelScope.launch {
inputText.collectLatest { input ->
// So, what next?
}
}
}

Like that, we can collect user input, and use it to request results from our (fake) endpoint. We’ll use the interactor that we created in part 4.

fun initialize() {
viewModelScope.launch {
inputText.collectLatest { input ->
val results = getSearchResults.invoke(input)
// TODO: Handle results
}
}
}

Before move forward, let’s stop for a second and talk about debouncing.

2. Debouncing

Debouncing is a technique used to optimize the search process and reduce unnecessary network requests or resource-consuming operations when a user is typing in a search box. It addresses the issue of making repeated requests to the server or performing heavy computations with every keystroke, which can lead to inefficient use of resources and potential performance issues.

When a user is typing in a search box, each keystroke may trigger a search query to fetch results from a server or execute a search algorithm. However, if the search function is not debounced, it will immediately fire a new search request for every keystroke. For instance, if the user is quickly typing a full sentence, the search function could trigger several requests in rapid succession.

Debouncing works by introducing a slight delay before executing the search function after the user stops typing. The purpose is to wait for a brief period of inactivity, assuming the user has finished typing, before triggering the actual search request. During this delay, if the user continues typing, the timer gets reset, and the delay is extended. This process repeats until there’s a pause in typing, at which point the search function is finally executed with the user’s latest input.

It might sound complicated at first. Luckily, it’s very simple to implement using Flow operators.

fun initialize() {
viewModelScope.launch {
inputText.debounce(timeoutMillis = 500).collectLatest { input ->
val results = getSearchResults.invoke(input)
// TODO: Handle results
}
}
}

Just like that, we can introduce debounce. There is no golden rule about timeout value. For me personally, 500 milliseconds is fine.

3. Handling input & results

Almost there. Let’s now handle empty inputs and results from the endpoint. Also, make sure that we initialize only once.

    private val isInitialized = AtomicBoolean(false)

@FlowPreview
fun initialize() {
if (isInitialized.compareAndSet(false, true)) {
viewModelScope.launch {
inputText.debounce(500).collectLatest { input ->
if (input.blankOrEmpty()) {
_viewState.update { ViewState.IdleScreen }
return@collectLatest
}

when (val result = getSearchResults(input)) {
is GetSearchResults.Result.Success -> {
if (result.results.isEmpty()) {
_viewState.update { ViewState.NoResults }
} else {
_viewState.update { ViewState.SearchResultsFetched(result.results) }
}
}
is GetSearchResults.Result.Error -> {
_viewState.update { ViewState.Error }
}
}
}
}
}
}

Here’s the view model we should end up with now.

class SearchViewModel(private val getSearchResults: GetSearchResults) : 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

private val isInitialized = AtomicBoolean(false)

@FlowPreview
fun initialize() {
if (isInitialized.compareAndSet(false, true)) {
viewModelScope.launch {
inputText.debounce(500).collectLatest { input ->
if (input.blankOrEmpty()) {
_viewState.update { ViewState.IdleScreen }
return@collectLatest
}

when (val result = getSearchResults(input)) {
is GetSearchResults.Result.Success -> {
if (result.results.isEmpty()) {
_viewState.update { ViewState.NoResults }
} else {
_viewState.update { ViewState.SearchResultsFetched(result.results) }
}
}
is GetSearchResults.Result.Error -> {
_viewState.update { ViewState.Error }
}
}
}
}
}
}

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

if (inputText.blankOrEmpty().not()) {
_viewState.update { ViewState.Loading }
}
}

fun searchFieldActivated() {
activateSearchField()
}

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

fun revertToInitialState() {
_viewState.update { ViewState.IdleScreen }
_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()
}

4. Display results

For this part, I’ll keep the code very basic. Feel free to copy-paste it and refine it to your needs.

@Composable
private fun SearchResultsList(items: List<SearchResult>, onItemClicked: (SearchResult) -> Unit) {
LazyColumn {
itemsIndexed(items = items) { index, searchResult ->
Column(modifier = Modifier.fillMaxWidth().clickable { onItemClicked.invoke(searchResult) }) {
Spacer(
modifier = Modifier.height(height = if (index == 0) 16.dp else 4.dp)
)
Text(
text = searchResult.title,
color = color_soft_white,
modifier = Modifier.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = searchResult.subtitle,
color = color_silver,
modifier = Modifier.padding(horizontal = 16.dp),
fontSize = 12.sp
)
Spacer(modifier = Modifier.height(8.dp))
Spacer(
modifier = Modifier
.fillMaxWidth()
.height(1.dp)
.padding(start = 16.dp)
.background(color_silver.copy(alpha = 0.2f))
)
Spacer(modifier = Modifier.height(4.dp))
}
}
}
}
@Composable
private fun SearchScreenLayout(
viewState: NewSearchViewModel.ViewState,
searchFieldState: NewSearchViewModel.SearchFieldState,
inputText: String,
onSearchInputChanged: (String) -> Unit,
onSearchInputClicked: () -> Unit,
onClearInputClicked: () -> Unit,
onChevronClicked: () -> Unit,
onItemClicked: (SearchResult) -> Unit,
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(color_dark_blue)
) {
SearchHeader(searchFieldState = searchFieldState)
SearchInputField(
searchFieldState = searchFieldState,
inputText = inputText,
onClearInputClicked = onClearInputClicked,
onSearchInputChanged = onSearchInputChanged,
onClicked = onSearchInputClicked,
onChevronClicked = onChevronClicked,
)
Spacer(
modifier = Modifier
.fillMaxWidth()
.height(2.dp)
.background(color_silver.copy(alpha = 0.2f))
)
when (viewState) {
NewSearchViewModel.ViewState.IdleScreen -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Image(
// Just a stock image from UnDraw
painter = painterResource(id = R.drawable.undraw_search),
contentDescription = "Illustration",
modifier = Modifier.padding(16.dp)
)
}
}

NewSearchViewModel.ViewState.Error -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(text = "Error :(", color = color_soft_white)
}
}

NewSearchViewModel.ViewState.Loading -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}

NewSearchViewModel.ViewState.NoResults -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(text = "No results for this input :(", color = color_soft_white)
}
}

is NewSearchViewModel.ViewState.SearchResultsFetched -> {
SearchResultsList(items = viewState.results, onItemClicked = onItemClicked)
}
}
}
}

There we go! It was simple, wasn’t it?

Just like that, we reach another milestone. We’re done with the basic implementation!

Photo by Ambreen Hasan on Unsplash

For the next parts, I’ll cover more advanced concepts. Unit testing with JUnit5, controlling keyboard behaviour & best practices.

Thank you for tuning along, cheers!

--

--

A Poplawski

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