Bottom Sheet filters like Gmail
Tags: android, kotlin, jetpackcompose
Published: 2023-01-02 09:45:00

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
```grovy
    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
![Demo app](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ngx9nvu8nng7z92y34re.gif)

## 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](https://github.com/DonMat/compose-bottom-sheet-filters-demo/blob/main/app/src/main/java/pl/utkala/buttonfilters/model/Person.kt)
For this post I create simple data class which hold Person and Job.

```kotlin
enum class Job {
    PROGRAMMER, TESTER, WEB_DEVELOPER
}

data class Person(val id: String, val name: String, val surname: String, val job: Job?)
```

### [Filters](https://github.com/DonMat/compose-bottom-sheet-filters-demo/blob/read/app/src/main/java/pl/utkala/buttonfilters/data/Filterable.kt)
To provide simple but efficient way to manage filters we will use _sealed class_ and _Filterable interface_.

```kotlin
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. 

``` kotlin

    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.

```kotlin
    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.

```kotlin
    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](https://github.com/DonMat/compose-bottom-sheet-filters-demo/blob/read/app/src/main/java/pl/utkala/buttonfilters/viewmodel/PersonFilterViewModel.kt)
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.

```kotlin
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](https://github.com/DonMat/compose-bottom-sheet-filters-demo/blob/read/app/src/main/java/pl/utkala/buttonfilters/ui/FilterBottomSheet.kt)

```kotlin
@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](https://github.com/DonMat/compose-bottom-sheet-filters-demo/blob/read/app/src/main/java/pl/utkala/buttonfilters/ui/FilterBottomSheet.kt)

```kotlin
@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()_.

```kotlin
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.


```kotlin
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](https://github.com/DonMat/compose-bottom-sheet-filters-demo)

This is my first post. Let me know what do you think about this project :)