// Copyright 2015 The Chromium 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:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/widgets.dart'; import 'bottom_sheet_theme.dart'; import 'colors.dart'; import 'debug.dart'; import 'material.dart'; import 'material_localizations.dart'; import 'scaffold.dart'; import 'theme.dart'; const Duration _bottomSheetDuration = Duration(milliseconds: 200); const double _minFlingVelocity = 700.0; const double _closeProgressThreshold = 0.5; /// 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.backgroundColor, this.elevation, this.shape, @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 downards. /// /// Default is true. final bool enableDrag; /// 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; @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: _bottomSheetDuration, 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(); return renderBox.size.height; } bool get _dismissUnderway => widget.animationController.status == AnimationStatus.reverse; void _handleDragUpdate(DragUpdateDetails details) { assert(widget.enableDrag); if (_dismissUnderway) return; widget.animationController.value -= details.primaryDelta / (_childHeight ?? details.primaryDelta); } void _handleDragEnd(DragEndDetails details) { assert(widget.enableDrag); if (_dismissUnderway) return; 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) { widget.onClosing(); } } else if (widget.animationController.value < _closeProgressThreshold) { if (widget.animationController.value > 0.0) widget.animationController.fling(velocity: -1.0); widget.onClosing(); } else { widget.animationController.forward(); } } 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 Widget bottomSheet = Material( key: _childKey, color: color, elevation: elevation, shape: shape, child: NotificationListener<DraggableScrollableNotification>( onNotification: extentChanged, child: widget.builder(context), ), ); return !widget.enableDrag ? bottomSheet : GestureDetector( 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.isScrollControlled = false, }) : assert(isScrollControlled != null), super(key: key); final _ModalBottomSheetRoute<T> route; final bool isScrollControlled; final Color backgroundColor; final double elevation; final ShapeBorder shape; @override _ModalBottomSheetState<T> createState() => _ModalBottomSheetState<T>(); } class _ModalBottomSheetState<T> extends State<_ModalBottomSheet<T>> { String _getRouteLabel(MaterialLocalizations localizations) { switch (defaultTargetPlatform) { case TargetPlatform.iOS: return ''; case TargetPlatform.android: case TargetPlatform.fuchsia: return localizations.dialogLabel; } return null; } @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, 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 = 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: 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, ), ), ), ); }, ); } } class _ModalBottomSheetRoute<T> extends PopupRoute<T> { _ModalBottomSheetRoute({ this.builder, this.theme, this.barrierLabel, this.backgroundColor, this.elevation, this.shape, @required this.isScrollControlled, RouteSettings settings, }) : assert(isScrollControlled != null), super(settings: settings); final WidgetBuilder builder; final ThemeData theme; final bool isScrollControlled; final Color backgroundColor; final double elevation; final ShapeBorder shape; @override Duration get transitionDuration => _bottomSheetDuration; @override bool get barrierDismissible => true; @override final String barrierLabel; @override Color get barrierColor => Colors.black54; AnimationController _animationController; @override AnimationController createAnimationController() { assert(_animationController == null); _animationController = 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. Widget bottomSheet = MediaQuery.removePadding( context: context, removeTop: true, child: _ModalBottomSheet<T>( route: this, backgroundColor: backgroundColor, elevation: elevation, shape: shape, isScrollControlled: isScrollControlled ), ); if (theme != null) bottomSheet = Theme(data: theme, child: bottomSheet); return bottomSheet; } } /// 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]. /// /// Returns a `Future` that resolves to the value (if any) that was passed to /// [Navigator.pop] when the modal bottom sheet was closed. /// /// See also: /// /// * [BottomSheet], which is the widget normally returned by the function /// passed as the `builder` argument to [showModalBottomSheet]. /// * [showBottomSheet] and [ScaffoldState.showBottomSheet], for showing /// non-modal bottom sheets. /// * <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, bool isScrollControlled = false, bool useRootNavigator = false, }) { assert(context != null); assert(builder != null); assert(isScrollControlled != null); assert(useRootNavigator != null); assert(debugCheckHasMediaQuery(context)); assert(debugCheckHasMaterialLocalizations(context)); return Navigator.of(context, rootNavigator: useRootNavigator).push(_ModalBottomSheetRoute<T>( builder: builder, theme: Theme.of(context, shadowThemeOnly: true), isScrollControlled: isScrollControlled, barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, backgroundColor: backgroundColor, elevation: elevation, shape: shape, )); } /// 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. /// /// 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 appbar 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 appbar, 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 is the widget typically 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, }) { assert(context != null); assert(builder != null); assert(debugCheckHasScaffold(context)); return Scaffold.of(context).showBottomSheet<T>( builder, backgroundColor: backgroundColor, elevation: elevation, shape: shape, ); }