Configuring SearchView in Jetpack Compose

Configuring SearchView in Jetpack Compose

If you have worked in the Android View system, you are most likely familiar with the SearchView widget that can be used to send a request to a search provider, display any available results, and allow users to select them. In Jetpack Compose there is no widget at the time of this writing but it's straightforward to implement it in several different ways.

I would like to walk through one approach I have built and used in my app that might work for your use case. Here is the outcome we will be working towards.

If you want to skip reading ahead and instead just want to review the source code, you can find it here.

If you do want to read about the implementation, let's dive in.

Building the Composable Search Bar UI

While the existing view system widget is called SearchView, I prefer naming the new component SearchBar. If we think about a SearchBar there are two main UI components:

  • Search Bar for entering the search text
  • Results tied to the search text

We need a composable which ties the search bar to the results and update the search results accordingly. Let's look at how to build these two components.

SearchBar

Since we would be typing in text, we want to position this at the top of the screen, so there is enough real estate for the keyboard and the search results. We can leverage the Material TopAppBar and build our SearchBar on top of it by leveraging the predefined slots.

TopAppBar(title = { Text("") }, navigationIcon = {
        IconButton(onClick = { onNavigateBack() }) {
            Icon(
                imageVector = Icons.Filled.ArrowBack,
                modifier = Modifier,
                contentDescription = stringResource(id = R.string.icn_search_back_content_description)
            )
        }
    }, actions = {
    
    }
 )
SearchBarUI.kt

Here we provided a navigation icon to allow the user to navigate back to the previous screen and call a function on the click event to pass this event to the parent composable function to perform the desired navigation.

Next, let's populate the actions RowScope slot. Here we will use an OutlinedTextField for the user to enter the search text and have it take up the remaining space.

We need to pass certain arguments from the parent composable as well as store the state for others. Below is a list of all the state members that are needed.

var showClearButton by remember { mutableStateOf(false) }
val keyboardController = LocalSoftwareKeyboardController.current
val focusRequester = remember { FocusRequester() }

LaunchedEffect(Unit) {
   focusRequester.requestFocus()
}
SearchBarUI.kt
  • showClearButton: We will use this to toggle the visibility of the clear button in the search text field when it has focus.
  • keyboardController: Used to hide the keyboard when pressing the done on the keyboard.
  • focusRequester: remember the state of the focusRequester when being used in the modifier. We want the search text to have focus every time the screen is launched, so we use  LaunchedEffect and set the focus.
OutlinedTextField(
    modifier = Modifier
        .fillMaxWidth()
        .padding(vertical = 2.dp)
        .onFocusChanged { focusState ->
            showClearButton = (focusState.isFocused)
        }
        .focusRequester(focusRequester),
    value = searchText,
    onValueChange = onSearchTextChanged,
    placeholder = {
        Text(text = placeholderText)
    },
    colors = TextFieldDefaults.textFieldColors(
        focusedIndicatorColor = Color.Transparent,
        unfocusedIndicatorColor = Color.Transparent,
        backgroundColor = Color.Transparent,
        cursorColor = LocalContentColor.current.copy(alpha = LocalContentAlpha.current)
    ),
    trailingIcon = {
        AnimatedVisibility(
            visible = showClearButton,
            enter = fadeIn(),
            exit = fadeOut()
        ) {
            IconButton(onClick = { onClearClick() }) {
                Icon(
                    imageVector = Icons.Filled.Close,
                    contentDescription = stringResource(id = R.string.icn_search_clear_content_description)
                )
            }

        }
    },
    maxLines = 1,
    singleLine = true,
    keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done),
    keyboardActions = KeyboardActions(onDone = {
        keyboardController?.hide()
    }),
)
SearchBarUI.kt

Quite a few things are going on in the above code, let's break down the main items

  • We use the onFocusChanged function on the Modifier to show the clear icon button when the text field has the focus
  • Add a focusRequester to the modifier to set focus on the search bar when initially opening the search UI.
  • Add a placeholder text, which will be passed from the parent
  • Set the value of the OutlinedTextField and also set the onValueChange event handler
  • Use to colors property to remove the default border that comes with OutlinedTextField
  • Set the trailing icon to show a clear button, which will display only when the text field has focus.

Note: I have used the experimental AnimatedVisiblity but you don't have to use it if you want to avoid experimental APIs in your production app. Instead, just use an if condition to show the icon.

  • Handle the onDone event from the keyboard to hide the keyboard.

Combining everything we should now have the below composable function

@ExperimentalAnimationApi
@ExperimentalComposeUiApi
@Composable
fun SearchBar(
    searchText: String,
    placeholderText: String = "",
    onSearchTextChanged: (String) -> Unit = {},
    onClearClick: () -> Unit = {},
    onNavigateBack: () -> Unit = {}
) {
    var showClearButton by remember { mutableStateOf(false) }
    val keyboardController = LocalSoftwareKeyboardController.current
    val focusRequester = remember { FocusRequester() }



    TopAppBar(title = { Text("") }, navigationIcon = {
        IconButton(onClick = { onNavigateBack() }) {
            Icon(
                imageVector = Icons.Filled.ArrowBack,
                modifier = Modifier,
                contentDescription = stringResource(id = R.string.icn_search_back_content_description)
            )
        }
    }, actions = {

        OutlinedTextField(
            modifier = Modifier
                .fillMaxWidth()
                .padding(vertical = 2.dp)
                .onFocusChanged { focusState ->
                    showClearButton = (focusState.isFocused)
                }
                .focusRequester(focusRequester),
            value = searchText,
            onValueChange = onSearchTextChanged,
            placeholder = {
                Text(text = placeholderText)
            },
            colors = TextFieldDefaults.textFieldColors(
                focusedIndicatorColor = Color.Transparent,
                unfocusedIndicatorColor = Color.Transparent,
                backgroundColor = Color.Transparent,
                cursorColor = LocalContentColor.current.copy(alpha = LocalContentAlpha.current)
            ),
            trailingIcon = {
                AnimatedVisibility(
                    visible = showClearButton,
                    enter = fadeIn(),
                    exit = fadeOut()
                ) {
                    IconButton(onClick = { onClearClick() }) {
                        Icon(
                            imageVector = Icons.Filled.Close,
                            contentDescription = stringResource(id = R.string.icn_search_clear_content_description)
                        )
                    }

                }
            },
            maxLines = 1,
            singleLine = true,
            keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done),
            keyboardActions = KeyboardActions(onDone = {
                keyboardController?.hide()
            }),
        )


    })


    LaunchedEffect(Unit) {
        focusRequester.requestFocus()
    }
}
SearchBarUI.kt

No Search Results

Now that we have the SearchBar composable let's look at creating the search results composable when there are no matches. We will use a simple function as below:

@Composable
fun NoSearchResults() {
    
   Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center,
        horizontalAlignment = CenterHorizontally
    ) {
        Text("No matches found")
    }
}
SearchBarUI.kt

Nothing fancy here, but we can always make it fancy later if needed :)

SearchBarUI

Finally, let's define our SearchBarUI that we will use as a reusable composable function as needed in our app.

@ExperimentalAnimationApi
@ExperimentalComposeUiApi
@Composable
fun SearchBarUI(
    searchText: String,
    placeholderText: String = "",
    onSearchTextChanged: (String) -> Unit = {},
    onClearClick: () -> Unit = {},
    onNavigateBack: () -> Unit = {},
    matchesFound: Boolean,
    results: @Composable () -> Unit = {}
) {

    Box {
        Column(
            modifier = Modifier
                .fillMaxSize()
        ) {

            SearchBar(
                searchText,
                placeholderText,
                onSearchTextChanged,
                onClearClick,
                onNavigateBack
            )

            if (matchesFound) {
                Text("Results", modifier = Modifier.padding(8.dp), fontWeight = FontWeight.Bold)
                results()
            } else {
                if (searchText.isNotEmpty()) {
                    NoSearchResults()
                }

            }
        }

    }
}
SearchBarUI.kt

Notice that we don't define any UI for the results. Since we want this to be generic enough for any use case we just expose a Composable function as a parameter and just render it when there are matches. All the remaining parameters are passed to the SearchBar composable as arguments.

Implementation

We have our reusable SearchBarUI function ready to go, woohoo! Let's look at how we can implement it in an app. We will be searching a list of users from a fake user dataset that I downloaded online. I saved this in a JSON file for this demo and will be reading the users from this file. Our app uses ViewModel as the primary source of truth for the Search Screen.

If you are new to Jetpack Compose, I highly recommend reading about state holders to understand the different ways of managing the state.

Configuring State in ViewModel

We use a combined flow in our ViewModel to expose the data elements needed for managing the state of the search screen. The flow uses the below data class.

data class UserSearchModelState(
    val searchText: String = "",
    val users: List<User> = arrayListOf(),
    val showProgressBar: Boolean = false
) {

    companion object {
        val Empty = UserSearchModelState()
    }

}
UserSearchModelState.kt

The combined flow returns the above data class from combining multiple state flow elements in our ViewModel. Anytime any of these variables change in our ViewModel, the combined flow notifies our composable function of the changes and the recomposition happens only for elements that changed.

private var allUsers: ArrayList<User> = ArrayList<User>()
private val searchText: MutableStateFlow < String > = MutableStateFlow("")
private var showProgressBar: MutableStateFlow<Boolean> = MutableStateFlow(false)
private var matchedUsers: MutableStateFlow<List<User>> = MutableStateFlow(arrayListOf())

val userSearchModelState = combine(
    searchText,
    matchedUsers,
    showProgressBar
) {
    text, matchedUsers, showProgress ->

        UserSearchModelState(
            text,
            matchedUsers,
            showProgress
        )
}

init {
    retrieveUsers()
}

fun retrieveUsers() {
    val users = userRepository.getUsers()

    if (users != null) {
        allUsers.addAll(users)
    }
}
UserSearchViewModel.kt
  • allUsers: Stores all the available users. For large data sets, we would use a repository class to send the search request and get back the matched responses instead of retrieving all the data for in-memory search.
  • matchedUsers: Users that matched the search criteria, that are rendered in our composable

Handling Events in ViewModel from the Search Bar UI

We have two events for the SearchBar UI that we will handle in the ViewModel.

Search

This method handles the actual search based on the criteria we define. For this demo, I'm just going to search on username, email, and name fields and set the value to the matchedUsers variable.

fun onSearchTextChanged(changedSearchText: String) {
    searchText.value = changedSearchText
    if (changedSearchText.isEmpty()) {
        matchedUsers.value = arrayListOf()
        return
    }
    val usersFromSearch = allUsers.filter { x ->
        x.username.contains(changedSearchText, true) ||
                x.email.contains(changedSearchText, true) || x.name.contains(
            changedSearchText,
            true
        )
    }
    matchedUsers.value = usersFromSearch
}
UserSearchViewModel.kt

Clear

This method just clears out the values

fun onClearClick() {
    searchText.value = ""
    matchedUsers.value = arrayListOf()
}
UserSearchViewModel.kt

Hooking Up the ViewModel with the Composable

Showtime! We have all the pieces in place to implement the functionality. Let's hook up our ViewModel with the composable that has our reusable SearchBarUI.

@ExperimentalComposeUiApi
@ExperimentalAnimationApi
@Composable
fun UserSearchUI(navHostController: NavHostController, userSearchViewModel: UserSearchViewModel) {
    val userSearchModelState by rememberFlowWithLifecycle(userSearchViewModel.userSearchModelState)
        .collectAsState(initial = UserSearchModelState.Empty)
    SearchBarUI(
        searchText = userSearchModelState.searchText,
        placeholderText = "Search users",
        onSearchTextChanged = { userSearchViewModel.onSearchTextChanged(it) },
        onClearClick = { userSearchViewModel.onClearClick() },
        onNavigateBack = {
            navHostController.popBackStack()
        },
        matchesFound = userSearchModelState.users.isNotEmpty()
    ) {

        Users(users = userSearchModelState.users) {
            user ->
            navHostController.navigate(route = "${NavPath.UserDetail.route}?id=${user.id}")
        }
    }
}

UserSearchUI.kt

Remember the userSearchModelState that we have defined in our ViewModel? We now have to remember it in our composable. We do this by leveraging the rememberFlowWithLifecycle method, which is essentially a wrapper for flowWithLifecycle

The users found from the search are then displayed using the below composable code.

@Composable
fun Users(users: List<User>?, onClick: (User) -> Unit) {
    users?.forEach { user ->
        UserRow(user = user) {
            onClick(user)
        }
        Divider()
    }
}


@Composable
fun UserRow(user: User, onClick: () -> Unit) {
    Column(modifier = Modifier
        .fillMaxWidth()
        .padding(8.dp)
        .clickable { onClick() }) {
        Text(user.name, fontSize = 14.sp, fontWeight = FontWeight.Bold)
        Spacer(modifier = Modifier.height(2.dp))
        Text(user.email)
        Spacer(modifier = Modifier.height(4.dp))
    }
}

Hope this helps you understand how you can build your SearchBarUI for your app. Let me know what you think about this implementation and if there are ways to improve it in the comments below. Cheers!

For complete working code, see here.