-
Notifications
You must be signed in to change notification settings - Fork 0
LayrzBottomSheet
A Material-free modal or persistent bottom sheet for presenting content above the page, draggable to resize and dismiss, respecting the system bars and the on-screen keyboard.
Metadata
Mirrors: None (new component for layrz_ui)
Phase: M5 (Layout, navigation, feedback)
Domain: Overlays and Pickers
Primitive: RawDialogRoute + DraggableScrollableSheet
Status: Shipped (0.0.14).
LayrzBottomSheet is a static-method surface — there is no widget to construct, only LayrzBottomSheet.show<T>(), which pushes a route and returns a Future<T?> that completes with whatever value the sheet's content passes to Navigator.pop, or null if the sheet is dismissed without one. It is the presentation layer picker inputs (LayrzSelectInput, LayrzDurationInput, LayrzComboBoxInput) fall back to on compact viewports, and it is what LayrzScaffoldShell uses for its narrow-band detail sheet.
It can be either:
-
Modal (
isPersistent: false, default): a semi-transparent barrier appears, the page beneath is not interactive, and pressing Escape dismisses the sheet. -
Persistent (
isPersistent: true): no barrier, the page beneath stays interactive. Useful for supplementary UI that coexists with the main content rather than blocking it.
Uses DraggableScrollableSheet internally rather than hand-rolling the drag/scroll handoff — dragging the handle (or, with showDragHandle: true, the entire header region above the content) resizes the sheet across minSize, snapSizes, and maxSize, and dragging past the lowest snap point dismisses it, exactly as dragging the sheet's own scrollable content does.
class LayrzBottomSheet {
/// Shows a bottom sheet and returns the result.
///
/// Returns a `Future<T?>` that completes with the value passed to
/// [Navigator.pop] in the sheet, or `null` if the sheet is dismissed
/// without a value.
static Future<T?> show<T>(
BuildContext context, {
/// The build context from which to show the sheet. Must contain a Navigator.
required BuildContext context,
/// Builder function that constructs the sheet's content. Receives the
/// sheet's own context as an argument.
required WidgetBuilder builder,
/// Whether the sheet is persistent (no barrier, page stays interactive)
/// or modal (barrier present, page not interactive). Defaults to `false`
/// (modal).
bool isPersistent = false,
/// Optional semantic label for screen readers announcing the sheet.
/// For modal sheets, this should describe the purpose and exit mechanism
/// (e.g., "Choose an option. Press Escape to close."). Required when
/// [isPersistent] is `false` to announce the modal dialog role and
/// communicate the exit path — if not provided, no dialog semantics are
/// added, preventing a focus trap without announcement. Ignored for
/// persistent sheets. Must be localized by the caller. Defaults to `null`.
String? semanticLabel,
/// Optional list of snap point fractions (0.0 to 1.0) in ascending order.
/// Constraints are enforced by assertion at the call site: the list must
/// not be empty, every value must lie within [minSize]..[maxSize], and
/// values must be strictly ascending. Defaults to `[0.5, 0.95]`.
List<double>? snapSizes,
/// The fraction of the screen height the sheet initially occupies.
/// Must be between [minSize] and [maxSize]. Defaults to `0.5`.
double initialSize = 0.5,
/// The minimum fraction of screen height the sheet can be dragged down
/// to. Useful for showing a minimum indicator or handle. Defaults to
/// `0.25`.
double minSize = 0.25,
/// The maximum fraction of screen height the sheet can occupy. Defaults
/// to `0.95`, leaving minimal space for the status bar / app bar.
double maxSize = 0.95,
/// Whether to render a visual drag handle above the content. When `true`
/// (default), the entire header region — not just the visible pill — is
/// the drag target, more forgiving than a handle-only hit area on touch.
bool showDragHandle = true,
/// Whether to use the root navigator instead of the nearest one. Set to
/// `true` when showing from a context whose nearest Navigator is nested
/// (e.g. inside a `go_router` `ShellRoute`) and the sheet must cover the
/// full screen and sit outside that nested navigator's own subtree — see
/// **`useRootNavigator`** below. Defaults to `false`.
bool useRootNavigator = false,
/// Whether the sheet wraps [builder]'s content in its own
/// `SingleChildScrollView`. Defaults to `true`. Set to `false` when
/// [builder] returns its own scrollable (a `ListView`/`GridView`) — see
/// **`scrollable: false`** below.
bool scrollable = true,
});
}final selected = await LayrzBottomSheet.show<String>(
context,
semanticLabel: 'Choose an option. Press Escape to close.',
builder: (context) => Column(
mainAxisSize: MainAxisSize.min,
children: options.map((o) => LayrzTappable(
onTap: () => Navigator.of(context).pop(o),
child: Text(o),
)).toList(),
),
);LayrzBottomSheet.show<void>(
context,
isPersistent: true,
showDragHandle: true,
builder: (context) => _buildSupplementaryPanel(),
);// Inside a page hosted by a go_router ShellRoute, where the nearest
// Navigator is the shell's own nested one:
LayrzBottomSheet.show<void>(
context,
useRootNavigator: true,
builder: (context) => _buildDetail(),
);LayrzBottomSheet.show<void>(
context,
scrollable: false,
builder: (context) => ListView.builder(
// No explicit controller — it inherits the sheet's drag/scroll handoff
// via PrimaryScrollController automatically.
itemCount: items.length,
itemBuilder: (context, i) => Text(items[i]),
),
);The sheet's surface — its fill, rounded top corners (r4), and elevation shadow — paints edge-to-edge under the status bar and the Android navigation bar, matching the modern full-bleed Android look. Only the content is inset clear of those system bars, via a SafeArea(left: false, right: false, ...) wrapped around the content column, not the surface. left/right are left unhandled because the sheet is always full-width with no side notches to avoid.
This composes with the keyboard for free: SafeArea's default bottom: true reads MediaQuery.paddingOf, which the engine already reports as zero for any edge the keyboard currently covers — unlike viewPadding, which holds the device's permanent inset regardless of the keyboard. So once the keyboard is up and covers the navigation bar, the bottom inset this SafeArea applies collapses to zero on its own; nothing here needs to know about the keyboard to avoid double-insetting on top of the keyboard handling described next.
When the on-screen keyboard opens, the sheet shrinks to the space remaining above it and pins itself there:
- The route's available height is reduced by
viewInsets.bottom, and the inset is zeroed for the sheet's own subtree viaMediaQuery.removeViewInsets— the same patternLayrzLayoutuses for keyboard handling (see decision D65) — so a nested widget readingviewInsetsitself does not double-count the same inset the sheet already consumed. - The sheet is then pinned to fill that reduced space (
minChildSize == maxChildSize == 1.0for as long as the keyboard is up), rather than staying at whatever fraction it happened to be at when the keyboard opened. -
Expansion controls go inert while the keyboard is up — there is nothing left to expand into, and
snapSizescollapses to a single[1.0]point. - Drag-to-dismiss keeps working. Suppressing expansion has nothing to do with taking away the ability to swipe the sheet away while typing: the drag handle switches to a separate dismiss-only gesture path (a raw pixel-distance threshold, since the sheet's own size is pinned and has no range left to drag within), so a deliberate downward swipe still closes the sheet.
-
Closing the keyboard restores the sheet's exact previous size — not a snap to
maxSize. The sheet's fractional size is captured the moment the keyboard opens (before pinning), and reapplied once the keyboard closes.
Applies to persistent sheets too. The keyboard handling has nothing to do with the barrier — it applies identically whether isPersistent is true or false.
A modal sheet (isPersistent: false) is dismissed by dragging the handle down past the lowest snap point, a barrier tap, Escape, or the system back gesture — all of which call Navigator.of(context).pop().
Every one of those pop sites is guarded by ModalRoute.of(context)?.isCurrent. The barrier stays mounted and hit-testable for the entire exit transition, including the slide-out animation — so a second fast tap during that window would otherwise call pop() on a route that is already popping. Under go_router, that throws (currentConfiguration.isNotEmpty trying to remove the last page off the stack, or !_debugLocked re-entering the Navigator mid-pop); in a plain Navigator, the unguarded version silently dismissed the caller's own page underneath the sheet in a release build, with no error to signal it. The same guard covers the barrier tap, the Escape key handler, and both drag-to-dismiss paths (ordinary and keyboard-pinned), for consistency across every pop site in the widget.
Does not apply to persistent sheets — there is no barrier, so there is nothing for a second tap to race against.
Modal (false, default) |
Persistent (true) |
|
|---|---|---|
| Barrier | Semi-transparent, blocks page interaction | None — page stays interactive |
| Escape dismisses | Yes | No (there is no dismiss route to trigger) |
| Barrier tap dismisses | Yes | N/A (no barrier) |
| Double-pop guard | Applies | N/A (no barrier to double-tap) |
Route semantics (scopesRoute/namesRoute) |
Applied when semanticLabel is set |
Never applied — persistent sheets are supplementary UI, not a modal route |
| Keyboard shrink/pin/restore | Applies | Applies |
| Drag-to-resize / drag-to-dismiss | Applies | Applies |
Defaults to false (nearest Navigator). Set to true when the nearest Navigator is nested inside an app shell that owns its own Navigator — the canonical case is a go_router ShellRoute, which builds a Navigator for the routes it hosts, nested inside the page body.
Pushing to that nested Navigator instead of the root one has two consequences, both fixed by useRootNavigator: true:
- The sheet's
Overlayentry becomes a genuine descendant of whateverSelectableRegionwraps the page body (e.g.LayrzLayout's own selection scope), so a gesture on the sheet's own text can resolve against page content behind it instead of the sheet. - The nested Overlay is bounded by the page body, not the full physical screen, so the sheet's barrier cannot cover the app's own chrome (e.g. a top bar) — a tap there reaches the chrome's controls instead of being blocked by the modal scrim.
LayrzScaffoldShell passes useRootNavigator: true for exactly this reason when presenting its narrow-band detail sheet — see LayrzScaffoldShell for the fuller account of the same fix from the consumer side.
Defaults to true, which wraps builder's content in the sheet's own SingleChildScrollView and attaches the drag/scroll-handoff ScrollController to that wrapper — a non-scrolling builder (e.g. a Column) needs no changes to work under this default.
Set to false when builder returns its own scrollable (a ListView or GridView): a same-axis scrollable nested inside the sheet's own SingleChildScrollView is given unbounded height by its parent and asserts (Vertical viewport was given unbounded height). With scrollable: false, the sheet instead hands its ScrollController down via PrimaryScrollController (covering every platform, not just mobile) — a vertical scrollable in builder that sets no controller of its own picks it up automatically, giving it the sheet's drag/scroll handoff without an explicit wire-up. A caller that wants a different controller (e.g. to also read its own scroll offset) passes one explicitly, which opts that scrollable out of inheriting the sheet's. A horizontal scrollable never inherits it regardless of this flag. If builder nests a second vertical scrollable inside the first, only the outer one inherits — the inner one scrolls independently, with no drag handoff of its own.
- Focus moves into the sheet on open (
FocusScope.of(context).autofocus) and returns to the invoker on close. - Escape dismisses modal sheets (guarded the same way as every other pop site — see above).
- Reduce-motion is respected: the slide transition is shortened or skipped entirely when
MediaQuery.of(context).disableAnimationsistrue. - The drag is never the only route to content — the initial size shows the primary content, and content scrolls independently of the drag.
- When
isPersistentisfalseandsemanticLabelis supplied, the sheet is wrapped inSemantics(scopesRoute: true, namesRoute: true, explicitChildNodes: true), announcing the modal dialog role to screen readers. With nosemanticLabel, no dialog semantics are added at all, rather than announcing an unlabeled route — this avoids a focus trap with nothing to announce.
LayrzBottomSheet is used by:
-
LayrzScaffoldShell— the narrow-band (sm/xs) detail sheet, presented withuseRootNavigator: true(see above) -
LayrzSelectInput,LayrzDurationInput,LayrzComboBoxInput— the compact-viewport (context.isCompact) presentation of their selection surface, in place ofLayrzAnchoredPanelon desktop
- LayrzAnchoredPanel — the desktop counterpart used by the same picker inputs
- LayrzScaffoldShell — the narrow-band detail sheet consumer; documents the same root-navigator rationale from the caller's side
- LayrzLayout — shares the keyboard-resize pattern (D65)
- Repo: D65 — LayrzLayout Resizes Its Body for the Keyboard, Scaffold-Style
Last updated: 2026-08-26 Status: Shipped (0.0.14)
Made with ❤️ by Golden M, Inc.
- LayrzTextInput
- LayrzSelectableAction
- LayrzSelectionToolbar
- LayrzTextSelectionControls
- LayrzSelectionMagnifier
- LayrzSelectionHandlePainter
- LayrzTextAreaInput
- LayrzComboBoxInput
- LayrzNumberInput
- LayrzPasswordInput
- LayrzCheckboxInput
- LayrzRadioInput
- LayrzSelectInput
- LayrzMultiSelectInput
- LayrzSearchInput
- LayrzDualListInput
- LayrzDurationInput