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.

Note: This is correct, real Compose/Kotlin code — but this website's own project doesn't run a live Android Studio build, so you won't see it actually render on this page.
Next up: Cards builds the recipe list's individual row as a proper Card with elevation, a thumbnail, and a click handler — the component this filter sheet's results feed into.