import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; const Duration _bottomSheetEnterDuration = Duration(milliseconds: 300); const Duration _bottomSheetExitDuration = Duration(milliseconds: 200); const Curve _modalBottomSheetCurve = Easing.standardDecelerate; const double _defaultScrollControlDisabledMaxHeightRatio = 9.0 / 16.0; class SideSheet extends StatefulWidget { const SideSheet({ super.key, this.animationController, this.enableDrag = true, this.showDragHandle, this.dragHandleColor, this.dragHandleSize, this.onDragStart, this.onDragEnd, this.backgroundColor, this.shadowColor, this.elevation, this.shape, this.clipBehavior, this.constraints, required this.onClosing, required this.builder, }) : assert(elevation == null || elevation >= 0.0); final AnimationController? animationController; final VoidCallback onClosing; final WidgetBuilder builder; final bool enableDrag; final bool? showDragHandle; final Color? dragHandleColor; final Size? dragHandleSize; final BottomSheetDragStartHandler? onDragStart; final BottomSheetDragEndHandler? onDragEnd; final Color? backgroundColor; final Color? shadowColor; final double? elevation; final ShapeBorder? shape; final Clip? clipBehavior; final BoxConstraints? constraints; @override State createState() => _SideSheetState(); static AnimationController createAnimationController(TickerProvider vsync) { return AnimationController( duration: _bottomSheetEnterDuration, reverseDuration: _bottomSheetExitDuration, debugLabel: 'SideSheet', vsync: vsync, ); } } class _SideSheetState extends State { final GlobalKey _childKey = GlobalKey(debugLabel: 'SideSheet child'); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final Color color = widget.backgroundColor ?? colorScheme.surface; final Color surfaceTintColor = colorScheme.surfaceTint; final Color shadowColor = widget.shadowColor ?? Colors.transparent; final double elevation = widget.elevation ?? 0; final ShapeBorder shape = widget.shape ?? RoundedRectangleBorder( borderRadius: BorderRadius.circular(0), ); final BoxConstraints constraints = widget.constraints ?? const BoxConstraints(maxWidth: 320, minWidth: 320); final Clip clipBehavior = widget.clipBehavior ?? Clip.none; Widget sideSheet = Material( key: _childKey, color: color, elevation: elevation, surfaceTintColor: surfaceTintColor, shadowColor: shadowColor, shape: shape, clipBehavior: clipBehavior, child: widget.builder(context), ); return ConstrainedBox( constraints: constraints, child: sideSheet, ); } } typedef _SizeChangeCallback = void Function(Size); class _SideSheetLayoutWithSizeListener extends SingleChildRenderObjectWidget { const _SideSheetLayoutWithSizeListener({ required this.onChildSizeChanged, required this.animationValue, required this.isScrollControlled, required this.scrollControlDisabledMaxHeightRatio, super.child, }); final _SizeChangeCallback onChildSizeChanged; final double animationValue; final bool isScrollControlled; final double scrollControlDisabledMaxHeightRatio; @override _RenderSideSheetLayoutWithSizeListener createRenderObject( BuildContext context) { return _RenderSideSheetLayoutWithSizeListener( onChildSizeChanged: onChildSizeChanged, animationValue: animationValue, isScrollControlled: isScrollControlled, scrollControlDisabledMaxHeightRatio: scrollControlDisabledMaxHeightRatio, ); } @override void updateRenderObject(BuildContext context, _RenderSideSheetLayoutWithSizeListener renderObject) { renderObject.onChildSizeChanged = onChildSizeChanged; renderObject.animationValue = animationValue; renderObject.isScrollControlled = isScrollControlled; renderObject.scrollControlDisabledMaxHeightRatio = scrollControlDisabledMaxHeightRatio; } } class _RenderSideSheetLayoutWithSizeListener extends RenderShiftedBox { _RenderSideSheetLayoutWithSizeListener({ RenderBox? child, required _SizeChangeCallback onChildSizeChanged, required double animationValue, required bool isScrollControlled, required double scrollControlDisabledMaxHeightRatio, }) : _onChildSizeChanged = onChildSizeChanged, _animationValue = animationValue, _isScrollControlled = isScrollControlled, _scrollControlDisabledMaxHeightRatio = scrollControlDisabledMaxHeightRatio, super(child); Size _lastSize = Size.zero; _SizeChangeCallback get onChildSizeChanged => _onChildSizeChanged; _SizeChangeCallback _onChildSizeChanged; set onChildSizeChanged(_SizeChangeCallback newCallback) { if (_onChildSizeChanged == newCallback) { return; } _onChildSizeChanged = newCallback; markNeedsLayout(); } double get animationValue => _animationValue; double _animationValue; set animationValue(double newValue) { if (_animationValue == newValue) { return; } _animationValue = newValue; markNeedsLayout(); } bool get isScrollControlled => _isScrollControlled; bool _isScrollControlled; set isScrollControlled(bool newValue) { if (_isScrollControlled == newValue) { return; } _isScrollControlled = newValue; markNeedsLayout(); } double get scrollControlDisabledMaxHeightRatio => _scrollControlDisabledMaxHeightRatio; double _scrollControlDisabledMaxHeightRatio; set scrollControlDisabledMaxHeightRatio(double newValue) { if (_scrollControlDisabledMaxHeightRatio == newValue) { return; } _scrollControlDisabledMaxHeightRatio = newValue; markNeedsLayout(); } Size _getSize(BoxConstraints constraints) { return constraints.constrain(constraints.biggest); } @override double computeMinIntrinsicWidth(double height) { final double width = _getSize(BoxConstraints.tightForFinite(height: height)).width; if (width.isFinite) { return width; } return 0.0; } @override double computeMaxIntrinsicWidth(double height) { final double width = _getSize(BoxConstraints.tightForFinite(height: height)).width; if (width.isFinite) { return width; } return 0.0; } @override double computeMinIntrinsicHeight(double width) { final double height = _getSize(BoxConstraints.tightForFinite(width: width)).height; if (height.isFinite) { return height; } return 0.0; } @override double computeMaxIntrinsicHeight(double width) { final double height = _getSize(BoxConstraints.tightForFinite(width: width)).height; if (height.isFinite) { return height; } return 0.0; } @override Size computeDryLayout(BoxConstraints constraints) { return _getSize(constraints); } BoxConstraints _getConstraintsForChild(BoxConstraints constraints) { return BoxConstraints( maxHeight: constraints.maxHeight, ); } Offset _getPositionForChild(Size size, Size childSize) { return Offset(size.width - childSize.width * animationValue, 0.0); } @override void performLayout() { size = _getSize(constraints); if (child != null) { final BoxConstraints childConstraints = _getConstraintsForChild(constraints); assert(childConstraints.debugAssertIsValid(isAppliedConstraint: true)); child!.layout( childConstraints, parentUsesSize: !childConstraints.isTight, ); final BoxParentData childParentData = child!.parentData! as BoxParentData; childParentData.offset = _getPositionForChild( size, childConstraints.isTight ? childConstraints.smallest : child!.size, ); final Size childSize = childConstraints.isTight ? childConstraints.smallest : child!.size; if (_lastSize != childSize) { _lastSize = childSize; _onChildSizeChanged.call(_lastSize); } } } } class _ModalSideSheet extends StatefulWidget { const _ModalSideSheet({ super.key, required this.route, this.backgroundColor, this.elevation, this.shape, this.clipBehavior, this.constraints, this.isScrollControlled = false, this.scrollControlDisabledMaxHeightRatio = _defaultScrollControlDisabledMaxHeightRatio, this.enableDrag = true, this.showDragHandle = false, }); final ModalSideSheetRoute route; final bool isScrollControlled; final double scrollControlDisabledMaxHeightRatio; final Color? backgroundColor; final double? elevation; final ShapeBorder? shape; final Clip? clipBehavior; final BoxConstraints? constraints; final bool enableDrag; final bool showDragHandle; @override _ModalSideSheetState createState() => _ModalSideSheetState(); } class _ModalSideSheetState extends State<_ModalSideSheet> { ParametricCurve animationCurve = _modalBottomSheetCurve; String _getRouteLabel(MaterialLocalizations localizations) { switch (Theme.of(context).platform) { case TargetPlatform.iOS: case TargetPlatform.macOS: return ''; case TargetPlatform.android: case TargetPlatform.fuchsia: case TargetPlatform.linux: case TargetPlatform.windows: return localizations.dialogLabel; } } EdgeInsets _getNewClipDetails(Size topLayerSize) { return EdgeInsets.fromLTRB(0, 0, 0, topLayerSize.height); } @override Widget build(BuildContext context) { assert(debugCheckHasMediaQuery(context)); assert(debugCheckHasMaterialLocalizations(context)); final MaterialLocalizations localizations = MaterialLocalizations.of(context); final String routeLabel = _getRouteLabel(localizations); return AnimatedBuilder( animation: widget.route.animation!, child: SideSheet( animationController: widget.route._animationController, onClosing: () { if (widget.route.isCurrent) { Navigator.pop(context); } }, builder: widget.route.builder, backgroundColor: widget.backgroundColor, elevation: widget.elevation, shape: widget.shape, clipBehavior: widget.clipBehavior, constraints: widget.constraints, enableDrag: widget.enableDrag, showDragHandle: widget.showDragHandle, ), builder: (BuildContext context, Widget? child) { final double animationValue = animationCurve.transform( widget.route.animation!.value, ); return Semantics( scopesRoute: true, namesRoute: true, label: routeLabel, explicitChildNodes: true, child: ClipRect( child: _SideSheetLayoutWithSizeListener( onChildSizeChanged: (Size size) { widget.route._didChangeBarrierSemanticsClip( _getNewClipDetails(size), ); }, animationValue: animationValue, isScrollControlled: widget.isScrollControlled, scrollControlDisabledMaxHeightRatio: widget.scrollControlDisabledMaxHeightRatio, child: child, ), ), ); }, ); } } class ModalSideSheetRoute extends PopupRoute { ModalSideSheetRoute( {required this.builder, this.capturedThemes, this.barrierLabel, this.barrierOnTapHint, this.backgroundColor, this.elevation, this.shape, this.clipBehavior, this.constraints, this.modalBarrierColor, this.isDismissible = true, this.isScrollControlled = false, this.scrollControlDisabledMaxHeightRatio = _defaultScrollControlDisabledMaxHeightRatio, super.settings, this.transitionAnimationController, this.anchorPoint, this.useSafeArea = false, super.filter}); final WidgetBuilder builder; final CapturedThemes? capturedThemes; final bool isScrollControlled; final double scrollControlDisabledMaxHeightRatio; final Color? backgroundColor; final double? elevation; final ShapeBorder? shape; final Clip? clipBehavior; final BoxConstraints? constraints; final Color? modalBarrierColor; final bool isDismissible; final AnimationController? transitionAnimationController; final Offset? anchorPoint; final bool useSafeArea; final String? barrierOnTapHint; final ValueNotifier _clipDetailsNotifier = ValueNotifier(EdgeInsets.zero); @override void dispose() { _clipDetailsNotifier.dispose(); super.dispose(); } bool _didChangeBarrierSemanticsClip(EdgeInsets newClipDetails) { if (_clipDetailsNotifier.value == newClipDetails) { return false; } _clipDetailsNotifier.value = newClipDetails; return true; } @override Duration get transitionDuration => _bottomSheetEnterDuration; @override Duration get reverseTransitionDuration => _bottomSheetExitDuration; @override bool get barrierDismissible => isDismissible; @override final String? barrierLabel; @override Color get barrierColor => modalBarrierColor ?? Colors.black54; AnimationController? _animationController; @override AnimationController createAnimationController() { assert(_animationController == null); if (transitionAnimationController != null) { _animationController = transitionAnimationController; willDisposeAnimationController = false; } else { _animationController = SideSheet.createAnimationController(navigator!); } return _animationController!; } @override Widget buildPage(BuildContext context, Animation animation, Animation secondaryAnimation) { final Widget content = DisplayFeatureSubScreen( anchorPoint: anchorPoint, child: Builder( builder: (BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return _ModalSideSheet( route: this, backgroundColor: backgroundColor ?? colorScheme.surface, elevation: elevation ?? 0, shape: shape, clipBehavior: clipBehavior, constraints: constraints, isScrollControlled: isScrollControlled, scrollControlDisabledMaxHeightRatio: scrollControlDisabledMaxHeightRatio, ); }, ), ); final Widget sideSheet = content; return capturedThemes?.wrap(sideSheet) ?? sideSheet; } @override Widget buildModalBarrier() { if (barrierColor.alpha != 0 && !offstage) { assert(barrierColor != barrierColor.withOpacity(0.0)); final Animation color = animation!.drive( ColorTween( begin: barrierColor.withOpacity(0.0), end: barrierColor, ).chain( CurveTween(curve: barrierCurve), ), ); return AnimatedModalBarrier( color: color, dismissible: barrierDismissible, semanticsLabel: barrierLabel, barrierSemanticsDismissible: semanticsDismissible, clipDetailsNotifier: _clipDetailsNotifier, semanticsOnTapHint: barrierOnTapHint, ); } else { return ModalBarrier( dismissible: barrierDismissible, semanticsLabel: barrierLabel, barrierSemanticsDismissible: semanticsDismissible, clipDetailsNotifier: _clipDetailsNotifier, semanticsOnTapHint: barrierOnTapHint, ); } } } Future showModalSideSheet({ required BuildContext context, required Widget body, required String title, Color? backgroundColor, String? barrierLabel, double? elevation, ShapeBorder? shape, Clip? clipBehavior, BoxConstraints? constraints, Color? barrierColor, bool isScrollControlled = false, double scrollControlDisabledMaxHeightRatio = _defaultScrollControlDisabledMaxHeightRatio, bool useRootNavigator = false, bool isDismissible = true, bool useSafeArea = false, RouteSettings? routeSettings, AnimationController? transitionAnimationController, Offset? anchorPoint, }) { assert(debugCheckHasMediaQuery(context)); assert(debugCheckHasMaterialLocalizations(context)); final NavigatorState navigator = Navigator.of(context, rootNavigator: useRootNavigator); final MaterialLocalizations localizations = MaterialLocalizations.of(context); return navigator.push(ModalSideSheetRoute( builder: (context) { return SafeArea( child: Column( children: [ AppBar( automaticallyImplyLeading: false, title: Text(title), centerTitle: false, actions: const [ SizedBox( height: kToolbarHeight, width: kToolbarHeight, child: CloseButton(), ) ], ), Expanded( flex: 1, child: body, ), ], ), ); }, capturedThemes: InheritedTheme.capture(from: context, to: navigator.context), isScrollControlled: isScrollControlled, scrollControlDisabledMaxHeightRatio: scrollControlDisabledMaxHeightRatio, barrierLabel: barrierLabel ?? localizations.scrimLabel, barrierOnTapHint: localizations.scrimOnTapHint(localizations.bottomSheetLabel), backgroundColor: backgroundColor, elevation: elevation, shape: shape, clipBehavior: clipBehavior, constraints: constraints, isDismissible: isDismissible, modalBarrierColor: barrierColor ?? Theme.of(context).bottomSheetTheme.modalBarrierColor, settings: routeSettings, transitionAnimationController: transitionAnimationController, anchorPoint: anchorPoint, useSafeArea: useSafeArea, )); }