Bottom Sheet
Sorting and filtering options don't fit comfortably in a dialog (too many choices, feels
heavy-handed) or a snackbar (no room for controls) — they belong in a
bottom sheet: a panel that slides up from the bottom edge, partially covering
the screen, that the user can dismiss by swiping down or tapping the scrim behind it. Material
3 Compose exposes this as ModalBottomSheet, and RecipeBox uses one for its
"sort/filter recipes" panel.
ModalBottomSheet & rememberModalBottomSheetState
Like the dialogs in the earlier lesson, a ModalBottomSheet is only composed while
a boolean is true — but it additionally carries its own
SheetState, created with rememberModalBottomSheetState(), which
drives the slide-up/slide-down animation and tracks whether the sheet is partially or fully
expanded:
var showFilterSheet by remember { mutableStateOf(false) }
val sheetState = rememberModalBottomSheetState()
if (showFilterSheet) {
ModalBottomSheet(
onDismissRequest = { showFilterSheet = false },
sheetState = sheetState
) {
// sheet content
}
}
Just like AlertDialog's onDismissRequest, this fires when the user
swipes the sheet down or taps the scrim — you're expected to flip showFilterSheet
back to false there.
Worked Example: Sort/Filter Recipes Sheet
The sheet's own content is ordinary Compose UI — here, a short list of sort options plus a category filter row, followed by an explicit "Apply" button:
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RecipeFilterSheetContent(
selectedSort: RecipeSort,
onSortSelected: (RecipeSort) -> Unit,
onApply: () -> Unit
) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Sort by", style = MaterialTheme.typography.titleMedium)
RecipeSort.values().forEach { sort ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { onSortSelected(sort) }
.padding(vertical = 8.dp)
) {
RadioButton(selected = sort == selectedSort, onClick = { onSortSelected(sort) })
Text(text = sort.label, modifier = Modifier.padding(start = 8.dp))
}
}
Button(
onClick = onApply,
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp)
) {
Text("Apply")
}
}
}
Wired into the screen, opening the sheet is just a state flip triggered from, say, a filter
icon in the TopAppBar from a few lessons back:
ModalBottomSheet(
onDismissRequest = { showFilterSheet = false },
sheetState = sheetState
) {
RecipeFilterSheetContent(
selectedSort = currentSort,
onSortSelected = { currentSort = it },
onApply = { showFilterSheet = false }
)
}
Full Expansion vs. Partial Expansion
By default a ModalBottomSheet may open partially expanded if its content is short
enough, letting the user drag it further up. Passing
SheetState(skipPartiallyExpanded = true) — via
rememberModalBottomSheetState(skipPartiallyExpanded = true) — forces it straight to
full expansion instead, which is often the better choice for a filter panel with several rows of
controls that shouldn't feel cut off.
Card with elevation, a thumbnail, and a click handler — the component this filter sheet's results feed into.