Files
plezy/lib/widgets/overlay_sheet.dart
edde746 24a041977b fix(sheets): size sheets to their content instead of 75% of the window
Sheets rendered at the host's maximum height regardless of content, so a
one-item player queue or a two-track picker filled ~75% of a desktop window
with empty space.

BottomSheetPageScaffold now always lays out Column(mainAxisSize: .min) plus
Flexible(child:), and each sheet body shrink-wraps its own scrollable. The
scaffold's shrinkWrap flag is gone: its old true branch put the child on an
unbounded axis, where an over-tall list overflowed instead of clamping and
scrolling. Measured on a 1600x1000 window, the chapter sheet goes from 750px
to 118px for one chapter and the two-column track sheet from 750px to 154px
for one audio and one subtitle track, both still clamping at the cap.

Add SheetSplitColumns for the three side-by-side sheet layouts. A bare
VerticalDivider has no intrinsic height, so it inflated those rows to the cap
on its own; the rule now paints from a Positioned.fill that cannot size the
Stack. IntrinsicHeight is not an option because a Viewport has no intrinsics.

Because sheets are bottom-anchored, a content-driven height moves the sheet's
top edge and everything above the change point. Three surfaces opt out for
that reason and say so at the call site: SubtitleSearchSheet and its language
picker keep filling, since both refilter under an autofocused field;
FiltersBottomSheet holds the outgoing page's height through its loading
transient; and RatingBottomSheet no longer hides MAL/AniList rows
asynchronously, which used to slide live rating controls down two rows several
hundred ms after open. Wrap the shared StateMessageWidget at the filters sheet
boundary rather than editing a widget with 33 filling call sites.

The host gains an AnimatedSize keyed per sheet session so nested pushes ease
while a replacing show adopts its own height, a 720px absolute height ceiling
on desktop windows only, and a min(max(25%, 96px), 60%) drag-dismiss threshold
so short sheets neither close on a nudge nor become undismissable.

Add videoControls.noAudioDevicesAvailable so the audio output page shows a
placeholder instead of a bare header while devices load.
2026-08-08 00:37:17 +02:00

876 lines
32 KiB
Dart

import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../focus/dpad_navigator.dart';
import '../focus/input_mode_tracker.dart';
import '../focus/key_event_utils.dart';
import '../utils/platform_detector.dart';
/// Entry in the sheet page stack.
class _OverlaySheetEntry {
final WidgetBuilder builder;
final Completer<dynamic> completer;
final FocusNode? initialFocusNode;
_OverlaySheetEntry({required this.builder, required this.completer, this.initialFocusNode});
}
/// Provides [OverlaySheetController] to descendants via [of] / [maybeOf].
class _OverlaySheetScope extends InheritedWidget {
final OverlaySheetController controller;
const _OverlaySheetScope({required this.controller, required super.child});
@override
bool updateShouldNotify(_OverlaySheetScope oldWidget) => controller != oldWidget.controller;
}
/// Controller for the overlay-based bottom sheet system.
///
/// Use [of] or [maybeOf] to access from descendants.
class OverlaySheetController {
final _OverlaySheetHostState _state;
OverlaySheetController._(this._state);
static OverlaySheetController of(BuildContext context) {
final scope = context.dependOnInheritedWidgetOfExactType<_OverlaySheetScope>();
assert(scope != null, 'No OverlaySheetHost found in context');
return scope!.controller;
}
static OverlaySheetController? maybeOf(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<_OverlaySheetScope>()?.controller;
}
/// Number of sheets currently open across all hosts (and [showAdaptive]
/// modal fallbacks). Sheets render inside their host's subtree, so chrome
/// mounted above the navigator (the music mini-player) can never sit under
/// them — such chrome listens here and hides itself while this is nonzero.
static final ValueNotifier<int> openSheetCount = ValueNotifier<int>(0);
/// Whether a sheet is currently showing (including while animating closed).
bool get isOpen => _state._isOpen;
/// Show a sheet with [builder] content. Returns a Future that completes
/// when the sheet is closed (with an optional result).
///
/// [alignment] controls where the sheet appears. Defaults to
/// [Alignment.bottomCenter]. Use [Alignment.topCenter] to anchor at the top.
Future<T?> show<T>({
required WidgetBuilder builder,
BoxConstraints? constraints,
Color? backgroundColor,
bool barrierDismissible = true,
FocusNode? initialFocusNode,
Alignment alignment = Alignment.bottomCenter,
bool showDragHandle = false,
}) {
return _state._show<T>(
builder: builder,
constraints: constraints,
backgroundColor: backgroundColor,
barrierDismissible: barrierDismissible,
initialFocusNode: initialFocusNode,
alignment: alignment,
showDragHandle: showDragHandle,
);
}
/// Push a sub-page within the open sheet. Returns a Future that completes
/// when the pushed page is popped (with an optional result).
Future<T?> push<T>({required WidgetBuilder builder, FocusNode? initialFocusNode}) {
return _state._push<T>(builder: builder, initialFocusNode: initialFocusNode);
}
/// Pop the top sub-page, or close the sheet if on the last page.
void pop([dynamic result]) {
_state._pop(result);
}
/// Force close the sheet, completing all pending completers.
void close([dynamic result]) {
_state._close(result);
}
/// Re-focus the first focusable descendant within the sheet.
/// Useful after internal page changes via setState.
void refocus() {
_state._autoFocus(clearSelectSuppression: false);
}
/// Absolute height ceiling for resizable desktop windows. Without it a 4K
/// window yields a 1620px sheet, which reads as a wall of list rather than a
/// sheet.
static const _windowedMaxHeight = 720.0;
/// Sizing applied when a caller supplies no explicit constraints: three
/// quarters of the viewport height everywhere, the capped width on wide
/// viewports, and the absolute height ceiling on desktop windows only.
///
/// Both caps require `width > 600`. The height ceiling additionally requires
/// a desktop OS and not TV, because it exists for a window the user can
/// resize arbitrarily tall: a portrait tablet and a 10-foot UI keep the full
/// 75%, and so does a desktop window narrower than 601px, which is
/// phone-shaped and where 75% is the norm.
static BoxConstraints _defaultSheetConstraints(BuildContext context) {
final size = MediaQuery.sizeOf(context);
final isWideViewport = size.width > 600;
final isDesktopWindow = isWideViewport && PlatformDetector.isDesktopOS() && !PlatformDetector.isTV();
final maxHeight = size.height * 0.75;
return BoxConstraints(
maxWidth: isWideViewport ? 700 : double.infinity,
maxHeight: isDesktopWindow ? math.min(maxHeight, _windowedMaxHeight) : maxHeight,
);
}
/// Show a sheet using the overlay system if available, otherwise fall back
/// to [showModalBottomSheet]. Returns the result from the sheet.
static Future<T?> showAdaptive<T>(
BuildContext context, {
required WidgetBuilder builder,
BoxConstraints? constraints,
Color? backgroundColor,
bool barrierDismissible = true,
bool isScrollControlled = false,
FocusNode? initialFocusNode,
Alignment alignment = Alignment.bottomCenter,
bool showDragHandle = false,
}) async {
final controller = maybeOf(context);
if (controller != null) {
return controller.show<T>(
builder: builder,
constraints: constraints,
backgroundColor: backgroundColor,
barrierDismissible: barrierDismissible,
initialFocusNode: initialFocusNode,
alignment: alignment,
showDragHandle: showDragHandle,
);
}
// Apply the same default constraints the overlay system uses so sheets
// shown without an OverlaySheetHost still have sensible sizing on desktop.
final effectiveConstraints = constraints ?? _defaultSheetConstraints(context);
openSheetCount.value++;
try {
return await showModalBottomSheet<T>(
context: context,
// The host path insets its sheet by the bottom safe area; mirror that
// here so the last row clears the home indicator / gesture nav bar.
builder: (context) => SafeArea(top: false, child: builder(context)),
constraints: effectiveConstraints,
backgroundColor: backgroundColor ?? Theme.of(context).colorScheme.surface,
barrierColor: Colors.black54,
isDismissible: barrierDismissible,
isScrollControlled: isScrollControlled,
showDragHandle: showDragHandle,
);
} finally {
openSheetCount.value--;
}
}
/// Push a sub-page using the overlay system if available, otherwise fall
/// back to [showModalBottomSheet]. Returns the result from the page.
///
/// When a hosted sheet is already open, this pushes a nested page and
/// retains the root sheet's presentation. When a host is available but
/// idle, this opens [builder] as its root sheet using the supplied hosted
/// presentation options. Without a host, the modal fallback is used.
///
/// [isScrollControlled] applies only to the modal fallback; hosted sheets
/// use their explicit or default constraints.
static Future<T?> pushAdaptive<T>(
BuildContext context, {
required WidgetBuilder builder,
FocusNode? initialFocusNode,
BoxConstraints? constraints,
Color? backgroundColor,
bool barrierDismissible = true,
bool isScrollControlled = false,
bool showDragHandle = false,
}) async {
final controller = maybeOf(context);
if (controller != null) {
if (controller.isOpen) {
return controller.push<T>(builder: builder, initialFocusNode: initialFocusNode);
}
return controller.show<T>(
builder: builder,
constraints: constraints,
backgroundColor: backgroundColor,
barrierDismissible: barrierDismissible,
initialFocusNode: initialFocusNode,
showDragHandle: showDragHandle,
);
}
BackKeyCoordinator.clear();
return showAdaptive<T>(
context,
builder: builder,
constraints: constraints,
backgroundColor: backgroundColor,
barrierDismissible: barrierDismissible,
isScrollControlled: isScrollControlled,
showDragHandle: showDragHandle,
);
}
/// Close the sheet entirely. Uses overlay controller if available,
/// otherwise pops the route.
static void closeAdaptive(BuildContext context, [dynamic result]) {
final controller = maybeOf(context);
if (controller != null) {
controller.close(result);
} else {
Navigator.pop(context, result);
}
}
/// Pop one level (sub-page or close if last page). Uses overlay controller
/// if available, otherwise pops the route.
static void popAdaptive(BuildContext context, [dynamic result]) {
final controller = maybeOf(context);
if (controller != null) {
controller.pop(result);
} else {
Navigator.pop(context, result);
}
}
}
/// Host widget for the overlay-based bottom sheet system.
///
/// Sheets are rendered as overlays within this widget's Stack instead of as
/// modal routes, eliminating the route-based back-button race condition on
/// Android TV and providing centralized focus management for keyboard/dpad
/// navigation on all platforms.
///
/// ## Back handling
///
/// The host already owns the dpad/key back path (its sheet [FocusScope] closes
/// the sheet on BACK when focus is inside it). For the system/route back path
/// (Android gesture, iOS swipe, predictive back), opt in via [canPop]: the host
/// then renders its own [PopScope] that closes an open sheet instead of popping
/// the screen, so callers don't have to hand-roll it. When [canPop] is null
/// (the default) the host adds no [PopScope] and behaves exactly as before.
class OverlaySheetHost extends StatefulWidget {
final Widget child;
final ValueChanged<bool>? onOpenChanged;
/// Whether the enclosing route may pop when no sheet is open (the screen's own
/// business rule, mirroring [PopScope.canPop]).
///
/// When non-null the host installs a [PopScope]:
/// - a system back with a sheet open closes the sheet (never pops the screen);
/// - otherwise, if `canPop` is true the route pops natively (preserving the
/// iOS interactive swipe-back), and if false [onSystemBack] runs instead.
///
/// When null (default) the host installs no [PopScope] — today's behavior.
final bool? canPop;
/// Called for a system/route back when no sheet is open and [canPop] is false.
/// Not called when a sheet is open (the sheet is closed instead) or when
/// [canPop] allows a native pop. Implementations that also have a dpad key
/// handler should start with `if (BackKeyCoordinator.consumeIfHandled()) return;`
/// so the system path dedups against the key path.
final VoidCallback? onSystemBack;
const OverlaySheetHost({super.key, required this.child, this.onOpenChanged, this.canPop, this.onSystemBack});
@override
State<OverlaySheetHost> createState() => _OverlaySheetHostState();
}
class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerProviderStateMixin {
late final AnimationController _animationController;
late final CurvedAnimation _slideCurve;
late final Animation<double> _barrierAnimation;
late final OverlaySheetController _controller;
final List<_OverlaySheetEntry> _pageStack = [];
final _sheetFocusScopeNode = FocusScopeNode(debugLabel: 'OverlaySheetScope');
bool _isOpen = false;
bool _isClosing = false;
bool _barrierDismissible = true;
bool _showDragHandle = false;
BoxConstraints? _constraints;
Color? _explicitBackgroundColor;
Alignment _alignment = Alignment.bottomCenter;
Offset? _lastPointerPosition;
double? _sheetHorizontalAnchor;
/// Bumped on every [_show]. Keys the resize animation so a freshly opened
/// sheet adopts its own height immediately instead of animating down from
/// the previous sheet's; nested pushes within one sheet still animate.
///
/// Changing the key also remounts the sheet subtree, so a `show` that
/// replaces a live sheet of the same widget type starts with fresh [State]
/// rather than reconciling into the outgoing sheet's.
int _sheetSession = 0;
// Drag-to-dismiss state
double _dragOffset = 0;
bool _isDragging = false;
final _sheetKey = GlobalKey();
@override
void initState() {
super.initState();
_controller = OverlaySheetController._(this);
_animationController = AnimationController(duration: const Duration(milliseconds: 250), vsync: this);
_slideCurve = CurvedAnimation(
parent: _animationController,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
_barrierAnimation = Tween<double>(
begin: 0,
end: 0.5,
).animate(CurvedAnimation(parent: _animationController, curve: Curves.easeOutCubic));
}
@override
void dispose() {
for (final entry in _pageStack) {
if (!entry.completer.isCompleted) {
entry.completer.complete(null);
}
}
// A host torn down mid-sheet (or mid-close animation) never reaches the
// close completion below — release its slot in the global count here.
if (_isOpen) OverlaySheetController.openSheetCount.value--;
_sheetFocusScopeNode.dispose();
_slideCurve.dispose();
_animationController.dispose();
super.dispose();
}
Future<T?> _show<T>({
required WidgetBuilder builder,
BoxConstraints? constraints,
Color? backgroundColor,
bool barrierDismissible = true,
FocusNode? initialFocusNode,
Alignment alignment = Alignment.bottomCenter,
bool showDragHandle = false,
}) {
BackKeyCoordinator.clear();
// If already open, close first (instant)
final wasOpen = _isOpen;
if (_isOpen) {
for (final entry in _pageStack) {
if (!entry.completer.isCompleted) {
entry.completer.complete(null);
}
}
_pageStack.clear();
_isClosing = false;
}
final completer = Completer<T?>();
final entry = _OverlaySheetEntry(builder: builder, completer: completer, initialFocusNode: initialFocusNode);
final horizontalAnchor = _resolveSheetHorizontalAnchor(alignment);
setState(() {
_pageStack.add(entry);
_sheetSession++;
_isOpen = true;
_isClosing = false;
_barrierDismissible = barrierDismissible;
_showDragHandle = showDragHandle;
_constraints = constraints;
_explicitBackgroundColor = backgroundColor;
_alignment = alignment;
_sheetHorizontalAnchor = horizontalAnchor;
_dragOffset = 0;
_isDragging = false;
});
if (!wasOpen) {
widget.onOpenChanged?.call(true);
OverlaySheetController.openSheetCount.value++;
}
BackKeyUpSuppressor.clearSuppression();
_animationController.forward(from: 0);
_autoFocus();
return completer.future;
}
Future<T?> _push<T>({required WidgetBuilder builder, FocusNode? initialFocusNode}) {
if (!_isOpen || _isClosing) {
return Future.value(null);
}
final completer = Completer<T?>();
final entry = _OverlaySheetEntry(builder: builder, completer: completer, initialFocusNode: initialFocusNode);
setState(() {
_pageStack.add(entry);
});
_autoFocus();
return completer.future;
}
void _pop([dynamic result]) {
if (!_isOpen || _isClosing || _pageStack.isEmpty) return;
if (_pageStack.length == 1) {
_close(result);
return;
}
final removed = _pageStack.removeLast();
if (!removed.completer.isCompleted) {
removed.completer.complete(result);
}
setState(() {});
_autoFocus();
}
void _close([dynamic result]) {
if (!_isOpen || _isClosing) return;
_isClosing = true;
_animationController.reverse().then((_) {
if (!mounted) return;
setState(() {
for (final entry in _pageStack) {
if (!entry.completer.isCompleted) {
entry.completer.complete(result);
}
}
_pageStack.clear();
_isOpen = false;
_isClosing = false;
_dragOffset = 0;
_isDragging = false;
_sheetHorizontalAnchor = null;
});
widget.onOpenChanged?.call(false);
OverlaySheetController.openSheetCount.value--;
});
}
void _rememberPointerPosition(PointerEvent event) {
if (event.kind != PointerDeviceKind.mouse) return;
_lastPointerPosition = event.localPosition;
}
double? _resolveSheetHorizontalAnchor(Alignment alignment) {
if (!PlatformDetector.isDesktopOS() || PlatformDetector.isTV()) return null;
if (InputModeTracker.isKeyboardMode(context, listen: false)) return null;
if (alignment.x != 0 || alignment.y <= 0) return null;
return _lastPointerPosition?.dx;
}
void _autoFocus({bool clearSelectSuppression = true}) {
final focusDescendant = InputModeTracker.isKeyboardMode(context, listen: false);
// First post-frame: the FocusScope is now built and the node is attached.
// Always grab scope focus so key events (especially back) are trapped, even
// when a pointer opened the sheet. In keyboard mode, a second post-frame
// callback focuses the first descendant for dpad navigation.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_isOpen) return;
_sheetFocusScopeNode.requestFocus();
if (!focusDescendant) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_isOpen) return;
// If the current top entry has an initialFocusNode that is attached,
// focus that instead of the first descendant.
final topEntry = _pageStack.isNotEmpty ? _pageStack.last : null;
final initialNode = topEntry?.initialFocusNode;
if (initialNode != null && initialNode.context != null) {
initialNode.requestFocus();
} else {
_focusFirstDescendant();
}
// Clear stale select suppression from the press that opened this sheet,
// but only if no select key is currently held down. This handles:
// - Short press: key already released → clear flag (prevents first
// select inside the sheet from being eaten).
// - Long press: key still held → keep flag so KeyRepeat/KeyUp events
// from the long press are correctly suppressed.
if (clearSelectSuppression && !HardwareKeyboard.instance.logicalKeysPressed.any((k) => k.isSelectKey)) {
SelectKeyUpSuppressor.clearSuppression();
}
});
});
}
void _focusFirstDescendant() {
final descendants = _sheetFocusScopeNode.traversalDescendants.toList();
if (descendants.isNotEmpty) {
descendants.first.requestFocus();
} else {
_sheetFocusScopeNode.requestFocus();
}
}
void _handleBack() {
if (_pageStack.length > 1) {
_pop();
} else {
_close();
}
}
KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) {
// Suppress stale select key-ups
if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) {
return KeyEventResult.handled;
}
// Suppress stale back key-ups
if (BackKeyUpSuppressor.consumeIfSuppressed(event)) {
return KeyEventResult.handled;
}
// Back key: pop sub-page or close sheet
if (event.logicalKey.isBackKey) {
if (PlatformDetector.isTV() && event is KeyDownEvent) {
BackKeyCoordinator.markHandled();
}
return handleBackKeyAction(event, _handleBack);
}
// Let all other keys pass through. Directional keys need to reach
// Flutter's DirectionalFocusAction for dpad/arrow navigation, and
// select/enter keys need to reach ActivateAction for item taps.
// The FocusScope traps traversal within the sheet; the screen-level
// Focus catches any leaked nav keys.
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
Widget content = Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: _rememberPointerPosition,
onPointerHover: _rememberPointerPosition,
child: Stack(
children: [
widget.child,
// Barrier + sheet only when open
if (_isOpen) ...[
Positioned.fill(
// The barrier swallows every pointer event behind it, so the
// screen underneath must leave the semantics tree too. Without
// this, assistive tech (and UI automation) still reads rows that
// cannot be activated — including through the ~250ms close, where
// a tap on a "visible" row lands on the barrier instead. Flutter's
// own ModalBarrier blocks semantics for the same reason.
child: BlockSemantics(
child: AnimatedBuilder(
animation: _barrierAnimation,
builder: (context, child) {
return GestureDetector(
onTap: _barrierDismissible ? () => _close() : null,
child: ColoredBox(color: Colors.black.withValues(alpha: _barrierAnimation.value)),
);
},
),
),
),
_buildSheet(context),
],
],
),
);
// When a screen opts in via [canPop], the host owns the system/route back
// path so callers don't hand-roll it: a back with a sheet open closes the
// sheet (sub-page aware, matching the dpad path) instead of popping the
// screen; otherwise the route pops natively (canPop true) or [onSystemBack]
// runs (canPop false). `!_isClosing` lets a press during the ~250ms close
// animation fall through instead of being swallowed.
final canPop = widget.canPop;
if (canPop != null) {
content = PopScope(
canPop: canPop && !_isOpen,
onPopInvokedWithResult: (didPop, result) {
if (didPop) return;
if (_isOpen && !_isClosing) {
// Only TV routes one Back through both the focused key path and
// the platform pop, so only TV needs to dedup them. Touch
// platforms never deliver Back to [_handleKeyEvent], so consulting
// the global marker here could only ever consume some unrelated
// widget's mark and swallow the one signal that closes the sheet.
// The marker is global and one-shot, so anything left set by
// another handler would strand the sheet open with no way out.
if (PlatformDetector.isTV() && BackKeyCoordinator.consumeIfHandled()) return;
_handleBack();
return;
}
widget.onSystemBack?.call();
},
child: content,
);
}
return _OverlaySheetScope(controller: _controller, child: content);
}
double _getSheetHeight() {
final renderBox = _sheetKey.currentContext?.findRenderObject() as RenderBox?;
return renderBox?.size.height ?? 300;
}
/// Minimum drag distance that dismisses a sheet, regardless of how short the
/// sheet is. Content-sized sheets can be ~150px tall, where a bare 25% of the
/// height is barely more than touch slop, so a slow nudge while scrolling or
/// reaching would close them. Fast flicks are already handled by the velocity
/// check in [_checkDismiss].
static const _minDismissDrag = 96.0;
/// Ceiling on that floor, as a fraction of the sheet. A one-row menu can be
/// shorter than [_minDismissDrag], and an unclamped floor would mean the only
/// way to dismiss it by distance is to drag it clean off the screen.
static const _maxDismissDragFraction = 0.6;
void _checkDismiss(double velocity) {
final sheetHeight = _getSheetHeight();
final threshold = math.min(math.max(sheetHeight * 0.25, _minDismissDrag), sheetHeight * _maxDismissDragFraction);
if (_dragOffset > threshold || velocity > 500) {
_close();
} else {
setState(() {
_dragOffset = 0;
});
}
}
Widget _buildSheet(BuildContext context) {
final size = MediaQuery.sizeOf(context);
final isDesktop = size.width > 600;
final isTop = _alignment.y < 0;
final isTV = PlatformDetector.isTV();
final showHandle = _showDragHandle && !isTV && !isTop;
final effectiveConstraints = _constraints ?? OverlaySheetController._defaultSheetConstraints(context);
// Slide direction depends on alignment: bottom sheets slide up, top sheets slide down.
// Use a pixel transform instead of FractionalTranslation so mouse-tracker
// hit testing never depends on the sheet child's just-invalidated layout.
final slideDirection = isTop ? -1.0 : 1.0;
final slideDistance = size.height;
final borderRadius = isTop
? const BorderRadius.vertical(bottom: Radius.circular(16))
: const BorderRadius.vertical(top: Radius.circular(16));
final colorScheme = Theme.of(context).colorScheme;
Widget content = _pageStack.isNotEmpty ? Builder(builder: _pageStack.last.builder) : const SizedBox.shrink();
// Keep sheet scrollables from attaching to the route's primary controller.
content = PrimaryScrollController.none(child: content);
// Wrap content in NotificationListener for scroll-aware drag-to-dismiss
if (showHandle) {
content = NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (notification is OverscrollNotification) {
// Android (ClampingScrollPhysics): overscroll fires reliably
if (notification.overscroll < 0) {
setState(() {
_dragOffset += -notification.overscroll;
});
return true;
}
} else if (notification is ScrollUpdateNotification) {
// iOS (BouncingScrollPhysics): pixels go negative when bouncing past top
if (notification.metrics.pixels < 0) {
setState(() {
_dragOffset = -notification.metrics.pixels;
});
return true;
}
// If user scrolled back down from overscroll, reset drag offset
if (_dragOffset > 0 && notification.metrics.pixels >= 0) {
setState(() {
_dragOffset = 0;
});
}
} else if (notification is ScrollEndNotification) {
if (_dragOffset > 0) {
_checkDismiss(0);
return true;
}
}
return false;
},
child: content,
);
}
// Build the sheet content column (handle + content)
Widget sheetContent;
if (showHandle) {
sheetContent = Column(
mainAxisSize: .min,
children: [
// M3 drag handle: 32x4, rounded, with 12dp top / 4dp bottom margin
Container(
width: 32,
height: 4,
margin: const EdgeInsets.only(top: 12, bottom: 4),
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: const BorderRadius.all(Radius.circular(2)),
),
),
Flexible(child: content),
],
);
} else {
sheetContent = content;
}
Widget sheet = FocusScope(
node: _sheetFocusScopeNode,
onKeyEvent: _handleKeyEvent,
child: Focus(
canRequestFocus: false,
skipTraversal: true,
onKeyEvent: _handleKeyEvent,
child: CustomSingleChildLayout(
delegate: _OverlaySheetLayoutDelegate(
alignment: _alignment,
horizontalAnchor: _sheetHorizontalAnchor,
edgePadding: isDesktop ? _OverlaySheetLayoutDelegate.desktopEdgePadding : 0,
),
child: AnimatedBuilder(
animation: _slideCurve,
builder: (context, child) {
final dy = slideDirection * slideDistance * (1 - _slideCurve.value);
return Transform.translate(offset: Offset(0, dy), child: child);
},
child: Transform.translate(
offset: Offset(0, _dragOffset.clamp(0, double.infinity)),
child: SafeArea(
left: true,
right: true,
top: false,
bottom: false,
child: Material(
key: _sheetKey,
color: _explicitBackgroundColor ?? colorScheme.surface,
borderRadius: borderRadius,
clipBehavior: Clip.antiAlias,
child: SafeArea(
top: isTop,
bottom: !isTop,
left: false,
right: false,
// Content is sized by the sheet body, so pushing a nested
// page or resolving async content changes the sheet's
// height. Ease the box between those heights instead of
// snapping. The child is laid out at its final size and
// pinned to the anchored edge throughout, so it is revealed
// rather than stretched.
child: AnimatedSize(
key: ValueKey(_sheetSession),
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
alignment: isTop ? Alignment.topCenter : Alignment.bottomCenter,
child: ConstrainedBox(constraints: effectiveConstraints, child: sheetContent),
),
),
),
),
),
),
),
),
);
// Swipe-down-to-dismiss on non-scrollable areas (skip on TV and top-aligned)
if (showHandle) {
sheet = RawGestureDetector(
gestures: <Type, GestureRecognizerFactory>{
VerticalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<VerticalDragGestureRecognizer>(
() => VerticalDragGestureRecognizer()..onlyAcceptDragOnThreshold = true,
(instance) {
instance
..onStart = (_) {
_isDragging = true;
_dragOffset = 0;
}
..onUpdate = (details) {
if (!_isDragging) return;
setState(() {
_dragOffset += details.delta.dy;
});
}
..onEnd = (details) {
if (!_isDragging) return;
_isDragging = false;
_checkDismiss(details.primaryVelocity ?? 0);
};
},
),
},
child: sheet,
);
}
return sheet;
}
}
class _OverlaySheetLayoutDelegate extends SingleChildLayoutDelegate {
static const desktopEdgePadding = 16.0;
final Alignment alignment;
final double? horizontalAnchor;
final double edgePadding;
const _OverlaySheetLayoutDelegate({
required this.alignment,
required this.horizontalAnchor,
required this.edgePadding,
});
@override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
final size = constraints.biggest;
final maxWidth = size.width > edgePadding * 2 ? size.width - edgePadding * 2 : size.width;
return BoxConstraints.loose(Size(maxWidth, size.height));
}
@override
Offset getPositionForChild(Size size, Size childSize) {
final hasHorizontalPadding = edgePadding > 0 && size.width > childSize.width + edgePadding * 2;
final minLeft = hasHorizontalPadding ? edgePadding : 0.0;
final maxLeft = hasHorizontalPadding ? size.width - childSize.width - edgePadding : minLeft;
final left = horizontalAnchor == null
? minLeft + (maxLeft - minLeft) * (alignment.x + 1) / 2
: (horizontalAnchor! - childSize.width / 2).clamp(minLeft, maxLeft).toDouble();
final maxTop = size.height > childSize.height ? size.height - childSize.height : 0.0;
final top = maxTop * (alignment.y + 1) / 2;
return Offset(left, top);
}
@override
bool shouldRelayout(_OverlaySheetLayoutDelegate oldDelegate) {
return alignment != oldDelegate.alignment ||
horizontalAnchor != oldDelegate.horizontalAnchor ||
edgePadding != oldDelegate.edgePadding;
}
}