| // 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:math' as math; |
| |
| import 'package:flutter/foundation.dart'; |
| import 'package:flutter/gestures.dart'; |
| |
| import 'basic.dart'; |
| import 'framework.dart'; |
| import 'inherited_notifier.dart'; |
| import 'layout_builder.dart'; |
| import 'notification_listener.dart'; |
| import 'scroll_activity.dart'; |
| import 'scroll_context.dart'; |
| import 'scroll_controller.dart'; |
| import 'scroll_notification.dart'; |
| import 'scroll_physics.dart'; |
| import 'scroll_position.dart'; |
| import 'scroll_position_with_single_context.dart'; |
| import 'scroll_simulation.dart'; |
| |
| /// The signature of a method that provides a [BuildContext] and |
| /// [ScrollController] for building a widget that may overflow the draggable |
| /// [Axis] of the containing [DraggableScrollableSheet]. |
| /// |
| /// Users should apply the [scrollController] to a [ScrollView] subclass, such |
| /// as a [SingleChildScrollView], [ListView] or [GridView], to have the whole |
| /// sheet be draggable. |
| typedef ScrollableWidgetBuilder = Widget Function( |
| BuildContext context, |
| ScrollController scrollController, |
| ); |
| |
| /// A container for a [Scrollable] that responds to drag gestures by resizing |
| /// the scrollable until a limit is reached, and then scrolling. |
| /// |
| /// {@youtube 560 315 https://www.youtube.com/watch?v=Hgw819mL_78} |
| /// |
| /// This widget can be dragged along the vertical axis between its |
| /// [minChildSize], which defaults to `0.25` and [maxChildSize], which defaults |
| /// to `1.0`. These sizes are percentages of the height of the parent container. |
| /// |
| /// The widget coordinates resizing and scrolling of the widget returned by |
| /// builder as the user drags along the horizontal axis. |
| /// |
| /// The widget will initially be displayed at its initialChildSize which |
| /// defaults to `0.5`, meaning half the height of its parent. Dragging will work |
| /// between the range of minChildSize and maxChildSize (as percentages of the |
| /// parent container's height) as long as the builder creates a widget which |
| /// uses the provided [ScrollController]. If the widget created by the |
| /// [ScrollableWidgetBuilder] does not use the provided [ScrollController], the |
| /// sheet will remain at the initialChildSize. |
| /// |
| /// By default, the widget will stay at whatever size the user drags it to. To |
| /// make the widget snap to specific sizes whenever they lift their finger |
| /// during a drag, set [snap] to `true`. The sheet will snap between |
| /// [minChildSize] and [maxChildSize]. Use [snapSizes] to add more sizes for |
| /// the sheet to snap between. |
| /// |
| /// By default, the widget will expand its non-occupied area to fill available |
| /// space in the parent. If this is not desired, e.g. because the parent wants |
| /// to position sheet based on the space it is taking, the [expand] property |
| /// may be set to false. |
| /// |
| /// {@tool snippet} |
| /// |
| /// This is a sample widget which shows a [ListView] that has 25 [ListTile]s. |
| /// It starts out as taking up half the body of the [Scaffold], and can be |
| /// dragged up to the full height of the scaffold or down to 25% of the height |
| /// of the scaffold. Upon reaching full height, the list contents will be |
| /// scrolled up or down, until they reach the top of the list again and the user |
| /// drags the sheet back down. |
| /// |
| /// ```dart |
| /// class HomePage extends StatelessWidget { |
| /// const HomePage({Key? key}) : super(key: key); |
| /// |
| /// @override |
| /// Widget build(BuildContext context) { |
| /// return Scaffold( |
| /// appBar: AppBar( |
| /// title: const Text('DraggableScrollableSheet'), |
| /// ), |
| /// body: SizedBox.expand( |
| /// child: DraggableScrollableSheet( |
| /// builder: (BuildContext context, ScrollController scrollController) { |
| /// return Container( |
| /// color: Colors.blue[100], |
| /// child: ListView.builder( |
| /// controller: scrollController, |
| /// itemCount: 25, |
| /// itemBuilder: (BuildContext context, int index) { |
| /// return ListTile(title: Text('Item $index')); |
| /// }, |
| /// ), |
| /// ); |
| /// }, |
| /// ), |
| /// ), |
| /// ); |
| /// } |
| /// } |
| /// ``` |
| /// {@end-tool} |
| class DraggableScrollableSheet extends StatefulWidget { |
| /// Creates a widget that can be dragged and scrolled in a single gesture. |
| /// |
| /// The [builder], [initialChildSize], [minChildSize], [maxChildSize] and |
| /// [expand] parameters must not be null. |
| const DraggableScrollableSheet({ |
| Key? key, |
| this.initialChildSize = 0.5, |
| this.minChildSize = 0.25, |
| this.maxChildSize = 1.0, |
| this.expand = true, |
| this.snap = false, |
| this.snapSizes, |
| required this.builder, |
| }) : assert(initialChildSize != null), |
| assert(minChildSize != null), |
| assert(maxChildSize != null), |
| assert(minChildSize >= 0.0), |
| assert(maxChildSize <= 1.0), |
| assert(minChildSize <= initialChildSize), |
| assert(initialChildSize <= maxChildSize), |
| assert(expand != null), |
| assert(builder != null), |
| super(key: key); |
| |
| /// The initial fractional value of the parent container's height to use when |
| /// displaying the widget. |
| /// |
| /// The default value is `0.5`. |
| final double initialChildSize; |
| |
| /// The minimum fractional value of the parent container's height to use when |
| /// displaying the widget. |
| /// |
| /// The default value is `0.25`. |
| final double minChildSize; |
| |
| /// The maximum fractional value of the parent container's height to use when |
| /// displaying the widget. |
| /// |
| /// The default value is `1.0`. |
| final double maxChildSize; |
| |
| /// Whether the widget should expand to fill the available space in its parent |
| /// or not. |
| /// |
| /// In most cases, this should be true. However, in the case of a parent |
| /// widget that will position this one based on its desired size (such as a |
| /// [Center]), this should be set to false. |
| /// |
| /// The default value is true. |
| final bool expand; |
| |
| /// Whether the widget should snap between [snapSizes] when the user lifts |
| /// their finger during a drag. |
| /// |
| /// If the user's finger was still moving when they lifted it, the widget will |
| /// snap to the next snap size (see [snapSizes]) in the direction of the drag. |
| /// If their finger was still, the widget will snap to the nearest snap size. |
| final bool snap; |
| |
| /// A list of target sizes that the widget should snap to. |
| /// |
| /// Snap sizes are fractional values of the parent container's height. They |
| /// must be listed in increasing order and be between [minChildSize] and |
| /// [maxChildSize]. |
| /// |
| /// The [minChildSize] and [maxChildSize] are implicitly included in snap |
| /// sizes and do not need to be specified here. For example, `snapSizes = [.5]` |
| /// will result in a sheet that snaps between [minChildSize], `.5`, and |
| /// [maxChildSize]. |
| final List<double>? snapSizes; |
| |
| /// The builder that creates a child to display in this widget, which will |
| /// use the provided [ScrollController] to enable dragging and scrolling |
| /// of the contents. |
| final ScrollableWidgetBuilder builder; |
| |
| @override |
| State<DraggableScrollableSheet> createState() => _DraggableScrollableSheetState(); |
| } |
| |
| /// A [Notification] related to the extent, which is the size, and scroll |
| /// offset, which is the position of the child list, of the |
| /// [DraggableScrollableSheet]. |
| /// |
| /// [DraggableScrollableSheet] widgets notify their ancestors when the size of |
| /// the sheet changes. When the extent of the sheet changes via a drag, |
| /// this notification bubbles up through the tree, which means a given |
| /// [NotificationListener] will receive notifications for all descendant |
| /// [DraggableScrollableSheet] widgets. To focus on notifications from the |
| /// nearest [DraggableScrollableSheet] descendant, check that the [depth] |
| /// property of the notification is zero. |
| /// |
| /// When an extent notification is received by a [NotificationListener], the |
| /// listener will already have completed build and layout, and it is therefore |
| /// too late for that widget to call [State.setState]. Any attempt to adjust the |
| /// build or layout based on an extent notification would result in a layout |
| /// that lagged one frame behind, which is a poor user experience. Extent |
| /// notifications are used primarily to drive animations. The [Scaffold] widget |
| /// listens for extent notifications and responds by driving animations for the |
| /// [FloatingActionButton] as the bottom sheet scrolls up. |
| class DraggableScrollableNotification extends Notification with ViewportNotificationMixin { |
| /// Creates a notification that the extent of a [DraggableScrollableSheet] has |
| /// changed. |
| /// |
| /// All parameters are required. The [minExtent] must be >= 0. The [maxExtent] |
| /// must be <= 1.0. The [extent] must be between [minExtent] and [maxExtent]. |
| DraggableScrollableNotification({ |
| required this.extent, |
| required this.minExtent, |
| required this.maxExtent, |
| required this.initialExtent, |
| required this.context, |
| }) : assert(extent != null), |
| assert(initialExtent != null), |
| assert(minExtent != null), |
| assert(maxExtent != null), |
| assert(0.0 <= minExtent), |
| assert(maxExtent <= 1.0), |
| assert(minExtent <= extent), |
| assert(minExtent <= initialExtent), |
| assert(extent <= maxExtent), |
| assert(initialExtent <= maxExtent), |
| assert(context != null); |
| |
| /// The current value of the extent, between [minExtent] and [maxExtent]. |
| final double extent; |
| |
| /// The minimum value of [extent], which is >= 0. |
| final double minExtent; |
| |
| /// The maximum value of [extent]. |
| final double maxExtent; |
| |
| /// The initially requested value for [extent]. |
| final double initialExtent; |
| |
| /// The build context of the widget that fired this notification. |
| /// |
| /// This can be used to find the sheet's render objects to determine the size |
| /// of the viewport, for instance. A listener can only assume this context |
| /// is live when it first gets the notification. |
| final BuildContext context; |
| |
| @override |
| void debugFillDescription(List<String> description) { |
| super.debugFillDescription(description); |
| description.add('minExtent: $minExtent, extent: $extent, maxExtent: $maxExtent, initialExtent: $initialExtent'); |
| } |
| } |
| |
| /// Manages state between [_DraggableScrollableSheetState], |
| /// [_DraggableScrollableSheetScrollController], and |
| /// [_DraggableScrollableSheetScrollPosition]. |
| /// |
| /// The State knows the pixels available along the axis the widget wants to |
| /// scroll, but expects to get a fraction of those pixels to render the sheet. |
| /// |
| /// The ScrollPosition knows the number of pixels a user wants to move the sheet. |
| /// |
| /// The [currentExtent] will never be null. |
| /// The [availablePixels] will never be null, but may be `double.infinity`. |
| class _DraggableSheetExtent { |
| _DraggableSheetExtent({ |
| required this.minExtent, |
| required this.maxExtent, |
| required this.snap, |
| required this.snapSizes, |
| required this.initialExtent, |
| required VoidCallback listener, |
| }) : assert(minExtent != null), |
| assert(maxExtent != null), |
| assert(initialExtent != null), |
| assert(minExtent >= 0), |
| assert(maxExtent <= 1), |
| assert(minExtent <= initialExtent), |
| assert(initialExtent <= maxExtent), |
| _currentExtent = ValueNotifier<double>(initialExtent)..addListener(listener), |
| availablePixels = double.infinity; |
| |
| final double minExtent; |
| final double maxExtent; |
| final bool snap; |
| final List<double> snapSizes; |
| final double initialExtent; |
| final ValueNotifier<double> _currentExtent; |
| double availablePixels; |
| |
| // Used to disable snapping until the extent has changed. We do this because |
| // we don't want to snap away from the initial extent. |
| bool hasChanged = false; |
| |
| bool get isAtMin => minExtent >= _currentExtent.value; |
| bool get isAtMax => maxExtent <= _currentExtent.value; |
| |
| set currentExtent(double value) { |
| assert(value != null); |
| hasChanged = true; |
| _currentExtent.value = value.clamp(minExtent, maxExtent); |
| } |
| double get currentExtent => _currentExtent.value; |
| double get currentPixels => extentToPixels(_currentExtent.value); |
| |
| double get additionalMinExtent => isAtMin ? 0.0 : 1.0; |
| double get additionalMaxExtent => isAtMax ? 0.0 : 1.0; |
| List<double> get pixelSnapSizes => snapSizes.map(extentToPixels).toList(); |
| |
| /// The scroll position gets inputs in terms of pixels, but the extent is |
| /// expected to be expressed as a number between 0..1. |
| void addPixelDelta(double delta, BuildContext context) { |
| if (availablePixels == 0) { |
| return; |
| } |
| updateExtent(currentExtent + pixelsToExtent(delta), context); |
| } |
| |
| /// Set the extent to the new value. [newExtent] should be a number between |
| /// 0..1. |
| void updateExtent(double newExtent, BuildContext context) { |
| currentExtent = newExtent; |
| DraggableScrollableNotification( |
| minExtent: minExtent, |
| maxExtent: maxExtent, |
| extent: currentExtent, |
| initialExtent: initialExtent, |
| context: context, |
| ).dispatch(context); |
| } |
| |
| double pixelsToExtent(double pixels) { |
| return pixels / availablePixels * maxExtent; |
| } |
| |
| double extentToPixels(double extent) { |
| return extent / maxExtent * availablePixels; |
| } |
| } |
| |
| class _DraggableScrollableSheetState extends State<DraggableScrollableSheet> { |
| late _DraggableScrollableSheetScrollController _scrollController; |
| late _DraggableSheetExtent _extent; |
| |
| @override |
| void initState() { |
| super.initState(); |
| _extent = _DraggableSheetExtent( |
| minExtent: widget.minChildSize, |
| maxExtent: widget.maxChildSize, |
| snap: widget.snap, |
| snapSizes: _impliedSnapSizes(), |
| initialExtent: widget.initialChildSize, |
| listener: _setExtent, |
| ); |
| _scrollController = _DraggableScrollableSheetScrollController(extent: _extent); |
| } |
| |
| List<double> _impliedSnapSizes() { |
| for (int index = 0; index < (widget.snapSizes?.length ?? 0); index += 1) { |
| final double snapSize = widget.snapSizes![index]; |
| assert(snapSize >= widget.minChildSize && snapSize <= widget.maxChildSize, |
| '${_snapSizeErrorMessage(index)}\nSnap sizes must be between `minChildSize` and `maxChildSize`. '); |
| assert(index == 0 || snapSize > widget.snapSizes![index - 1], |
| '${_snapSizeErrorMessage(index)}\nSnap sizes must be in ascending order. '); |
| } |
| widget.snapSizes?.asMap().forEach((int index, double snapSize) { |
| }); |
| // Ensure the snap sizes start and end with the min and max child sizes. |
| if (widget.snapSizes == null || widget.snapSizes!.isEmpty) { |
| return <double>[ |
| widget.minChildSize, |
| widget.maxChildSize, |
| ]; |
| } |
| return <double>[ |
| if (widget.snapSizes!.first != widget.minChildSize) widget.minChildSize, |
| ...widget.snapSizes!, |
| if (widget.snapSizes!.last != widget.maxChildSize) widget.maxChildSize, |
| ]; |
| } |
| |
| @override |
| void didChangeDependencies() { |
| super.didChangeDependencies(); |
| if (_InheritedResetNotifier.shouldReset(context)) { |
| // jumpTo can result in trying to replace semantics during build. |
| // Just animate really fast. |
| // Avoid doing it at all if the offset is already 0.0. |
| if (_scrollController.offset != 0.0) { |
| _scrollController.animateTo( |
| 0.0, |
| duration: const Duration(milliseconds: 1), |
| curve: Curves.linear, |
| ); |
| } |
| _extent.hasChanged = false; |
| _extent._currentExtent.value = _extent.initialExtent; |
| } |
| } |
| |
| void _setExtent() { |
| setState(() { |
| // _extent has been updated when this is called. |
| }); |
| |
| } |
| |
| @override |
| Widget build(BuildContext context) { |
| return LayoutBuilder( |
| builder: (BuildContext context, BoxConstraints constraints) { |
| _extent.availablePixels = widget.maxChildSize * constraints.biggest.height; |
| final Widget sheet = FractionallySizedBox( |
| heightFactor: _extent.currentExtent, |
| alignment: Alignment.bottomCenter, |
| child: widget.builder(context, _scrollController), |
| ); |
| return widget.expand ? SizedBox.expand(child: sheet) : sheet; |
| }, |
| ); |
| } |
| |
| @override |
| void dispose() { |
| _scrollController.dispose(); |
| super.dispose(); |
| } |
| |
| String _snapSizeErrorMessage(int invalidIndex) { |
| final List<String> snapSizesWithIndicator = widget.snapSizes!.asMap().keys.map( |
| (int index) { |
| final String snapSizeString = widget.snapSizes![index].toString(); |
| if (index == invalidIndex) { |
| return '>>> $snapSizeString <<<'; |
| } |
| return snapSizeString; |
| }, |
| ).toList(); |
| return "Invalid snapSize '${widget.snapSizes![invalidIndex]}' at index $invalidIndex of:\n" |
| ' $snapSizesWithIndicator'; |
| } |
| } |
| |
| /// A [ScrollController] suitable for use in a [ScrollableWidgetBuilder] created |
| /// by a [DraggableScrollableSheet]. |
| /// |
| /// If a [DraggableScrollableSheet] contains content that is exceeds the height |
| /// of its container, this controller will allow the sheet to both be dragged to |
| /// fill the container and then scroll the child content. |
| /// |
| /// See also: |
| /// |
| /// * [_DraggableScrollableSheetScrollPosition], which manages the positioning logic for |
| /// this controller. |
| /// * [PrimaryScrollController], which can be used to establish a |
| /// [_DraggableScrollableSheetScrollController] as the primary controller for |
| /// descendants. |
| class _DraggableScrollableSheetScrollController extends ScrollController { |
| _DraggableScrollableSheetScrollController({ |
| double initialScrollOffset = 0.0, |
| String? debugLabel, |
| required this.extent, |
| }) : assert(extent != null), |
| super( |
| debugLabel: debugLabel, |
| initialScrollOffset: initialScrollOffset, |
| ); |
| |
| final _DraggableSheetExtent extent; |
| |
| @override |
| _DraggableScrollableSheetScrollPosition createScrollPosition( |
| ScrollPhysics physics, |
| ScrollContext context, |
| ScrollPosition? oldPosition, |
| ) { |
| return _DraggableScrollableSheetScrollPosition( |
| physics: physics, |
| context: context, |
| oldPosition: oldPosition, |
| extent: extent, |
| ); |
| } |
| |
| @override |
| void debugFillDescription(List<String> description) { |
| super.debugFillDescription(description); |
| description.add('extent: $extent'); |
| } |
| } |
| |
| /// A scroll position that manages scroll activities for |
| /// [_DraggableScrollableSheetScrollController]. |
| /// |
| /// This class is a concrete subclass of [ScrollPosition] logic that handles a |
| /// single [ScrollContext], such as a [Scrollable]. An instance of this class |
| /// manages [ScrollActivity] instances, which changes the |
| /// [_DraggableSheetExtent.currentExtent] or visible content offset in the |
| /// [Scrollable]'s [Viewport] |
| /// |
| /// See also: |
| /// |
| /// * [_DraggableScrollableSheetScrollController], which uses this as its [ScrollPosition]. |
| class _DraggableScrollableSheetScrollPosition |
| extends ScrollPositionWithSingleContext { |
| _DraggableScrollableSheetScrollPosition({ |
| required ScrollPhysics physics, |
| required ScrollContext context, |
| double initialPixels = 0.0, |
| bool keepScrollOffset = true, |
| ScrollPosition? oldPosition, |
| String? debugLabel, |
| required this.extent, |
| }) : assert(extent != null), |
| super( |
| physics: physics, |
| context: context, |
| initialPixels: initialPixels, |
| keepScrollOffset: keepScrollOffset, |
| oldPosition: oldPosition, |
| debugLabel: debugLabel, |
| ); |
| |
| VoidCallback? _dragCancelCallback; |
| VoidCallback? _ballisticCancelCallback; |
| final _DraggableSheetExtent extent; |
| bool get listShouldScroll => pixels > 0.0; |
| |
| @override |
| void beginActivity(ScrollActivity? newActivity) { |
| // Cancel the running ballistic simulation, if there is one. |
| _ballisticCancelCallback?.call(); |
| super.beginActivity(newActivity); |
| } |
| |
| @override |
| bool applyContentDimensions(double minScrollExtent, double maxScrollExtent) { |
| // We need to provide some extra extent if we haven't yet reached the max or |
| // min extents. Otherwise, a list with fewer children than the extent of |
| // the available space will get stuck. |
| return super.applyContentDimensions( |
| minScrollExtent - extent.additionalMinExtent, |
| maxScrollExtent + extent.additionalMaxExtent, |
| ); |
| } |
| |
| @override |
| void applyUserOffset(double delta) { |
| if (!listShouldScroll && |
| (!(extent.isAtMin || extent.isAtMax) || |
| (extent.isAtMin && delta < 0) || |
| (extent.isAtMax && delta > 0))) { |
| extent.addPixelDelta(-delta, context.notificationContext!); |
| } else { |
| super.applyUserOffset(delta); |
| } |
| } |
| |
| bool get _isAtSnapSize { |
| return extent.snapSizes.any( |
| (double snapSize) { |
| return (extent.currentExtent - snapSize).abs() <= extent.pixelsToExtent(physics.tolerance.distance); |
| }, |
| ); |
| } |
| bool get _shouldSnap => extent.snap && extent.hasChanged && !_isAtSnapSize; |
| |
| @override |
| void dispose() { |
| // Stop the animation before dispose. |
| _ballisticCancelCallback?.call(); |
| super.dispose(); |
| } |
| |
| @override |
| void goBallistic(double velocity) { |
| if ((velocity == 0.0 && !_shouldSnap) || |
| (velocity < 0.0 && listShouldScroll) || |
| (velocity > 0.0 && extent.isAtMax)) { |
| super.goBallistic(velocity); |
| return; |
| } |
| // Scrollable expects that we will dispose of its current _dragCancelCallback |
| _dragCancelCallback?.call(); |
| _dragCancelCallback = null; |
| |
| late final Simulation simulation; |
| if (extent.snap) { |
| // Snap is enabled, simulate snapping instead of clamping scroll. |
| simulation = _SnappingSimulation( |
| position: extent.currentPixels, |
| initialVelocity: velocity, |
| pixelSnapSize: extent.pixelSnapSizes, |
| tolerance: physics.tolerance); |
| } else { |
| // The iOS bouncing simulation just isn't right here - once we delegate |
| // the ballistic back to the ScrollView, it will use the right simulation. |
| simulation = ClampingScrollSimulation( |
| // Run the simulation in terms of pixels, not extent. |
| position: extent.currentPixels, |
| velocity: velocity, |
| tolerance: physics.tolerance, |
| ); |
| } |
| |
| final AnimationController ballisticController = AnimationController.unbounded( |
| debugLabel: objectRuntimeType(this, '_DraggableScrollableSheetPosition'), |
| vsync: context.vsync, |
| ); |
| // Stop the ballistic animation if a new activity starts. |
| // See: [beginActivity]. |
| _ballisticCancelCallback = ballisticController.stop; |
| double lastPosition = extent.currentPixels; |
| void _tick() { |
| final double delta = ballisticController.value - lastPosition; |
| lastPosition = ballisticController.value; |
| extent.addPixelDelta(delta, context.notificationContext!); |
| if ((velocity > 0 && extent.isAtMax) || (velocity < 0 && extent.isAtMin)) { |
| // Make sure we pass along enough velocity to keep scrolling - otherwise |
| // we just "bounce" off the top making it look like the list doesn't |
| // have more to scroll. |
| velocity = ballisticController.velocity + (physics.tolerance.velocity * ballisticController.velocity.sign); |
| super.goBallistic(velocity); |
| ballisticController.stop(); |
| } else if (ballisticController.isCompleted) { |
| super.goBallistic(0); |
| } |
| } |
| |
| ballisticController |
| ..addListener(_tick) |
| ..animateWith(simulation).whenCompleteOrCancel( |
| () { |
| _ballisticCancelCallback = null; |
| ballisticController.dispose(); |
| }, |
| ); |
| } |
| |
| @override |
| Drag drag(DragStartDetails details, VoidCallback dragCancelCallback) { |
| // Save this so we can call it later if we have to [goBallistic] on our own. |
| _dragCancelCallback = dragCancelCallback; |
| return super.drag(details, dragCancelCallback); |
| } |
| } |
| |
| /// A widget that can notify a descendent [DraggableScrollableSheet] that it |
| /// should reset its position to the initial state. |
| /// |
| /// The [Scaffold] uses this widget to notify a persistent bottom sheet that |
| /// the user has tapped back if the sheet has started to cover more of the body |
| /// than when at its initial position. This is important for users of assistive |
| /// technology, where dragging may be difficult to communicate. |
| class DraggableScrollableActuator extends StatelessWidget { |
| /// Creates a widget that can notify descendent [DraggableScrollableSheet]s |
| /// to reset to their initial position. |
| /// |
| /// The [child] parameter is required. |
| DraggableScrollableActuator({ |
| Key? key, |
| required this.child, |
| }) : super(key: key); |
| |
| /// This child's [DraggableScrollableSheet] descendant will be reset when the |
| /// [reset] method is applied to a context that includes it. |
| /// |
| /// Must not be null. |
| final Widget child; |
| |
| final _ResetNotifier _notifier = _ResetNotifier(); |
| |
| /// Notifies any descendant [DraggableScrollableSheet] that it should reset |
| /// to its initial position. |
| /// |
| /// Returns `true` if a [DraggableScrollableActuator] is available and |
| /// some [DraggableScrollableSheet] is listening for updates, `false` |
| /// otherwise. |
| static bool reset(BuildContext context) { |
| final _InheritedResetNotifier? notifier = context.dependOnInheritedWidgetOfExactType<_InheritedResetNotifier>(); |
| if (notifier == null) { |
| return false; |
| } |
| return notifier._sendReset(); |
| } |
| |
| @override |
| Widget build(BuildContext context) { |
| return _InheritedResetNotifier(notifier: _notifier, child: child); |
| } |
| } |
| |
| /// A [ChangeNotifier] to use with [InheritedResetNotifier] to notify |
| /// descendants that they should reset to initial state. |
| class _ResetNotifier extends ChangeNotifier { |
| /// Whether someone called [sendReset] or not. |
| /// |
| /// This flag should be reset after checking it. |
| bool _wasCalled = false; |
| |
| /// Fires a reset notification to descendants. |
| /// |
| /// Returns false if there are no listeners. |
| bool sendReset() { |
| if (!hasListeners) { |
| return false; |
| } |
| _wasCalled = true; |
| notifyListeners(); |
| return true; |
| } |
| } |
| |
| class _InheritedResetNotifier extends InheritedNotifier<_ResetNotifier> { |
| /// Creates an [InheritedNotifier] that the [DraggableScrollableSheet] will |
| /// listen to for an indication that it should change its extent. |
| /// |
| /// The [child] and [notifier] properties must not be null. |
| const _InheritedResetNotifier({ |
| Key? key, |
| required Widget child, |
| required _ResetNotifier notifier, |
| }) : super(key: key, child: child, notifier: notifier); |
| |
| bool _sendReset() => notifier!.sendReset(); |
| |
| /// Specifies whether the [DraggableScrollableSheet] should reset to its |
| /// initial position. |
| /// |
| /// Returns true if the notifier requested a reset, false otherwise. |
| static bool shouldReset(BuildContext context) { |
| final InheritedWidget? widget = context.dependOnInheritedWidgetOfExactType<_InheritedResetNotifier>(); |
| if (widget == null) { |
| return false; |
| } |
| assert(widget is _InheritedResetNotifier); |
| final _InheritedResetNotifier inheritedNotifier = widget as _InheritedResetNotifier; |
| final bool wasCalled = inheritedNotifier.notifier!._wasCalled; |
| inheritedNotifier.notifier!._wasCalled = false; |
| return wasCalled; |
| } |
| } |
| |
| class _SnappingSimulation extends Simulation { |
| _SnappingSimulation({ |
| required this.position, |
| required double initialVelocity, |
| required List<double> pixelSnapSize, |
| Tolerance tolerance = Tolerance.defaultTolerance, |
| }) : super(tolerance: tolerance) { |
| _pixelSnapSize = _getSnapSize(initialVelocity, pixelSnapSize); |
| // Check the direction of the target instead of the sign of the velocity because |
| // we may snap in the opposite direction of velocity if velocity is very low. |
| if (_pixelSnapSize < position) { |
| velocity = math.min(-minimumSpeed, initialVelocity); |
| } else { |
| velocity = math.max(minimumSpeed, initialVelocity); |
| } |
| } |
| |
| final double position; |
| late final double velocity; |
| |
| // A minimum speed to snap at. Used to ensure that the snapping animation |
| // does not play too slowly. |
| static const double minimumSpeed = 1600.0; |
| |
| late final double _pixelSnapSize; |
| |
| @override |
| double dx(double time) { |
| if (isDone(time)) { |
| return 0; |
| } |
| return velocity; |
| } |
| |
| @override |
| bool isDone(double time) { |
| return x(time) == _pixelSnapSize; |
| } |
| |
| @override |
| double x(double time) { |
| final double newPosition = position + velocity * time; |
| if ((velocity >= 0 && newPosition > _pixelSnapSize) || |
| (velocity < 0 && newPosition < _pixelSnapSize)) { |
| // We're passed the snap size, return it instead. |
| return _pixelSnapSize; |
| } |
| return newPosition; |
| } |
| |
| // Find the two closest snap sizes to the position. If the velocity is |
| // non-zero, select the size in the velocity's direction. Otherwise, |
| // the nearest snap size. |
| double _getSnapSize(double initialVelocity, List<double> pixelSnapSizes) { |
| final int indexOfNextSize = pixelSnapSizes |
| .indexWhere((double size) => size >= position); |
| if (indexOfNextSize == 0) { |
| return pixelSnapSizes.first; |
| } |
| final double nextSize = pixelSnapSizes[indexOfNextSize]; |
| final double previousSize = pixelSnapSizes[indexOfNextSize - 1]; |
| if (initialVelocity.abs() <= tolerance.velocity) { |
| // If velocity is zero, snap to the nearest snap size with the minimum velocity. |
| if (position - previousSize < nextSize - position) { |
| return previousSize; |
| } else { |
| return nextSize; |
| } |
| } |
| // Snap forward or backward depending on current velocity. |
| if (initialVelocity < 0.0) { |
| return pixelSnapSizes[indexOfNextSize - 1]; |
| } |
| return pixelSnapSizes[indexOfNextSize]; |
| } |
| } |