Implementing a Custom Search Bar
This tutorial will show you how to create a custom search bar in your app that will return a list of items by title or date specified in the search bar. This list will refresh every time the user types a new character or deletes the query with a half second delay (debounce) to ensure the user has paused typing before showing the result.
This app shows a list of hiking trips the user has taken and saved to the database as a list. The user can update what is shown in the list by both making a search query and deleting a trip. This list is initially fetched from the database and the search query operates on the list currently in memory. Deleting a trip notifies the app to update the database and update the tripFlow in the ViewModel.
Trip.kt
@Entity
data class Trip(
@PrimaryKey(autoGenerate = true) var id: Int = 0,
var date: Long = System.currentTimeMillis(),
var title: String = "",
var path: List<PathMarker> = mutableListOf(),
var photos: List<Photo> = mutableListOf(),
var highlightPhoto: Int = 0
)
Our MainScreen composable's BottomAppBar hold the OutlinedTextField that acts as a search bar containing the user's query.
Main
val coroutineScope = rememberCoroutineScope()
var searchText by remember { mutableStateOf("") }
val onSearchUpdated: (String) -> Unit = { search ->
searchText = search
coroutineScope.launch {
viewModel.searchQuery.emit(search)
}
}
BottomAppBar(
actions = {
OutlinedTextField(
modifier = Modifier.padding(start = 8.dp),
value = searchText,
onValueChange = onSearchUpdated,
shape = RoundedCornerShape(12.dp),
singleLine = true,
leadingIcon = {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = ""
)
},
trailingIcon = {
Icon(
modifier = Modifier.clickable {
searchText = ""
coroutineScope.launch {
viewModel.searchQuery.emit("")
}
},
imageVector = Icons.Filled.Clear,
contentDescription = ""
)
}
)
},
Here, searchText is the variable that holds the query that the user typed into the search bar. OnSearchUpdated is the function that is called every time the user types a new character or deletes the query. It emits a new value to the flow in our ViewModel and refreshes our list.
TripViewModel
private var tripFlow = dao.getAll()
var tripList = listOf<Trip>()
private set
val searchQuery = MutableStateFlow("")
@OptIn(FlowPreview::class)
val filteredTrips = merge(
tripFlow,
searchQuery
.debounce(500L)
.flatMapMerge { query ->
tripFlow.map { trips ->
trips.filter { trip ->
trip.title.contains(query, true) ||
trip.getMonthName().contains(query, true) ||
trip.getMonth().toString().contains(query, true) ||
trip.getDay().toString().contains(query, true) ||
trip.getYear().toString().contains(query, true)
}
}
}
)
init {
viewModelScope.launch {
dao.getAll().collect { trips ->
tripList = trips
}
}
}
TripDao
@Query("SELECT * FROM trip ORDER BY date DESC")
fun getAll(): Flow<List<Trip>>
We fetch our tripFlow from our app's dao and initialize the ViewModel with an initial list containing all of our data. The tripFlow contains a list of all the trips in our database. In an app with a large database or that retrieves data over the network, you would fetch and cache a small subset of data, but for this app we are fine to return the entire list since its objects are lightweight and the data is local. The searchQuery is a flow that updates every time the user types or deletes a character.
FilteredTrips is the flow that updates our UI with whatever trips the user's query filters. We start by merging our tripFlow and searchQuery because we want our trip list to update when we perform delete actions as well as perform search queries.
We debounce the list after half a second, which means the list will only refresh after the user stops typing. Now we filter all our trips from our tripList by title, month name as a string, or month, day, or year numerically. We also need to perform a flatMapMerge() to flatten the result since it will return a List of a List of trips.
Main
val lifecycleOwner = LocalLifecycleOwner.current
val tripListFlowLifeCycleAware = remember(viewModel.filteredTrips, lifecycleOwner) {
viewModel.filteredTrips.flowWithLifecycle(
lifecycleOwner.lifecycle,
Lifecycle.State.STARTED
)
}
val tripList: List<Trip> by tripListFlowLifeCycleAware.collectAsState(initial = viewModel.tripList)
We collect the flow here in a lifecycle aware manner. While not strictly necessary for the search itself, performing a collection from within a composable should be done with the lifecycle in mind. Collecting a flow can cause problems if the activity is stopped or destroyed due to configuration changes as the ViewModel would still be alive.
Main
val columns = when (LocalConfiguration.current.orientation) {
Configuration.ORIENTATION_PORTRAIT -> 3
else -> 5
}
LazyVerticalGrid(
modifier = Modifier
.width((columns * PHOTO_WIDTH).dp)
.padding(innerPadding),
columns = GridCells.Fixed(columns),
state = rememberLazyGridState()
) {
items(
items = tripList, <---------
key = { trip -> trip.id } <------ key
) { trip ->
ListContent(
trip = trip,
photo = trip.photos.firstOrNull(),
navigateOnClick = navigateOnClick,
deleteDialogShownOnClick = deleteDialogShownOnClick
)
}
}
Here, this tripList is passed into our lazy list. We also supply the trip's unique id as a key for the list to know how to reorder itself after it changes after the user makes a query or deletes an item. Now, your search query will refresh the list every half second after the user stops typing, creating a responsive search bar.
Happy coding!
Comments
Post a Comment