// Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'dart:ui' show lerpDouble; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'bottom_sheet_theme.dart'; import 'colors.dart'; import 'curves.dart'; import 'debug.dart'; import 'material.dart'; import 'material_localizations.dart'; import 'scaffold.dart'; import 'theme.dart'; const Duration _bottomSheetEnterDuration = Duration(milliseconds: 250); const Duration _bottomSheetExitDuration = Duration(milliseconds: 200); const Curve _modalBottomSheetCurve = decelerateEasing; const double _minFlingVelocity = 700.0; const double _closeProgressThreshold = 0.5; /// A callback for when the user begins dragging the bottom sheet. /// /// Used by [BottomSheet.onDragStart]. typedef BottomSheetDragStartHandler = void Function(DragStartDetails details); /// A callback for when the user stops dragging the bottom sheet. /// /// Used by [BottomSheet.onDragEnd]. typedef BottomSheetDragEndHandler = void Function( DragEndDetails details, { required bool isClosing, }); /// A material design bottom sheet. /// /// There are two kinds of bottom sheets in material design: /// /// * _Persistent_. A persistent bottom sheet shows information that /// supplements the primary content of the app. A persistent bottom sheet /// remains visible even when the user interacts with other parts of the app. /// Persistent bottom sheets can be created and displayed with the /// [ScaffoldState.showBottomSheet] function or by specifying the /// [Scaffold.bottomSheet] constructor parameter. /// /// * _Modal_. A modal bottom sheet is an alternative to a menu or a dialog and /// prevents the user from interacting with the rest of the app. Modal bottom /// sheets can be created and displayed with the [showModalBottomSheet] /// function. /// /// The [BottomSheet] widget itself is rarely used directly. Instead, prefer to /// create a persistent bottom sheet with [ScaffoldState.showBottomSheet] or /// [Scaffold.bottomSheet], and a modal bottom sheet with [showModalBottomSheet]. /// /// See also: /// /// * [showBottomSheet] and [ScaffoldState.showBottomSheet], for showing /// non-modal "persistent" bottom sheets. /// * [showModalBottomSheet], which can be used to display a modal bottom /// sheet. /// * <https://material.io/design/components/sheets-bottom.html> class BottomSheet extends StatefulWidget { /// Creates a bottom sheet. /// /// Typically, bottom sheets are created implicitly by /// [ScaffoldState.showBottomSheet], for persistent bottom sheets, or by /// [showModalBottomSheet], for modal bottom sheets. const BottomSheet({ Key? key, this.animationController, this.enableDrag = true, this.onDragStart, this.onDragEnd, this.backgroundColor, this.elevation, this.shape, this.clipBehavior, required this.onClosing, required this.builder, }) : assert(enableDrag != null), assert(onClosing != null), assert(builder != null), assert(elevation == null || elevation >= 0.0), super(key: key); /// The animation controller that controls the bottom sheet's entrance and /// exit animations. /// /// The BottomSheet widget will manipulate the position of this animation, it /// is not just a passive observer. final AnimationController? animationController; /// Called when the bottom sheet begins to close. /// /// A bottom sheet might be prevented from closing (e.g., by user /// interaction) even after this callback is called. For this reason, this /// callback might be call multiple times for a given bottom sheet. final VoidCallback onClosing; /// A builder for the contents of the sheet. /// /// The bottom sheet will wrap the widget produced by this builder in a /// [Material] widget. final WidgetBuilder builder; /// If true, the bottom sheet can be dragged up and down and dismissed by /// swiping downwards. /// /// Default is true. final bool enableDrag; /// Called when the user begins dragging the bottom sheet vertically, if /// [enableDrag] is true. /// /// Would typically be used to change the bottom sheet animation curve so /// that it tracks the user's finger accurately. final BottomSheetDragStartHandler? onDragStart; /// Called when the user stops dragging the bottom sheet, if [enableDrag] /// is true. /// /// Would typically be used to reset the bottom sheet animation curve, so /// that it animates non-linearly. Called before [onClosing] if the bottom /// sheet is closing. final BottomSheetDragEndHandler? onDragEnd; /// The bottom sheet's background color. /// /// Defines the bottom sheet's [Material.color]. /// /// Defaults to null and falls back to [Material]'s default. final Color? backgroundColor; /// The z-coordinate at which to place this material relative to its parent. /// /// This controls the size of the shadow below the material. /// /// Defaults to 0. The value is non-negative. final double? elevation; /// The shape of the bottom sheet. /// /// Defines the bottom sheet's [Material.shape]. /// /// Defaults to null and falls back to [Material]'s default. final ShapeBorder? shape; /// {@macro flutter.material.Material.clipBehavior} /// /// Defines the bottom sheet's [Material.clipBehavior]. /// /// Use this property to enable clipping of content when the bottom sheet has /// a custom [shape] and the content can extend past this shape. For example, /// a bottom sheet with rounded corners and an edge-to-edge [Image] at the /// top. /// /// If this property is null then [BottomSheetThemeData.clipBehavior] of /// [ThemeData.bottomSheetTheme] is used. If that's null then the behavior /// will be [Clip.none]. final Clip? clipBehavior; @override _BottomSheetState createState() => _BottomSheetState(); /// Creates an [AnimationController] suitable for a /// [BottomSheet.animationController]. /// /// This API available as a convenience for a Material compliant bottom sheet /// animation. If alternative animation durations are required, a different /// animation controller could be provided. static AnimationController createAnimationController(TickerProvider vsync) { return AnimationController( duration: _bottomSheetEnterDuration, reverseDuration: _bottomSheetExitDuration, debugLabel: 'BottomSheet', vsync: vsync, ); } } class _BottomSheetState extends State<BottomSheet> { final GlobalKey _childKey = GlobalKey(debugLabel: 'BottomSheet child'); double get _childHeight { final RenderBox renderBox = _childKey.currentContext!.findRenderObject()! as RenderBox; return renderBox.size.height; } bool get _dismissUnderway => widget.animationController!.status == AnimationStatus.reverse; void _handleDragStart(DragStartDetails details) { if (widget.onDragStart != null) { widget.onDragStart!(details); } } void _handleDragUpdate(DragUpdateDetails details) { assert(widget.enableDrag); if (_dismissUnderway) return; widget.animationController!.value -= details.primaryDelta! / _childHeight; } void _handleDragEnd(DragEndDetails details) { assert(widget.enableDrag); if (_dismissUnderway) return; bool isClosing = false; if (details.velocity.pixelsPerSecond.dy > _minFlingVelocity) { final double flingVelocity = -details.velocity.pixelsPerSecond.dy / _childHeight; if (widget.animationController!.value > 0.0) { widget.animationController!.fling(velocity: flingVelocity); } if (flingVelocity < 0.0) { isClosing = true; } } else if (widget.animationController!.value < _closeProgressThreshold) { if (widget.animationController!.value > 0.0) widget.animationController!.fling(velocity: -1.0); isClosing = true; } else { widget.animationController!.forward(); } if (widget.onDragEnd != null) { widget.onDragEnd!( details, isClosing: isClosing, ); } if (isClosing) { widget.onClosing(); } } bool extentChanged(DraggableScrollableNotification notification) { if (notification.extent == notification.minExtent) { widget.onClosing(); } return false; } @override Widget build(BuildContext context) { final BottomSheetThemeData bottomSheetTheme = Theme.of(context).bottomSheetTheme; final Color? color = widget.backgroundColor ?? bottomSheetTheme.backgroundColor; final double elevation = widget.elevation ?? bottomSheetTheme.elevation ?? 0; final ShapeBorder? shape = widget.shape ?? bottomSheetTheme.shape; final Clip clipBehavior = widget.clipBehavior ?? bottomSheetTheme.clipBehavior ?? Clip.none; final Widget bottomSheet = Material( key: _childKey, color: color, elevation: elevation, shape: shape, clipBehavior: clipBehavior, child: NotificationListener<DraggableScrollableNotification>( onNotification: extentChanged, child: widget.builder(context), ), ); return !widget.enableDrag ? bottomSheet : GestureDetector( onVerticalDragStart: _handleDragStart, onVerticalDragUpdate: _handleDragUpdate, onVerticalDragEnd: _handleDragEnd, child: bottomSheet, excludeFromSemantics: true, ); } } // PERSISTENT BOTTOM SHEETS // See scaffold.dart // MODAL BOTTOM SHEETS class _ModalBottomSheetLayout extends SingleChildLayoutDelegate { _ModalBottomSheetLayout(this.progress, this.isScrollControlled); final double progress; final bool isScrollControlled; @override BoxConstraints getConstraintsForChild(BoxConstraints constraints) { return BoxConstraints( minWidth: constraints.maxWidth, maxWidth: constraints.maxWidth, minHeight: 0.0, maxHeight: isScrollControlled ? constraints.maxHeight : constraints.maxHeight * 9.0 / 16.0, ); } @override Offset getPositionForChild(Size size, Size childSize) { return Offset(0.0, size.height - childSize.height * progress); } @override bool shouldRelayout(_ModalBottomSheetLayout oldDelegate) { return progress != oldDelegate.progress; } } class _ModalBottomSheet<T> extends StatefulWidget { const _ModalBottomSheet({ Key? key, this.route, this.backgroundColor, this.elevation, this.shape, this.clipBehavior, this.isScrollControlled = false, this.enableDrag = true, }) : assert(isScrollControlled != null), assert(enableDrag != null), super(key: key); final _ModalBottomSheetRoute<T>? route; final bool isScrollControlled; final Color? backgroundColor; final double? elevation; final ShapeBorder? shape; final Clip? clipBehavior; final bool enableDrag; @override _ModalBottomSheetState<T> createState() => _ModalBottomSheetState<T>(); } class _ModalBottomSheetState<T> extends State<_ModalBottomSheet<T>> { ParametricCurve<double> 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; } } void handleDragStart(DragStartDetails details) { // Allow the bottom sheet to track the user's finger accurately. animationCurve = Curves.linear; } void handleDragEnd(DragEndDetails details, {bool? isClosing}) { // Allow the bottom sheet to animate smoothly from its current position. animationCurve = _BottomSheetSuspendedCurve( widget.route!.animation!.value, curve: _modalBottomSheetCurve, ); } @override Widget build(BuildContext context) { assert(debugCheckHasMediaQuery(context)); assert(debugCheckHasMaterialLocalizations(context)); final MediaQueryData mediaQuery = MediaQuery.of(context); final MaterialLocalizations localizations = MaterialLocalizations.of(context); final String routeLabel = _getRouteLabel(localizations); return AnimatedBuilder( animation: widget.route!.animation!, child: Padding( padding: MediaQuery.of(context).viewInsets, child: BottomSheet( 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, enableDrag: widget.enableDrag, onDragStart: handleDragStart, onDragEnd: handleDragEnd, ), ), builder: (BuildContext context, Widget? child) { // Disable the initial animation when accessible navigation is on so // that the semantics are added to the tree at the correct time. final double animationValue = animationCurve.transform( mediaQuery.accessibleNavigation ? 1.0 : widget.route!.animation!.value ); return Semantics( scopesRoute: true, namesRoute: true, label: routeLabel, explicitChildNodes: true, child: ClipRect( child: CustomSingleChildLayout( delegate: _ModalBottomSheetLayout(animationValue, widget.isScrollControlled), child: child, ), ), ); }, ); } } class _ModalBottomSheetRoute<T> extends PopupRoute<T> { _ModalBottomSheetRoute({ this.builder, required this.capturedThemes, this.barrierLabel, this.backgroundColor, this.elevation, this.shape, this.clipBehavior, this.modalBarrierColor, this.isDismissible = true, this.enableDrag = true, required this.isScrollControlled, RouteSettings? settings, this.transitionAnimationController, }) : assert(isScrollControlled != null), assert(isDismissible != null), assert(enableDrag != null), super(settings: settings); final WidgetBuilder? builder; final CapturedThemes capturedThemes; final bool isScrollControlled; final Color? backgroundColor; final double? elevation; final ShapeBorder? shape; final Clip? clipBehavior; final Color? modalBarrierColor; final bool isDismissible; final bool enableDrag; final AnimationController? transitionAnimationController; @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); _animationController = transitionAnimationController ?? BottomSheet.createAnimationController(navigator!.overlay!); return _animationController!; } @override Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) { // By definition, the bottom sheet is aligned to the bottom of the page // and isn't exposed to the top padding of the MediaQuery. final Widget bottomSheet = MediaQuery.removePadding( context: context, removeTop: true, child: Builder( builder: (BuildContext context) { final BottomSheetThemeData sheetTheme = Theme.of(context).bottomSheetTheme; return _ModalBottomSheet<T>( route: this, backgroundColor: backgroundColor ?? sheetTheme.modalBackgroundColor ?? sheetTheme.backgroundColor, elevation: elevation ?? sheetTheme.modalElevation ?? sheetTheme.elevation, shape: shape, clipBehavior: clipBehavior, isScrollControlled: isScrollControlled, enableDrag: enableDrag, ); }, ), ); return capturedThemes.wrap(bottomSheet); } } // TODO(guidezpl): Look into making this public. A copy of this class is in // scaffold.dart, for now, https://github.com/flutter/flutter/issues/51627 /// A curve that progresses linearly until a specified [startingPoint], at which /// point [curve] will begin. Unlike [Interval], [curve] will not start at zero, /// but will use [startingPoint] as the Y position. /// /// For example, if [startingPoint] is set to `0.5`, and [curve] is set to /// [Curves.easeOut], then the bottom-left quarter of the curve will be a /// straight line, and the top-right quarter will contain the entire contents of /// [Curves.easeOut]. /// /// This is useful in situations where a widget must track the user's finger /// (which requires a linear animation), and afterwards can be flung using a /// curve specified with the [curve] argument, after the finger is released. In /// such a case, the value of [startingPoint] would be the progress of the /// animation at the time when the finger was released. /// /// The [startingPoint] and [curve] arguments must not be null. class _BottomSheetSuspendedCurve extends ParametricCurve<double> { /// Creates a suspended curve. const _BottomSheetSuspendedCurve( this.startingPoint, { this.curve = Curves.easeOutCubic, }) : assert(startingPoint != null), assert(curve != null); /// The progress value at which [curve] should begin. /// /// This defaults to [Curves.easeOutCubic]. final double startingPoint; /// The curve to use when [startingPoint] is reached. final Curve curve; @override double transform(double t) { assert(t >= 0.0 && t <= 1.0); assert(startingPoint >= 0.0 && startingPoint <= 1.0); if (t < startingPoint) { return t; } if (t == 1.0) { return t; } final double curveProgress = (t - startingPoint) / (1 - startingPoint); final double transformed = curve.transform(curveProgress); return lerpDouble(startingPoint, 1, transformed)!; } @override String toString() { return '${describeIdentity(this)}($startingPoint, $curve)'; } } /// Shows a modal material design bottom sheet. /// /// A modal bottom sheet is an alternative to a menu or a dialog and prevents /// the user from interacting with the rest of the app. /// /// A closely related widget is a persistent bottom sheet, which shows /// information that supplements the primary content of the app without /// preventing the use from interacting with the app. Persistent bottom sheets /// can be created and displayed with the [showBottomSheet] function or the /// [ScaffoldState.showBottomSheet] method. /// /// The `context` argument is used to look up the [Navigator] and [Theme] for /// the bottom sheet. It is only used when the method is called. Its /// corresponding widget can be safely removed from the tree before the bottom /// sheet is closed. /// /// The `isScrollControlled` parameter specifies whether this is a route for /// a bottom sheet that will utilize [DraggableScrollableSheet]. If you wish /// to have a bottom sheet that has a scrollable child such as a [ListView] or /// a [GridView] and have the bottom sheet be draggable, you should set this /// parameter to true. /// /// The `useRootNavigator` parameter ensures that the root navigator is used to /// display the [BottomSheet] when set to `true`. This is useful in the case /// that a modal [BottomSheet] needs to be displayed above all other content /// but the caller is inside another [Navigator]. /// /// The [isDismissible] parameter specifies whether the bottom sheet will be /// dismissed when user taps on the scrim. /// /// The [enableDrag] parameter specifies whether the bottom sheet can be /// dragged up and down and dismissed by swiping downwards. /// /// The optional [backgroundColor], [elevation], [shape], [clipBehavior] and [transitionAnimationController] /// parameters can be passed in to customize the appearance and behavior of /// modal bottom sheets. /// /// The [transitionAnimationController] controls the bottom sheet's entrance and /// exit animations if provided. /// /// The optional `routeSettings` parameter sets the [RouteSettings] of the modal bottom sheet /// sheet. This is particularly useful in the case that a user wants to observe /// [PopupRoute]s within a [NavigatorObserver]. /// /// Returns a `Future` that resolves to the value (if any) that was passed to /// [Navigator.pop] when the modal bottom sheet was closed. /// /// {@tool dartpad --template=stateless_widget_scaffold} /// /// This example demonstrates how to use `showModalBottomSheet` to display a /// bottom sheet that obscures the content behind it when a user taps a button. /// It also demonstrates how to close the bottom sheet using the [Navigator] /// when a user taps on a button inside the bottom sheet. /// /// ```dart /// Widget build(BuildContext context) { /// return Center( /// child: ElevatedButton( /// child: const Text('showModalBottomSheet'), /// onPressed: () { /// showModalBottomSheet<void>( /// context: context, /// builder: (BuildContext context) { /// return Container( /// height: 200, /// color: Colors.amber, /// child: Center( /// child: Column( /// mainAxisAlignment: MainAxisAlignment.center, /// mainAxisSize: MainAxisSize.min, /// children: <Widget>[ /// const Text('Modal BottomSheet'), /// ElevatedButton( /// child: const Text('Close BottomSheet'), /// onPressed: () => Navigator.pop(context), /// ) /// ], /// ), /// ), /// ); /// }, /// ); /// }, /// ), /// ); /// } /// ``` /// {@end-tool} /// See also: /// /// * [BottomSheet], which becomes the parent of the widget returned by the /// function passed as the `builder` argument to [showModalBottomSheet]. /// * [showBottomSheet] and [ScaffoldState.showBottomSheet], for showing /// non-modal bottom sheets. /// * [DraggableScrollableSheet], which allows you to create a bottom sheet /// that grows and then becomes scrollable once it reaches its maximum size. /// * <https://material.io/design/components/sheets-bottom.html#modal-bottom-sheet> Future<T?> showModalBottomSheet<T>({ required BuildContext context, required WidgetBuilder builder, Color? backgroundColor, double? elevation, ShapeBorder? shape, Clip? clipBehavior, Color? barrierColor, bool isScrollControlled = false, bool useRootNavigator = false, bool isDismissible = true, bool enableDrag = true, RouteSettings? routeSettings, AnimationController? transitionAnimationController, }) { assert(context != null); assert(builder != null); assert(isScrollControlled != null); assert(useRootNavigator != null); assert(isDismissible != null); assert(enableDrag != null); assert(debugCheckHasMediaQuery(context)); assert(debugCheckHasMaterialLocalizations(context)); final NavigatorState navigator = Navigator.of(context, rootNavigator: useRootNavigator); return navigator.push(_ModalBottomSheetRoute<T>( builder: builder, capturedThemes: InheritedTheme.capture(from: context, to: navigator.context), isScrollControlled: isScrollControlled, barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, backgroundColor: backgroundColor, elevation: elevation, shape: shape, clipBehavior: clipBehavior, isDismissible: isDismissible, modalBarrierColor: barrierColor, enableDrag: enableDrag, settings: routeSettings, transitionAnimationController: transitionAnimationController, )); } /// Shows a material design bottom sheet in the nearest [Scaffold] ancestor. If /// you wish to show a persistent bottom sheet, use [Scaffold.bottomSheet]. /// /// Returns a controller that can be used to close and otherwise manipulate the /// bottom sheet. /// /// The optional [backgroundColor], [elevation], [shape], [clipBehavior] and [transitionAnimationController] /// parameters can be passed in to customize the appearance and behavior of /// persistent bottom sheets. /// /// To rebuild the bottom sheet (e.g. if it is stateful), call /// [PersistentBottomSheetController.setState] on the controller returned by /// this method. /// /// The new bottom sheet becomes a [LocalHistoryEntry] for the enclosing /// [ModalRoute] and a back button is added to the app bar of the [Scaffold] /// that closes the bottom sheet. /// /// To create a persistent bottom sheet that is not a [LocalHistoryEntry] and /// does not add a back button to the enclosing Scaffold's app bar, use the /// [Scaffold.bottomSheet] constructor parameter. /// /// A closely related widget is a modal bottom sheet, which is an alternative /// to a menu or a dialog and prevents the user from interacting with the rest /// of the app. Modal bottom sheets can be created and displayed with the /// [showModalBottomSheet] function. /// /// The `context` argument is used to look up the [Scaffold] for the bottom /// sheet. It is only used when the method is called. Its corresponding widget /// can be safely removed from the tree before the bottom sheet is closed. /// /// See also: /// /// * [BottomSheet], which becomes the parent of the widget returned by the /// `builder`. /// * [showModalBottomSheet], which can be used to display a modal bottom /// sheet. /// * [Scaffold.of], for information about how to obtain the [BuildContext]. /// * <https://material.io/design/components/sheets-bottom.html#standard-bottom-sheet> PersistentBottomSheetController<T> showBottomSheet<T>({ required BuildContext context, required WidgetBuilder builder, Color? backgroundColor, double? elevation, ShapeBorder? shape, Clip? clipBehavior, AnimationController? transitionAnimationController, }) { assert(context != null); assert(builder != null); assert(debugCheckHasScaffold(context)); return Scaffold.of(context).showBottomSheet<T>( builder, backgroundColor: backgroundColor, elevation: elevation, shape: shape, clipBehavior: clipBehavior, transitionAnimationController: transitionAnimationController, ); }