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.
§Navigator-modal architecture
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§
- Bottom
Sheet View - A declarative modal M3 bottom sheet wrapping a single content child. See the module docs.
- Bottom
Sheet Widget - The retained widget for a
BottomSheetView. See the module docs.
Functions§
- bottom_
sheet - Wrap
contentin a modal bottom sheet. ChainBottomSheetView::on_dismissto handle a dismiss gesture (usually acontroller.pop()—show_bottom_sheetwires this for you). - show_
bottom_ sheet - Push
build‘s sheet as a transparent navigator page and registeron_resultfor 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 isTransitionSpec::NONE— a page-level transition on top would animate the same slide twice, in both directions.