This app has five modal surfaces: the pizza builder, the cart, sign-in, registration and the address form — all bottom sheets — plus a confirmation dialog before anything is deleted. On a phone, a sheet is the right tool for a focused task that should not lose the customer's place: they finish or dismiss it and are exactly where they were.
Compose presents them in a way that surprises people coming from the View system or from iOS, and the surprise is the whole lesson.
A sheet is shown by being composed
There is no show(). A ModalBottomSheet is on screen while it is in the
composition, and dismissed when it leaves it. Whether it is composed is an ordinary
if:
val loaded = catalogue.valueOrNull
val openProduct = loaded?.product(openProductId ?: "")
if (loaded != null && openProduct != null) {
PizzaBuilderSheet(
state = rememberPizzaBuilderState(openProduct, loaded),
onAdd = { builder ->
viewModel.addToCart(builder)
openProductId = null
},
onDismiss = { openProductId = null },
)
}
The state is "which product's builder is open" — an id, saved with rememberSaveable so
the sheet reopens after a rotation. Tapping a product sets it; adding to the cart or dismissing clears
it. Nothing anywhere calls "open" or "close"; the sheet follows the state, like every other part of
the UI.
It follows that a second builder cannot stack on the first. There is one nullable id, so there is at most one open product. That property — the state making a bad situation unrepresentable — is worth designing for deliberately, and the root of the app does exactly that below.
ModalBottomSheet
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = PizzaTheme.colors.background,
) {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(horizontal = Spacing.lg)
.navigationBarsPadding(),
verticalArrangement = Arrangement.spacedBy(Spacing.lg),
// …
onDismissRequestis called when the customer swipes the sheet down, taps the dimmed scrim, or presses Back. It is a request: the sheet stays until your state changes. Here it clears the open product.rememberModalBottomSheetState(skipPartiallyExpanded = true)— by default a tall sheet opens half-way and the customer drags it up. The builder is a whole task with its button at the bottom; half-open would hide the button they came for, so it opens fully.verticalScrollinside the sheet, because the content is taller than a small phone, andnavigationBarsPadding()so the last button is not drawn under the gesture bar.
The cart makes the opposite choice:
ModalBottomSheet(onDismissRequest = onDismiss, containerColor = PizzaTheme.colors.background) {
CartContent(
cart = cart,
totals = totals,
onQuantityChange = viewModel::setQuantity,
onRemove = viewModel::remove,
onOrderTypeChange = viewModel::setOrderType,
onCheckout = onCheckout,
)
}
No sheet state at all, so it gets the default, which may open half-way. A cart with one line does not need the whole screen, and the smaller sheet leaves the menu visible behind it — a reminder of where the customer will land when they close it.
The exit animation
Removing a sheet from composition removes it immediately; it does not slide away. When that matters, the documented pattern is to animate first and remove afterwards:
val scope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState()
PizzaButton("Add to cart", onClick = {
scope.launch { sheetState.hide() }.invokeOnCompletion {
if (!sheetState.isVisible) openProductId = null
}
})
hide() suspends until the slide finishes, then the state is cleared. This app uses the
simpler version — the builder vanishes when you add — because a toast immediately confirms what
happened, and an extra coroutine per button is a cost every reader of the code pays. Choose per
sheet.
One sheet at a time, by construction
The cart, sign-in and registration sheets can be opened from anywhere: the top bar, the Orders tab, the Profile tab, from each other. The naive implementation is three booleans — and three booleans allow two sheets to be "open" at once, which renders two sheets stacked on top of each other. One enum makes that impossible:
enum class AppSheet { CART, SIGN_IN, REGISTER }
private val _sheet = MutableStateFlow<AppSheet?>(null)
val sheet: StateFlow<AppSheet?> = _sheet.asStateFlow()
The root composes whichever sheet the value names:
when (sheet) {
AppSheet.CART -> CartSheet(
onCheckout = {
viewModel.dismissSheet()
navController.navigate(CheckoutRoute) { launchSingleTop = true }
},
onDismiss = viewModel::dismissSheet,
)
AppSheet.SIGN_IN -> SignInSheet(
onDismiss = viewModel::dismissSheet,
onSwitchToRegister = { viewModel.showSheet(AppSheet.REGISTER) },
)
AppSheet.REGISTER -> RegisterSheet(
onDismiss = viewModel::dismissSheet,
onSwitchToSignIn = { viewModel.showSheet(AppSheet.SIGN_IN) },
)
null -> Unit
}
Switching from sign-in to registration is one assignment — showSheet(REGISTER) — which
replaces the sign-in sheet with the registration sheet in a single state change. With booleans it
would be two writes, with a frame in between where either both or neither were open.
Screens never touch this state directly. The Orders tab's "Sign in" button calls an
onSignIn lambda; the root turns that into showSheet(SIGN_IN). The same
screen-reports, root-decides split as navigation.
Sheets and toasts
A ModalBottomSheet is drawn in its own window, above the activity's content. That has
one visible consequence. The toast host is placed once, at the root, above every screen:
ToastHost(toasts, onDismiss = viewModel::dismissToast, modifier = Modifier.align(Alignment.TopCenter))
Above every screen — but a sheet is not in that window, so a toast shown while a sheet is open appears underneath it, and becomes visible when the sheet closes.
The builder closes itself before showing "added to your cart", so the confirmation is never hidden behind the sheet that caused it.
Resetting a form for free
The address sheet is used both to add an address and to edit one. Its fields must start empty for a
new address and pre-filled for an edit — and must not carry over the half-typed values from the last
time it was open. The iOS version gets this from sheet(item:) building a fresh view per
item. Compose gets it from keyed state:
var label by rememberSaveable(editing?.id) { mutableStateOf(editing?.label.orEmpty()) }
var line1 by rememberSaveable(editing?.id) { mutableStateOf(editing?.line1.orEmpty()) }
var line2 by rememberSaveable(editing?.id) { mutableStateOf(editing?.line2.orEmpty()) }
var city by rememberSaveable(editing?.id) { mutableStateOf(editing?.city.orEmpty()) }
var state by rememberSaveable(editing?.id) { mutableStateOf(editing?.state.orEmpty()) }
var postalCode by rememberSaveable(editing?.id) { mutableStateOf(editing?.postalCode.orEmpty()) }
rememberSaveable(editing?.id) keys each field on the address being edited. Opening the
sheet for a different address — or for a new one — is a different key, so the stored values are thrown
away and the initial values recomputed. There is no "reset the form" function, and therefore no bug
where someone forgets to call it.
The caller opens the sheet the same way as the builder, with a saveable id:
editingAddressId?.let { id ->
val editing = state.valueOrNull?.addresses?.firstOrNull { it.id == id }
AddressFormSheet(
editing = editing,
onSave = { request ->
viewModel.saveAddress(editing?.id, request)
editingAddressId = null
},
onDismiss = { editingAddressId = null },
)
}
An empty string means "new address", an id means "edit that one", and null means
closed. Three states, one variable.
Dialogs
Deleting an address or a card cannot be undone, so it asks first. An AlertDialog works
exactly like a sheet: composed means shown. The state is what is about to be deleted:
private sealed interface PendingDelete {
val id: UuidString
val description: String
data class AddressDelete(override val id: UuidString, override val description: String) : PendingDelete
data class CardDelete(override val id: UuidString, override val description: String) : PendingDelete
}
pendingDelete?.let { pending ->
AlertDialog(
onDismissRequest = { pendingDelete = null },
title = { Text("Delete this?") },
text = { Text(pending.description) },
confirmButton = {
TextButton(onClick = {
when (pending) {
is PendingDelete.AddressDelete -> viewModel.deleteAddress(pending.id)
is PendingDelete.CardDelete -> viewModel.deletePaymentMethod(pending.id)
}
pendingDelete = null
}) { Text("Delete", color = PizzaTheme.colors.danger) }
},
dismissButton = { TextButton(onClick = { pendingDelete = null }) { Text("Keep") } },
)
}
A few conventions worth copying. The confirm button says what it does — "Delete" — rather than
"OK", and is coloured as dangerous. The dismiss button is "Keep", which is unambiguous in a way
"Cancel" is not when the action itself is a kind of cancelling. Tapping outside or pressing Back calls
onDismissRequest, which must behave like "Keep": a destructive action never happens
because someone tapped the wrong part of the screen.
Material's AlertDialog also handles the accessibility details for you: focus moves
into the dialog when it opens, TalkBack announces the title first, and content behind it cannot be
reached until it closes. A hand-drawn overlay pretending to be a dialog gets none of that.
One honest trade-off: pendingDelete uses plain remember, so rotating the
phone with the dialog open closes it. A sealed type is not Parcelable; to keep the dialog
across rotation you would save the id and the kind instead. For a two-second confirmation, losing it
is acceptable — the customer taps Delete again. For a long form in a dialog, it would not be.
Sheets are not destinations — usually
None of these sheets is in the navigation graph. They are state in a screen or in the root, which has consequences worth choosing deliberately.
Back still works: ModalBottomSheet intercepts the Back gesture while it is open and
calls onDismissRequest, so Back closes the sheet before it pops a screen. That is the
behaviour customers expect, with no code.
What a sheet does not get is a place in the back stack. It cannot be the target of a deep
link, and its arguments are whatever its owner saved. For the builder (one saved id) and the cart
(app-wide state) that is exactly right. For something a notification should open — "your order is
ready" — a destination is better, and Navigation Compose has dialog<Route> for a
dialog that is a destination, with typed arguments and its own back-stack entry.
The rule of thumb this app follows: if only this app's own buttons ever open it, make it state; if anything outside the app should be able to open it, make it a destination.
State-driven sheets are also simple to test. A screen test sets the state that means "builder open for pepperoni" and asserts on what is shown; it never has to drive a gesture to open the sheet first.
When to use which
- A bottom sheet for a task with several inputs that the customer may abandon: building a pizza, signing in, editing an address.
- A dialog for one decision that blocks everything else: confirm a deletion, accept terms.
- A screen for anything with its own navigation, anything long enough to scroll substantially, or anything a deep link should reach. Checkout is a screen, not a sheet, for all three reasons.
Next
Every sheet, dialog and card so far has drawn its colours from somewhere. The next lesson is that
somewhere: a design system built on Material 3, where Material's colour scheme fits, where it runs
out, and how a CompositionLocal fills the gap.