Efficient Search with Lazy Layouts in Jetpack Compose

Meet Patadia
7 min readJun 17, 2024

--

Hey there! Right now, I’m diving into Jetpack Compose and trying my hand at recreating Spotify’s UI. Along the way, I’ve been facing all sorts of challenges, but hey, that’s where the real learning happens, right? So, I’ve decided to share my journey through this blog series to save you some of the headaches I’ve gone through.

In my last blog post, I talked about how to select multiple items in Lazy layouts. Today, we’re getting into something new: adding a search feature to our artist list. This time, we’re going to focus on making that search bar work like a charm.

So, stick around! We’re about to make searching in Jetpack Compose a piece of cake.

Alright, before we dive in, let me give you a quick blueprint of how we’re going to implement the search feature:

  • Custom SearchView: First off, we’ll need a search view. I’ve created a custom search view using TextField to handle user input.
  • ViewModel Setup: Next, we’ll set up our ViewModel. This is where we’ll declare our StateFlows to hold the search query and the list of artists.
  • Matching Query: We’ll add a function in our data class to filter artists based on the search query entered by the user.
  • Display Data: Then, we’ll integrate our ViewModel into the Compose function to fetch and display the filtered data.

This blueprint will guide us through the process of implementing search functionality smoothly. Let’s jump in and get started!

Alright, buckle up!

To kick things off, we’re gonna need two VIPs in our layout: a list and a search view. Because what’s a search without something to search in, right? It’s like Batman without Robin!

We’re skipping the usual SearchView from the Material Design 3 library. Nope, not today! We’re taking a different route and using a TextField instead. Why? Because we want full control to customize it however we like.

import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TextFieldDefaults.indicatorLine
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.codebyzebru.spotifycompose.ui.theme.PrimaryGreen

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchField(
searchQuery: String,
onQueryChanged: (String) -> Unit,
modifier: Modifier = Modifier,
) {
TextField(
value = searchQuery,
onValueChange = onQueryChanged,
modifier = modifier
.clip(MaterialTheme.shapes.extraSmall)
.indicatorLine(
enabled = false,
isError = false,
interactionSource = remember {
MutableInteractionSource()
},
colors = TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
),
focusedIndicatorLineThickness = 0.dp,
unfocusedIndicatorLineThickness = 0.dp
),
placeholder = { Text(text = "Search") },
leadingIcon = {
Icon(
imageVector = Icons.Rounded.Search,
contentDescription = ""
)
},
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White,
focusedTextColor = Color.Black,
unfocusedContainerColor = Color.White,
unfocusedTextColor = Color.Black,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
cursorColor = PrimaryGreen,
)
)
}

We won’t go too deep into its explanation because it’s quite straightforward to understand. So, here’s a brief overview of the above code:

  • searchQuery: A string representing the current text in the search field.
  • onQueryChanged: A lambda function that gets called whenever the text in the search field changes.
  • value: Binds the text field's value to the searchQuery state.
  • onValueChange: Calls the onQueryChanged lambda whenever the text changes, updating the state.
  • indicatorLine(...): Customizes the indicator line (the line under the TextField).
  • leadingIcon = {...}: Adds a icon at the start of the text field.

So, if I were to describe this code in words, it would be like this:

  • The SearchField composable creates a search input field with customized styling, including a search icon, placeholder text, and various color customizations for focused and unfocused states. The indicator line is hidden, and the text field uses rounded corners. The onQueryChanged lambda allows for dynamic updates to the search query state.

Now, let’s integrate this SearchField into our list layout.

import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import com.codebyzebru.spotifycompose.ui.components.SearchField

@Composable
fun PickArtist(...) {
Column(...) {

SearchField(
searchQuery = "",
onQueryChanged = {},
modifier = Modifier
.fillMaxWidth()
.padding(top = 40.dp, bottom = 20.dp)
)

// LazyVerticalGrid
}
}

Now, we need to create one function in our Artist data class to determine whether a given query string matches any part of the artist’s name or its first letter.

import androidx.compose.runtime.MutableState
import androidx.compose.runtime.Stable

@Stable
data class Artist(
val img: String,
val name: String
) {

// ...

fun isMatchWithQuery(queryString: String): Boolean {
val matchResult = listOf(
name, "${name.first()}"
)

return matchResult.any {
it.contains(queryString, true)
}
}
}
  • matchResult: we create a list containing two elements name (The full name of the artist) and name.first() (The first letter of the artist's name).
  • matchResult.any { ... }: This checks if any element in matchResult satisfies the condition inside the lambda.
  • it.contains(queryString, true): It checks if the current element contains the queryString, ignoring case sensitivity (true parameter).
  • The function returns true if any element in matchResult contains the queryString, otherwise false.
  • This function checks if the provided queryString matches either the full name or the first letter of the artist's name and returns true if there's a match, false otherwise.

Now, we’ll use ViewModel to adhere to some best practices of coding standards to keep our code clean and readable.

In the ViewModel, we’ll define MutableStateFlows for Query Text and Artists, along with a function to update the value of QueryText.

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.codebyzebru.spotifycompose.models.Data
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn

class SearchViewModel : ViewModel() {

private val _queryText = MutableStateFlow("")
val queryText = _queryText.asStateFlow()

private val _artists = MutableStateFlow(Data.artistsList)
val artists = queryText
.combine(_artists) { query, artist ->
if (query.isBlank()) {
artist
} else {
artist.filter { it.isMatchWithQuery(query) }
}
}
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000),
_artists.value
)

fun onQueryTextChanged(query: String) {
_queryText.value = query
}

}

Breaking down code for you:

  • _queryText is a private MutableStateFlow initialized with an empty string. It's a type of StateFlow that can be updated with new values.
  • queryText is a public read-only StateFlow created from _queryText using the asStateFlow() method. This makes queryText immutable from outside the ViewModel, exposing only the necessary data.
  • _artists is also a MutableStateFlow initialized with a list of artists (Data.artistsList).
  • artists is a StateFlow that combines queryText and _artists.
    combine() is a function that takes two StateFlow objects and combines their latest values using a lambda function.
    The lambda function filters the artists based on whether the query is blank or matches the artist.
  • If the query is blank, it returns the full list of artists. If not, it filters the list based on the query.
  • To convert the combined flow into a StateFlow within the viewModelScope, we use stateIn().
  • stateIn() converts the flow into a StateFlow.
    viewModelScope is a CoroutineScope tied to the ViewModel lifecycle.
    SharingStarted.WhileSubscribed(5000) means the flow will stay active for 5 seconds after the last subscriber is gone.
    _artists.value is the initial value for the state.
  • onQueryTextChanged is a function that updates the value of _queryText. When the query text changes (e.g., when the user types something in the search bar), this function is called to update _queryText, which in turn triggers the combined flow to update the filtered list of artists.

At this point, the ViewModel is all set up. Now, let’s fetch the data in our Compose function by initializing ViewModel.

import androidx.lifecycle.viewmodel.compose.viewModel
import com.codebyzebru.spotifycompose.viewmodel.SearchViewModel

@Composable
fun PickArtist(
modifier: Modifier = Modifier,
viewModel: SearchViewModel = viewModel()
) {

// ...

}

now let’s collect our state here.

import androidx.compose.runtime.collectAsState

@Composable
fun PickArtist(
modifier: Modifier = Modifier,
viewModel: SearchViewModel = viewModel()
) {

val searchQuery by viewModel.queryText.collectAsState()
val artists by viewModel.artists.collectAsState()

// SearchField

// LazyVerticalGrid

}

collectAsState(): This is an extension function provided by Jetpack Compose that collects the values from a StateFlow or Flow and converts them into a Compose State. This allows Compose to automatically recompose the UI when the data changes.

  • Here, artist is nothing but a List<Artist>, so we can pass that directly into our LazyVerticalGrid.
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed

LazyVerticalGrid(
...
) {
itemsIndexed(artists) { index, item ->
ArtistView(
...
)
}
}

Now, let’s talk about the SearchField. We have searchQuery, which holds our search text, so we'll pass that to the SearchView's searchQuery. Also, for onQueryChange, we have a function in our ViewModel; let's make sure to mention that too.

SearchField(
searchQuery = searchQuery,
onQueryChanged = viewModel::onQueryTextChanged,
// ...
)

And that’s it! We’ve implemented search functionality in our LazyVerticalGrid.

I hope this article has provided valuable insights and assistance for your Android journey. Your support means a lot to me, so if you found this content helpful, please start following me and don’t hesitate to show some love with a hearty round of applause!👏

Your feedback fuels my passion for creating quality content. For any Android queries or just to connect, reach out on LinkedIn and Twitter.

Thanks for reading — looking forward to staying in touch!

Happy Coding!!

--

--

Meet Patadia

Software Developer - Android, Java, Kotlin, MVVM, Jetpack Compose