Skip to main content

Module sheet

Module sheet 

Source
Expand description

The modal M3 BottomSheet: a full-area widget = scrim (32% scrim-color fill, fading with the panel’s own spring) + a bottom-anchored panel (top corners extra-large 28dp, surfaceContainerLow, a centered drag handle 32dp wide × 4dp tall in a 48dp touch target), pushed as a transparent navigator page via NavigatorController::push_transparent_for_result. show_bottom_sheet wraps the push.

Like crate::dialog, the scrim is part of this widget (the navigator provides none and routes input only to the top page), and the sheet is designed to be pushed as a transparent page over a static background. Dismissal is always controller.pop() — staged behind the exit spring on that path (see Who owns the exit) — wired to three gestures:

  • a scrim tap (a press+release outside the panel) pops with an empty PopResult;
  • a drag-release on the handle, decided by position and velocity (see below);
  • Escape, once the sheet holds focus (see Keyboard operability).

§Interactive drag + spring settle

Unlike the reference, the panel follows the finger: a press in the 48dp handle strip captures the pointer, and once the gesture passes frust::input::TOUCH_SLOP every Move scrubs BottomSheetWidget’s internal progress (0.0 fully closed/off-screen, 1.0 at rest) by the drag’s own fraction of the panel’s height — the panel translates with the pointer, clamped to 0.0..=1.0. A release below TOUCH_SLOP is a tap: unchanged from v1, nothing dismisses (only a real drag can).

On release, the panel’s fate is decided by position and velocity, mirroring overlay::modal’s own drag-settle: a release speed at or above [FLING_VELOCITY] commits in the direction it was flung (a fast downward flick dismisses even from near the top; a fast upward one stays open even from near the bottom); otherwise the position alone decides — past half the sheet’s own height ([DRAG_DISMISS_FRACTION]) dismisses, short of it springs back open. Either way, the panel springs to its resting point (open or closed) rather than snapping or easing on a fixed duration — BottomSheetWidget::spring_to launches a fresh leg of [SHEET_SPRING] (MaterialSpring::EXPRESSIVE_SPATIAL_DEFAULT) from wherever progress currently reads, at the release’s own measured speed (boosted to at least [SPRING_KICK] in the decided direction when the raw reading is too weak or the wrong sign to read as a deliberate commit).

§Spring entrance/exit

The sheet no longer rides the navigator’s own page-transition slide (show_bottom_sheet pushes with TransitionSpec::NONE — the widget stages its own motion, the crate::overlay::modal precedent): on its first paint, BottomSheetWidget kicks [SHEET_SPRING] from progress = 0.0 toward 1.0, so the first frame is invisible (the panel is off -screen) and the slide-up becomes visible only from the second frame onward — the same one-frame lag crate::overlay::modal’s own ramp documents. A dismiss trigger with no drag behind it (a scrim tap, Escape, an Android back press) kicks the same spring the other way, at [SPRING_KICK]. Theme.motion.reduce_motion collapses both to a jump: paint stops the spring and snaps progress straight to its target.

§Who owns the exit

The widget’s own spring owns it, on every mount, and on the navigator path the pop waits for that spring to settle — the same crate::overlay::modal staging shape, reached here without a single new public builder method.

The alternative was letting the navigator animate the pop, by pushing with a slide-up page transition instead of TransitionSpec::NONE. It is expressible, and was rejected on its merits rather than for lack of a seam: a TransitionSpec is one spec per page, applied to the push and the pop alike, so a preset pop-side slide-down necessarily brings its push-side slide-up back with it — double-animating the entrance the widget’s own spring already runs — but frust::PageTransition::Custom is handed is_pop and maps it onto the (entering, leaving) layer pair itself, so an asymmetric, pop-only spec is a plain use of that public seam, with no frust-widgets edit involved. What decides it is ownership: the entrance is already a spring inside this widget, and a navigator-driven exit starts from the page transition’s own 0..=1, knowing nothing of the progress a live handle drag left behind — the drag-release settle could no longer pick up where the finger stopped, and the sheet’s motion would live in two places at once. So both halves stay under one owner, the widget, and the navigator contributes no page offset in either direction.

Staging costs no public surface because the pop it defers is state-free: show_bottom_sheet wires an internal on_close (a StagedPop around controller.pop()) onto the view the same private way it already wires the back-press dismiss_signal cell — neither is a builder method, and neither is reachable from outside this module. Every dismiss trigger (scrim tap, handle-drag release, Escape, Android back) then takes one shape: kick the closing spring, mark the widget exiting, and let paint fire the pop exactly once the spring has settled at progress == 0 (a closed latch makes that firing terminal, so a trigger landing in the frames between the enqueued pop and the rebuild that drains it cannot pop the page underneath). Symmetrically, a leg aimed back open — a drag caught mid-exit, a release short of the dismiss threshold, a Cancel — clears exiting again, since a leg targeting 1.0 is by definition not a dismissal; leaving it set would strand the sheet at rest with a staged exit that can only fire at the closed end, refusing every later trigger. reduce_motion collapses the wait rather than skipping it: the exit snaps to 0 and fires on the next paint.

The deferred pop is identity-guarded, not blind. It takes the navigator’s top page, and the ramp it waits out is long enough for the app to push another one (a deep link, an async completion) — which a bare pop() would then take instead of this sheet. The hook is therefore a StagedPop: armed with the stack’s identity when the exit is staged, and refusing to fire if that identity moved. A refused pop is reported rather than swallowed — the sheet springs back open and stays dismissible, instead of latching closed over a page it never removed.

A mount with no on_close — a standalone frust::Stack mount, or any sheet built by bottom_sheet alone — keeps the immediate, state-touching v1 dismissal: BottomSheetView::on_dismiss (a Fn(&mut State)) fires in the same event as the spring kick, exactly as before, because there is no state-free hook to defer into. Exactly one of the two fires per dismissal.

§Convergence with overlay::modal

crate::overlay::modal is the merged host built on top of this module’s own BackPolicy/dismiss_signal mechanisms, and now carries a more general drag-to-close-with-snap-points machine of its own. This module does not rebuild itself on top of that host — that host generalizes to arbitrary snap points via a duration+curve ramp, where this sheet’s motion is a spring throughout — but it now shares the host’s staged-exit shape: a state-free close hook fired from paint on settle, guarded by an exiting/closed pair, with the state-bearing on_dismiss kept as the unstaged fallback for a mount that wires no hook. The theme-role resolution converges too, reading crate::overlay’s shared accessors (scrim/container/on_surface_variant/radius) rather than duplicating them locally.

§Panel anatomy

The panel spans the full width, anchored to the bottom edge, with its top two corners rounded to the extra-large (28dp) shape and its bottom corners square (they sit at the screen edge). It sizes to its content plus the 48dp handle strip at the top; the visual drag-handle indicator is 32×4dp, centered horizontally within that strip. The panel’s own extent never changes with progress — only its Y offset does, so a drag scrub or a spring-advanced frame repositions it (BottomSheetWidget::reposition) without a relayout.

§Standalone use

A BottomSheet is a plain widget: it works inside a frust::Stack too (the scrim is still its own). The navigator path is the primary, documented one. This module does not edit any nav file.

§Semantics

The sheet contributes one Role::Dialog container node with the accesskit modal flag set — a documented choice: accesskit has no dedicated “bottom sheet” role, and a modal sheet is, to assistive tech, a modal dialog-ish surface (the same role the crate::dialog uses). Its content becomes the node’s accesskit children.

§Keyboard operability

Escape-to-dismiss now works, once the sheet has focus. A Down anywhere in the sheet (scrim, handle, or panel background) claims focus via EventCtx::request_focus — the sheet already captures its whole area, so this is a pure opt-in with no new hit-testing. Once focused, a focus-routed Key(Escape) invokes the same dismiss path as a scrim tap or handle drag (on_dismiss) — unless the sheet’s content itself holds the deeper focus path, in which case content gets first crack at the key (mirroring how an action consumes an event before the dialog’s modal barrier does). There is still no hook to focus the sheet on appear (auto-focus-on-appear) — a caller must complete one pointer interaction with the sheet before Escape does anything; that gap is deferred to a future focus-manager work item.

The accesskit modal flag set above is nonetheless kept deliberately, not dropped. No platform accesskit_* adapter is wired yet (see docs/ARCHITECTURE.md’s Semantics pass), so there is no live assistive-tech audience the flag could currently mislead.

§State layer

This sheet has no StateLayer surface of its own (the scrim, panel, and drag handle are plain fills, not an M3 interactive surface) — there is nothing here for StateLayer::set_focused to wire into.

§dismissable(bool) + back-dismiss

BottomSheetView::dismissable (default true) is Flutter’s isDismissible+enableDrag collapsed into one v1 flag (spec parity): false disables the scrim tap, the handle drag (a drag past the dismiss threshold instead springs back open — the panel already tracked the finger down toward closed, so a bare no-op would leave it resting there as an invisible full-window barrier), and Escape, and show_bottom_sheet pushes the page with frust::BackPolicy::Veto so a back press is consumed with no effect. true pushes frust::BackPolicy::DismissAnimated: a back press bumps the shared dismiss-signal cell NavigatorController::request_back writes, observed from paint (observe_dismiss_signal) — which stages exactly the reverse ramp every other dismiss trigger stages (see Who owns the exit), so back is animated rather than instant. Because the pop that ramp eventually fires only enqueues a NavOp::Pop (it writes no tracked signal), the paint that fires it requests the next frame (PaintCtx::request_frame) so the enqueued pop is guaranteed a draining rebuild — otherwise a dirty-driven desktop shell idles and the mobile frame gate skips, leaving the back press dead until an unrelated later frame. The in-flight ramp keeps its own frames coming the same way.

§App-initiated dismiss: adopting ModalDismiss

BottomSheetView::dismiss_handle installs crate::overlay::modal’s own ModalDismiss handle — the same public type [crate::side_sheet] installs, never a second one minted for this module. The sheet’s content (a row tap, an “Apply” button) triggers it, and the widget observes the request through exactly the shape crate::overlay::modal’s own host does: a generation cell latched at build, re-seeded on rebuild only when the handle’s identity changed, and observed in both the event pass (right after the content routing that most likely raised it) and the paint pass (the backstop for a request raised outside an event). Neither observation stages a second exit path — both route straight into this module’s own BottomSheetWidget::request_dismiss/stage_exit/ fire_staged_close machinery, the same one every gesture dismissal already takes, so an app dismiss plays the identical spring settle and can carry a PopResult the same way ModalDismiss::dismiss_with does for [crate::side_sheet].

An app dismiss ignores BottomSheetView::dismissable the same way crate::overlay::modal’s own handle does (that flag exists to take the user’s exits away, and a ModalDismiss trigger is not one) — free here, since BottomSheetWidget::request_dismiss already applies no such gate itself; every gesture caller (the scrim tap, the handle-drag release, Escape) gates it at its own call site instead.

What an observation consumes mirrors that host too, rule for rule (see its own What a request consumes): the generation is latched by whichever pass saw the request, accepted or refused, so it acts exactly once; the payload is taken only where a dismissal was actually accepted and has a staged pop to carry it, never by a refusal (which would retarget the ramp already in flight) and never by the unstaged on_dismiss route (which carries no PopResult at all); and the one pass that latches nothing is the paint backstop on a Stack mount, which can deliver neither and leaves the request for the event pass rather than swallowing it.

A request left pending that way still dismisses only once, by the same rule the other host applies: the unstaged on_dismiss route is one-shot (BottomSheetWidget::dismissed, closed’s twin for a mount with no staged pop), and every dismissal route reads it through BottomSheetWidget::refuses_dismiss. Without it a scrim tap and a standing request are two dismissals rather than one: the tap fires on_dismiss (neither the Down barrier arm nor the scrim_captured arm observes the handle), and the next Move that does observe it fires again.

§Public API

bottom_sheet, show_bottom_sheet, and every BottomSheetView builder method except dismiss_handle are unchanged from v1 — the staged exit above is wired through private view/widget fields only, so this module’s public surface is the two functions plus BottomSheetView::on_dismiss/ BottomSheetView::dismissable/BottomSheetView::dismiss_handle.

Structs§

BottomSheetView
A declarative modal M3 bottom sheet wrapping a single content child. See the module docs.
BottomSheetWidget
The retained widget for a BottomSheetView. See the module docs.

Functions§

bottom_sheet
Wrap content in a modal bottom sheet. Chain BottomSheetView::on_dismiss to handle a dismiss gesture (usually a controller.pop() — show_bottom_sheet wires this for you).
show_bottom_sheet
Push build‘s sheet as a transparent navigator page and register on_result for the value the sheet pops with. The sheet owns both halves of its own spring motion (module docs’ Who owns the exit), so the page transition itself is TransitionSpec::NONE — a page-level transition on top would animate the same slide twice, in both directions.