Did you notice new Gmail filters? Is it useful? I think it is very intuitive and handy. I decided to make similar filter for one of my project. I want to share with you some code to show how it is done by me.
Pre-requirements
- Android compose project
- Kotlin lifecycle KTX
def lifecycle_version = "2.5.1"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycle_version"
implementation 'androidx.compose.material:material:1.3.1'
Effect
Let's build it together
Below I'll show you step by step how to implement this feature using simple lists. For production purpose you probably use repository and remote API calls. In general it should work the same.
Data model
For this post I create simple data class which hold Person and Job.
enum class Job {
PROGRAMMER, TESTER, WEB_DEVELOPER
}
data class Person(val id: String, val name: String, val surname: String, val job: Job?)
Filters
To provide simple but efficient way to manage filters we will use sealed class and Filterable interface.
interface Filterable<T> {
fun filter(list: List<T>): List<T>
}
sealed class FilterablePerson() : Filterable<Person> {
var isEnabled: Boolean = true
// classes pasted below
}
We use isEnabled to determine which filter is active.
Lets define our filters:
Person name filter
Due to power of sealed class we can add various constructor parameters. In this filter we grab condition string as predicate for searching by name. Take a look on custom setter that manage isEnabled field. Which is used in filter method to make sure that search is required.
class PersonNameFilter(condition: String) : FilterablePerson() {
var condition = condition
set(value) {
isEnabled = value.isNotBlank()
field = value
}
override fun filter(list: List<Person>): List<Person> {
return if (isEnabled)
list.filter { it.name.contains(condition) }
else
list
}
}
Job filter
This is another kind of filter. Now we expect list of Jobs insted of string. It is used to filter one or more jobs at once.
As above, custom setter determine that filter should be enabled or not. And main filter function do right filter.
class PersonJobsFilter(condition: List<Job>?) : FilterablePerson() {
var condition = condition
set(value) {
isEnabled = value?.isEmpty() == false
field = value
}
override fun filter(list: List<Person>): List<Person> {
return if (isEnabled) {
list.filter { condition!!.contains(it.job) }
} else {
list
}
}
}
Unemployed filter
This is the easiest filter. It expect only state if is enabled or not.
class UnemployedPersonFilter(state: Boolean) : FilterablePerson() {
var isEnabled = state
override fun filter(list: List<Person>): List<Person> {
return if (isEnabled) {
list.filter { it.job == null }
} else {
list
}
}
}
View model
This is last part of our logic. Let's define very simple ViewModel that can hold initial people list (repository in production) and applied filters.
Note that openFilter and applyFilter methods checks if this kind of filter is on appliedFilters. It is important because:
- it store isEnabled and condition directly, so we can use it in UI
- it prevent add duplicated filter
Rest of functions and properties will be described in UI section.
class PersonFilterViewModel : ViewModel() {
private val people: List<Person> = listOf<Person>(
Person("E0001", "John", "Doe", Job.PROGRAMMER),
Person("E0002", "Ellen", "Cunningham", Job.PROGRAMMER),
Person("E0003", "Carmen", "Walker", Job.TESTER),
Person("E0004", "Mike", "Walker", Job.PROGRAMMER),
Person("E0005", "Edgar", "Bourn", Job.WEB_DEVELOPER),
Person("E0006", "Richard", "Robson", Job.WEB_DEVELOPER),
Person("E0007", "Ralph", "Poe", Job.WEB_DEVELOPER),
Person("E0008", "Max", "Smith", null)
)
private val _filteredList = MutableStateFlow<List<Person>>(people)
val list: StateFlow<List<Person>> = _filteredList
private var appliedFilters = mutableListOf<FilterablePerson>()
private val _filterState = MutableStateFlow<FilterablePerson?>(null)
val filterState = _filterState
fun openFilter(filter: FilterablePerson) {
_filterState.update {
appliedFilters.find { it.javaClass == filter.javaClass } ?: filter
}
}
fun closeFilter() {
_filterState.update { null }
}
fun applyFilter(filter: FilterablePerson) {
appliedFilters.removeIf { it.javaClass == filter.javaClass }
appliedFilters.add(filter)
closeFilter()
_filteredList.update {
appliedFilters.fold(people) { list, filter ->
filter.filter(list)
}
}
}
}
UI
Finally we can go to layouts.
Text Filter Dialog
@Composable
fun TextFilterDialog(title: String, initialValue: String = "", onConfirm: (String) -> Unit, onDismiss: () -> Unit) {
var filterValue by remember {
mutableStateOf(initialValue)
}
Column(
modifier = Modifier
.padding(16.dp)
) {
BottomSheetTitle(
title = title,
onDismiss = onDismiss,
onConfirm = { onConfirm(filterValue) }
)
OutlinedTextField(
value = filterValue,
onValueChange = { filterValue = it },
modifier = Modifier
.padding(vertical = 8.dp)
.fillMaxWidth(),
singleLine = true,
trailingIcon = {
if (filterValue.isNotBlank()) {
Icon(Icons.Default.Clear,
contentDescription = "clear text",
modifier = Modifier
.clickable {
filterValue = ""
}
)
}
})
}
}
Dynamic Checkbox Filter Dialog
@Composable
fun <T> CheckboxFilterDialog(title: String, selectedItems: List<T>, items: List<T>, onConfirm: (List<T>) -> Unit, onDismiss: () -> Unit) {
val (selectedJobs, selectJob) = remember {
mutableStateOf(selectedItems)
}
Column(
modifier = Modifier.padding(16.dp)
) {
BottomSheetTitle(
title,
onDismiss = onDismiss,
onConfirm = { onConfirm(selectedJobs) }
)
items.forEach { job ->
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = selectedJobs.contains(job),
onCheckedChange = {
val list = selectedJobs.toMutableList()
if (selectedJobs.contains(job)) {
list.remove(job)
} else {
list.add(job)
}
selectJob(list)
})
Text(
text = job.toString(),
modifier = Modifier.clickable {
val list = selectedJobs.toMutableList()
if (selectedJobs.contains(job)) {
list.remove(job)
} else {
list.add(job)
}
selectJob(list)
}
)
}
}
}
}
BottomSheetLayout
We need to set states for our filter buttons.
ModalBottomSheetLayout require to set bottomSheetState. It help us to determinete if modal is show or hidden. If user dissmiss modal we call vm.closeFilter().
val vm = viewModel(PersonFilterViewModel::class.java)
val peopleList by vm.list.collectAsState()
val filterState by vm.filterState.collectAsState()
val coroutineScope = rememberCoroutineScope()
val bottomSheetState = rememberModalBottomSheetState(
initialValue = ModalBottomSheetValue.Hidden,
confirmStateChange = {
if (it == ModalBottomSheetValue.Hidden) {
vm.closeFilter()
}
it != ModalBottomSheetValue.HalfExpanded
}
)
val lazyListState = rememberLazyListState()
val scrollState = rememberScrollState()
var unemployedFilterState by rememberSaveable { mutableStateOf(false) }
var jobFilterState by rememberSaveable { mutableStateOf(false) }
var nameState by rememberSaveable { mutableStateOf(false) }
var surnameState by rememberSaveable { mutableStateOf(false) }
If states are defined, let prepare our layout.
sheetContent contain modal content witch will be dynamicly populated by filterState value. FilterState is base sealed class so when expresion know all PersonFilter classes. We can use it to prepare modal layout and set the initial values for filters and finally use isEnabled filter property to set FilterButton state.
Content part define base layout to show our Filter buttons row and list of People. Each button call ViewModel.openFilter with initial PersonFilter class.
ModalBottomSheetLayout(
sheetState = bottomSheetState,
sheetContent = {
Column(modifier = Modifier.fillMaxSize()) {
when (filterState) {
is FilterablePerson.PersonJobsFilter -> {
val jobFilter = (filterState as FilterablePerson.PersonJobsFilter)
CheckboxFilterDialog(
title = getString(R.string.filter_jobs),
selectedItems = jobFilter.condition ?: listOf(),
items = Job.values().toList(),
onConfirm = { jobList ->
jobFilter.condition = jobList
vm.applyFilter(jobFilter)
jobFilterState = jobFilter.isEnabled
coroutineScope.launch { bottomSheetState.hide() }
},
onDismiss = { coroutineScope.launch { bottomSheetState.hide() } }
)
}
is FilterablePerson.PersonNameFilter -> {
val nameFilter = filterState as FilterablePerson.PersonNameFilter
TextFilterDialog(
title = getString(R.string.filter_names),
initialValue = nameFilter.condition,
onConfirm = { filterString ->
nameFilter.condition = filterString
vm.applyFilter(nameFilter)
nameState = nameFilter.isEnabled
coroutineScope.launch { bottomSheetState.hide() }
},
onDismiss = {
coroutineScope.launch { bottomSheetState.hide() }
}
)
}
is FilterablePerson.PersonSurnameFilter -> {
val surnameFilter = filterState as FilterablePerson.PersonSurnameFilter
TextFilterDialog(
title = getString(R.string.filter_surnames),
initialValue = surnameFilter.condition,
onConfirm = { filterString ->
surnameFilter.condition = filterString
vm.applyFilter(surnameFilter)
surnameState = surnameFilter.isEnabled
coroutineScope.launch { bottomSheetState.hide() }
},
onDismiss = {
coroutineScope.launch { bottomSheetState.hide() }
}
)
}
is FilterablePerson.UnemployedPersonFilter -> {
val unemployedFilter = filterState as FilterablePerson.UnemployedPersonFilter
unemployedFilter.isEnabled = !unemployedFilterState
vm.applyFilter(unemployedFilter)
unemployedFilterState = unemployedFilter.isEnabled
}
null -> {} // nothing to do
}
}
},
modifier = Modifier.fillMaxWidth(),
) {
Column {
Row(
modifier = Modifier.horizontalScroll(state = scrollState),
horizontalArrangement = Arrangement.SpaceBetween
) {
FilterButton(selected = jobFilterState, onClick = {
vm.openFilter(FilterablePerson.PersonJobsFilter(listOf()))
coroutineScope.launch { bottomSheetState.show() }
}, modifier = Modifier.padding(8.dp)) {
Text(text = getString(R.string.filter_jobs))
}
FilterButton(selected = unemployedFilterState, onClick = {
vm.openFilter(FilterablePerson.UnemployedPersonFilter(unemployedFilterState))
}, modifier = Modifier.padding(8.dp)) {
Text(text = getString(R.string.filter_unemployed))
}
FilterButton(selected = nameState, onClick = {
vm.openFilter(FilterablePerson.PersonNameFilter(""))
coroutineScope.launch { bottomSheetState.show() }
}, modifier = Modifier.padding(8.dp)) {
Text(text = getString(R.string.filter_names))
}
FilterButton(selected = surnameState, onClick = {
vm.openFilter(FilterablePerson.PersonSurnameFilter(""))
coroutineScope.launch { bottomSheetState.show() }
}, modifier = Modifier.padding(8.dp)) {
Text(text = getString(R.string.filter_surnames))
}
}
Divider(modifier = Modifier.height(1.dp))
LazyColumn(state = lazyListState) {
items(key = { it.id }, items = peopleList) { person ->
PersonRow(person = person)
}
}
}
}
That's it! :)
Feel free to check repo
This is my first post. Let me know what do you think about this project :)
Top comments (0)