Build a Simple Sort and Filter Screen in Your Android App

Darush
5 min readJun 5, 2023

When your app presents large amounts of data in a list, users typically want more control over how they interact with it. They commonly use sorting and filtering to achieve this control.

In this post, I’ll demonstrate the implementation of a simple sort and filter functionality. The app retrieves a list of European countries from the https://restcountries.com/ API and provides two sorting options: by country name and population, along with filtering by sub-region.

This app employs various Android tools and components, including Google Material 3, Hilt, Retrofit, Paging, Navigation component, and Data binding.

The implementation code for this project can be found on my GitHub repository at https://github.com/IAmDarush/Rest-Countries-Sample.

Let’s delve into the code!

Implementation

This post will solely concentrate on the UI layer and design implementation of the app, without delving into other layers like networking or dependency injection. If you’re interested in learning about those aspects, feel free to explore the source code repository.

bottom_sheet_filter.xml

I chose to use a modal bottom sheet for the sort and filter screen since it fitted the style and design preferences of this app, though a dialog, fragment, or activity could also work.

Incorporating design elements like Chips, I added single-select and multi-select ChipGroup behaviors for Sort type and Subregion filters, respectively.

I also used a Badge component, on top of the “Reset All” button, to indicate the number of user’s selections. To customize the badge attributes, I created a file named “filter_badge.xml” with the following content and placed it inside res/xml folder.

<?xml version="1.0" encoding="utf-8"?>
<badge xmlns:app="http://schemas.android.com/apk/res-auto"
style="@style/Widget.Material3.Badge"
app:badgeGravity="TOP_END"
app:badgeWidePadding="8dp"
app:badgeWithTextHeight="24dp"
app:badgeWithTextWidth="24dp"
app:horizontalOffset="20dp"
app:maxCharacterCount="3"
app:verticalOffset="20dp" />

To learn more about the material components used, you can visit the Google’s Material Design system official guides:

FilterBottomSheet.kt

This class inflates the design layout file, connects the view model to the views, and handles user interactions. To accomplish this, it extends BottomSheetDialogFragment — a variation of DialogFragment specifically designed to display bottom sheets using BottomSheetDialog instead of a floating dialog.

Let’s eliminate the boilerplate in this post and only focus on the relevant code. You can always visit the repository for the complete code.

class FilterBottomSheet : BottomSheetDialogFragment() {

private var _binding: BottomSheetFilterBinding? = null
private val binding get() = _binding!!
private val countriesViewModel: CountriesViewModel by navGraphViewModels(R.id.countriesNavGraph) {
defaultViewModelProviderFactory
}
private val viewModel: FilterViewModel by viewModels()
private var badgeDrawable: BadgeDrawable? = null

...

@OptIn(ExperimentalBadgeUtils::class)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

...

binding.cgSortTypes.setOnCheckedStateChangeListener { _, checkedIds ->
if (checkedIds.isEmpty()) viewModel.setSortType(SortType.NONE)
else when (checkedIds.first()) {
R.id.chipAlphabeticalSort -> viewModel.setSortType(SortType.ALPHABETICAL_ASC)
R.id.chipByPopulationSort -> viewModel.setSortType(SortType.POPULATION_ASC)
}
}

for (index in 0 until binding.cgSubregion.childCount) {
val chip = binding.cgSubregion.getChildAt(index) as Chip
val subregion = getSubregionFromString(chip.text.toString())
requireNotNull(subregion) { "subregion must not be null" }
chip.isChecked = viewModel.uiState.value.subregions.contains(subregion)
chip.setOnCheckedChangeListener { _, isChecked ->
if (isChecked) viewModel.selectSubregion(subregion)
else viewModel.deselectSubregion(subregion)
}
}

lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
launch {
viewModel.uiState.map { it.filterCount }.distinctUntilChanged()
.collect { count ->
updateBadgeDrawable(count)
}
}

launch {
viewModel.uiState.map { it.applyAllFilters }.distinctUntilChanged()
.collect { shouldApplyFilters ->
if (shouldApplyFilters) {
val filterData = FilterData(
sortType = viewModel.uiState.value.sortType,
subregions = viewModel.uiState.value.subregions
)
countriesViewModel.applyFilters(filterData)
dismiss()
}
}
}

launch {
viewModel.uiState.map { it.clearAllFilters }.distinctUntilChanged()
.collect { shouldResetFilters ->
if (shouldResetFilters) {
countriesViewModel.resetFilters()
dismiss()
}
}
}
}
}

}

...

}

First, to handle Sort type changes, I’m adding a checked state listener in the OnCreateView() method.

Next, as multiple selections are allowed for subregion filter, I’m adding listeners for each subregion Chip.

Finally, I’m observing the UI state changes and performing relevant actions, such as updating the badge drawable state, applying filters and resetting filters on the calling view model.

FilterViewModel.kt

This class handles the communication and data management between the XML layout file bottom_sheet_filter.xml and FilterBottomSheet fragment.

package com.example.restcountries.ui.filter

import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import com.example.restcountries.data.model.SortType
import com.example.restcountries.data.model.Subregion
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update

const val KEY_FILTER_DATA = "filter_data"

data class FilterData(
val sortType: SortType = SortType.NONE,
val subregions: Set<Subregion> = setOf()
) : java.io.Serializable

class FilterViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {

data class UiState(
val sortType: SortType = SortType.NONE,
val subregions: Set<Subregion> = setOf(),
val clearAllFilters: Boolean = false,
val applyAllFilters: Boolean = false
) {
val filterCount: Int
get() {
val sortCount = if (sortType == SortType.NONE) 0 else 1
val filterCount = subregions.size
return sortCount + filterCount
}

val chipAlphabeticalSortIsChecked = sortType == SortType.ALPHABETICAL_ASC
val chipByPopulationSortIsChecked = sortType == SortType.POPULATION_ASC
}

private val _uiState = MutableStateFlow(UiState())
val uiState = _uiState.asStateFlow()

init {

savedStateHandle.get<FilterData>(KEY_FILTER_DATA)?.let { filterData ->
_uiState.update {
it.copy(sortType = filterData.sortType, subregions = filterData.subregions)
}
}

}

fun setSortType(sortType: SortType) {
_uiState.update {
it.copy(sortType = sortType)
}
}

fun selectSubregion(subregion: Subregion) {
_uiState.update {
val subregions = it.subregions.toMutableSet().apply {
add(subregion)
}
it.copy(subregions = subregions)
}
}

fun deselectSubregion(subregion: Subregion) {
_uiState.update {
val subregions = it.subregions.toMutableSet().apply {
remove(subregion)
}
it.copy(subregions = subregions)
}
}

fun resetFilters() {
_uiState.update {
it.copy(clearAllFilters = true, subregions = setOf(), sortType = SortType.NONE)
}
}

fun applyFilters() {
_uiState.update {
it.copy(applyAllFilters = true)
}
}

}

The calling Android component — in our case CountriesFragment — passes the data to the FilterViewModel using an object of the serializable data class FilterData. To check for any previous sort and filter states, I’m using savedStateHandle with the KEY_FILTER_DATA as the key, updating the uiState accordingly.

The remaining methods are just public state mutators that can be accessed by the views.

Addressing Some Quirks

While developing this project, I encountered some exceptional cases that required special consideration and attention.

The first one was that the bottom sheet was not fully expanded inside its parent view.

Bottom sheet content not fully expanded to match the parent height

To address this issue, I included the following code in the onViewCreated() method of the FilterBottomSheet with credit to this Stack Overflow answer.

requireDialog().setOnShowListener {
val bottomSheetBehavior = BottomSheetBehavior.from(binding.bottomSheet)
bottomSheetBehavior.isHideable = false
val bottomSheetParent = binding.bottomSheetParent
BottomSheetBehavior.from(bottomSheetParent.parent as View).peekHeight =
bottomSheetParent.height
bottomSheetBehavior.peekHeight = bottomSheetParent.height
bottomSheetParent.parent.requestLayout()
}

I encountered another issue with the badge drawable where it would not attach to the specified position and would disappear after a chip was selected. To address this problem, I included the following code in the onViewCreated() method:

// Prepare the filters badge once the views are laid out
binding.btnResetAll.viewTreeObserver.addOnGlobalLayoutListener(object :
ViewTreeObserver.OnGlobalLayoutListener {
@OptIn(ExperimentalBadgeUtils::class)
override fun onGlobalLayout() {
badgeDrawable =
BadgeDrawable.createFromResource(requireContext(), R.xml.filter_badge).apply {
horizontalOffsetWithText = binding.frameLayout.width / 4
verticalOffsetWithText =
binding.frameLayout.height / 2 + ViewUtils.dpToPx(resources, 24f)
.toInt() / 2
BadgeUtils.attachBadgeDrawable(
this, binding.btnResetAll, binding.frameLayout
)
}
updateBadgeDrawable(viewModel.uiState.value.filterCount)
binding.btnResetAll.viewTreeObserver.removeOnGlobalLayoutListener(this)
}
})

That wraps up our look at creating a sort and filter sheet in Android. I hope this article has been helpful and informative for you. Please feel free to share any feedback or comments you may have. Happy coding! :)

--

--