DEV Community

Cover image for Bottom Sheet filters like Gmail
Mateusz Utkała
Mateusz Utkała

Posted on

Bottom Sheet filters like Gmail

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'
Enter fullscreen mode Exit fullscreen mode

Effect

Demo app

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?)
Enter fullscreen mode Exit fullscreen mode

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 
}
Enter fullscreen mode Exit fullscreen mode

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
        }
    }
Enter fullscreen mode Exit fullscreen mode

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
            }
        }
    }
Enter fullscreen mode Exit fullscreen mode

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
            }
        }
    }
Enter fullscreen mode Exit fullscreen mode

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)
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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 = ""
                            }
                    )
                }
            })
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
                    }
                )

            }

        }

    }
}
Enter fullscreen mode Exit fullscreen mode

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) }

Enter fullscreen mode Exit fullscreen mode

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)
            }
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

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)