Android – Bottom Sheets and Dialogs

August 29, 20268 min readUpdated 10/11/2026

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),
            // …
  • onDismissRequest is 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.
  • verticalScroll inside the sheet, because the content is taller than a small phone, and navigationBarsPadding() 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.