| // 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 'binding.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'; |
| import 'value_listenable_builder.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, |
| ); |
| |
| /// Controls a [DraggableScrollableSheet]. |
| /// |
| /// Draggable scrollable controllers are typically stored as member variables in |
| /// [State] objects and are reused in each [State.build]. Controllers can only |
| /// be used to control one sheet at a time. A controller can be reused with a |
| /// new sheet if the previous sheet has been disposed. |
| /// |
| /// The controller's methods cannot be used until after the controller has been |
| /// passed into a [DraggableScrollableSheet] and the sheet has run initState. |
| /// |
| /// A [DraggableScrollableController] is a [Listenable]. It notifies its |
| /// listeners whenever an attached sheet changes sizes. It does not notify its |
| /// listeners when a sheet is first attached or when an attached sheet's |
| /// parameters change without affecting the sheet's current size. It does not |
| /// fire when [pixels] changes without [size] changing. For example, if the |
| /// constraints provided to an attached sheet change. |
| class DraggableScrollableController extends ChangeNotifier { |
| _DraggableScrollableSheetScrollController? _attachedController; |
| final Set<AnimationController> _animationControllers = <AnimationController>{}; |
| |
| /// Get the current size (as a fraction of the parent height) of the attached sheet. |
| double get size { |
| _assertAttached(); |
| return _attachedController!.extent.currentSize; |
| } |
| |
| /// Get the current pixel height of the attached sheet. |
| double get pixels { |
| _assertAttached(); |
| return _attachedController!.extent.currentPixels; |
| } |
| |
| /// Convert a sheet's size (fractional value of parent container height) to pixels. |
| double sizeToPixels(double size) { |
| _assertAttached(); |
| return _attachedController!.extent.sizeToPixels(size); |
| } |
| |
| /// Returns Whether any [DraggableScrollableController] objects have attached themselves to the |
| /// [DraggableScrollableSheet]. |
| /// |
| /// If this is false, then members that interact with the [ScrollPosition], |
| /// such as [sizeToPixels], [size], [animateTo], and [jumpTo], must not be |
| /// called. |
| bool get isAttached => _attachedController != null && _attachedController!.hasClients; |
| |
| /// Convert a sheet's pixel height to size (fractional value of parent container height). |
| double pixelsToSize(double pixels) { |
| _assertAttached(); |
| return _attachedController!.extent.pixelsToSize(pixels); |
| } |
| |
| /// Animates the attached sheet from its current size to the given [size], a |
| /// fractional value of the parent container's height. |
| /// |
| /// Any active sheet animation is canceled. If the sheet's internal scrollable |
| /// is currently animating (e.g. responding to a user fling), that animation is |
| /// canceled as well. |
| /// |
| /// An animation will be interrupted whenever the user attempts to scroll |
| /// manually, whenever another activity is started, or when the sheet hits its |
| /// max or min size (e.g. if you animate to 1 but the max size is .8, the |
| /// animation will stop playing when it reaches .8). |
| /// |
| /// The duration must not be zero. To jump to a particular value without an |
| /// animation, use [jumpTo]. |
| /// |
| /// The sheet will not snap after calling [animateTo] even if [DraggableScrollableSheet.snap] |
| /// is true. Snapping only occurs after user drags. |
| /// |
| /// When calling [animateTo] in widget tests, `await`ing the returned |
| /// [Future] may cause the test to hang and timeout. Instead, use |
| /// [WidgetTester.pumpAndSettle]. |
| Future<void> animateTo( |
| double size, { |
| required Duration duration, |
| required Curve curve, |
| }) async { |
| _assertAttached(); |
| assert(size >= 0 && size <= 1); |
| assert(duration != Duration.zero); |
| final AnimationController animationController = AnimationController.unbounded( |
| vsync: _attachedController!.position.context.vsync, |
| value: _attachedController!.extent.currentSize, |
| ); |
| _animationControllers.add(animationController); |
| _attachedController!.position.goIdle(); |
| // This disables any snapping until the next user interaction with the sheet. |
| _attachedController!.extent.hasDragged = false; |
| _attachedController!.extent.hasChanged = true; |
| _attachedController!.extent.startActivity(onCanceled: () { |
| // Don't stop the controller if it's already finished and may have been disposed. |
| if (animationController.isAnimating) { |
| animationController.stop(); |
| } |
| }); |
| animationController.addListener(() { |
| _attachedController!.extent.updateSize( |
| animationController.value, |
| _attachedController!.position.context.notificationContext!, |
| ); |
| if (animationController.value > _attachedController!.extent.maxSize || |
| animationController.value < _attachedController!.extent.minSize) { |
| // Animation hit the max or min size, stop animating. |
| animationController.stop(canceled: false); |
| } |
| }); |
| await animationController.animateTo(size, duration: duration, curve: curve); |
| } |
| |
| /// Jumps the attached sheet from its current size to the given [size], a |
| /// fractional value of the parent container's height. |
| /// |
| /// If [size] is outside of a the attached sheet's min or max child size, |
| /// [jumpTo] will jump the sheet to the nearest valid size instead. |
| /// |
| /// Any active sheet animation is canceled. If the sheet's inner scrollable |
| /// is currently animating (e.g. responding to a user fling), that animation is |
| /// canceled as well. |
| /// |
| /// The sheet will not snap after calling [jumpTo] even if [DraggableScrollableSheet.snap] |
| /// is true. Snapping only occurs after user drags. |
| void jumpTo(double size) { |
| _assertAttached(); |
| assert(size >= 0 && size <= 1); |
| // Call start activity to interrupt any other playing activities. |
| _attachedController!.extent.startActivity(onCanceled: () {}); |
| _attachedController!.position.goIdle(); |
| _attachedController!.extent.hasDragged = false; |
| _attachedController!.extent.hasChanged = true; |
| _attachedController!.extent.updateSize(size, _attachedController!.position.context.notificationContext!); |
| } |
| |
| /// Reset the attached sheet to its initial size (see: [DraggableScrollableSheet.initialChildSize]). |
| void reset() { |
| _assertAttached(); |
| _attachedController!.reset(); |
| } |
| |
| void _assertAttached() { |
| assert( |
| isAttached, |
| 'DraggableScrollableController is not attached to a sheet. A DraggableScrollableController ' |
| 'must be used in a DraggableScrollableSheet before any of its methods are called.', |
| ); |
| } |
| |
| void _attach(_DraggableScrollableSheetScrollController scrollController) { |
| assert(_attachedController == null, 'Draggable scrollable controller is already attached to a sheet.'); |
| _attachedController = scrollController; |
| _attachedController!.extent._currentSize.addListener(notifyListeners); |
| _attachedController!.onPositionDetached = _disposeAnimationControllers; |
| } |
| |
| void _onExtentReplaced(_DraggableSheetExtent previousExtent) { |
| // When the extent has been replaced, the old extent is already disposed and |
| // the controller will point to a new extent. We have to add our listener to |
| // the new extent. |
| _attachedController!.extent._currentSize.addListener(notifyListeners); |
| if (previousExtent.currentSize != _attachedController!.extent.currentSize) { |
| // The listener won't fire for a change in size between two extent |
| // objects so we have to fire it manually here. |
| notifyListeners(); |
| } |
| } |
| |
| void _detach() { |
| _attachedController?.extent._currentSize.removeListener(notifyListeners); |
| _attachedController = null; |
| } |
| |
| void _disposeAnimationControllers() { |
| for (final AnimationController animationController in _animationControllers) { |
| animationController.dispose(); |
| } |
| _animationControllers.clear(); |
| } |
| } |
| |
| /// 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. |
| /// |
| /// The snapping effect is only applied on user drags. Programmatically |
| /// manipulating the sheet size via [DraggableScrollableController.animateTo] or |
| /// [DraggableScrollableController.jumpTo] will ignore [snap] and [snapSizes]. |
| /// |
| /// 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({super.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({ |
| super.key, |
| this.initialChildSize = 0.5, |
| this.minChildSize = 0.25, |
| this.maxChildSize = 1.0, |
| this.expand = true, |
| this.snap = false, |
| this.snapSizes, |
| this.snapAnimationDuration, |
| this.controller, |
| 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(snapAnimationDuration == null || snapAnimationDuration > Duration.zero), |
| assert(expand != null), |
| assert(builder != null); |
| |
| /// The initial fractional value of the parent container's height to use when |
| /// displaying the widget. |
| /// |
| /// Rebuilding the sheet with a new [initialChildSize] will only move the |
| /// the sheet to the new value if the sheet has not yet been dragged since it |
| /// was first built or since the last call to [DraggableScrollableActuator.reset]. |
| /// |
| /// 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. |
| /// |
| /// Snapping is not applied when the sheet is programmatically moved by |
| /// calling [DraggableScrollableController.animateTo] or [DraggableScrollableController.jumpTo]. |
| /// |
| /// Rebuilding the sheet with snap newly enabled will immediately trigger a |
| /// snap unless the sheet has not yet been dragged away from |
| /// [initialChildSize] since first being built or since the last call to |
| /// [DraggableScrollableActuator.reset]. |
| 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]. |
| /// |
| /// Any modifications to the [snapSizes] list will not take effect until the |
| /// `build` function containing this widget is run again. |
| /// |
| /// Rebuilding with a modified or new list will trigger a snap unless the |
| /// sheet has not yet been dragged away from [initialChildSize] since first |
| /// being built or since the last call to [DraggableScrollableActuator.reset]. |
| final List<double>? snapSizes; |
| |
| /// Defines a duration for the snap animations. |
| /// |
| /// If it's not set, then the animation duration is the distance to the snap |
| /// target divided by the velocity of the widget. |
| final Duration? snapAnimationDuration; |
| |
| /// A controller that can be used to programmatically control this sheet. |
| final DraggableScrollableController? controller; |
| |
| /// 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 [currentSize] will never be null. |
| /// The [availablePixels] will never be null, but may be `double.infinity`. |
| class _DraggableSheetExtent { |
| _DraggableSheetExtent({ |
| required this.minSize, |
| required this.maxSize, |
| required this.snap, |
| required this.snapSizes, |
| required this.initialSize, |
| this.snapAnimationDuration, |
| ValueNotifier<double>? currentSize, |
| bool? hasDragged, |
| bool? hasChanged, |
| }) : assert(minSize != null), |
| assert(maxSize != null), |
| assert(initialSize != null), |
| assert(minSize >= 0), |
| assert(maxSize <= 1), |
| assert(minSize <= initialSize), |
| assert(initialSize <= maxSize), |
| _currentSize = currentSize ?? ValueNotifier<double>(initialSize), |
| availablePixels = double.infinity, |
| hasDragged = hasDragged ?? false, |
| hasChanged = hasChanged ?? false; |
| |
| VoidCallback? _cancelActivity; |
| |
| final double minSize; |
| final double maxSize; |
| final bool snap; |
| final List<double> snapSizes; |
| final Duration? snapAnimationDuration; |
| final double initialSize; |
| final ValueNotifier<double> _currentSize; |
| double availablePixels; |
| |
| // Used to disable snapping until the user has dragged on the sheet. |
| bool hasDragged; |
| |
| // Used to determine if the sheet should move to a new initial size when it |
| // changes. |
| // We need both `hasChanged` and `hasDragged` to achieve the following |
| // behavior: |
| // 1. The sheet should only snap following user drags (as opposed to |
| // programmatic sheet changes). See docs for `animateTo` and `jumpTo`. |
| // 2. The sheet should move to a new initial child size on rebuild iff the |
| // sheet has not changed, either by drag or programmatic control. See |
| // docs for `initialChildSize`. |
| bool hasChanged; |
| |
| bool get isAtMin => minSize >= _currentSize.value; |
| bool get isAtMax => maxSize <= _currentSize.value; |
| |
| double get currentSize => _currentSize.value; |
| double get currentPixels => sizeToPixels(_currentSize.value); |
| |
| List<double> get pixelSnapSizes => snapSizes.map(sizeToPixels).toList(); |
| |
| /// Start an activity that affects the sheet and register a cancel call back |
| /// that will be called if another activity starts. |
| /// |
| /// Note that `onCanceled` will get called even if the subsequent activity |
| /// started after this one finished so `onCanceled` should be safe to call at |
| /// any time. |
| void startActivity({required VoidCallback onCanceled}) { |
| _cancelActivity?.call(); |
| _cancelActivity = onCanceled; |
| } |
| |
| /// The scroll position gets inputs in terms of pixels, but the size is |
| /// expected to be expressed as a number between 0..1. |
| /// |
| /// This should only be called to respond to a user drag. To update the |
| /// size in response to a programmatic call, use [updateSize] directly. |
| void addPixelDelta(double delta, BuildContext context) { |
| // Stop any playing sheet animations. |
| _cancelActivity?.call(); |
| _cancelActivity = null; |
| // The user has interacted with the sheet, set `hasDragged` to true so that |
| // we'll snap if applicable. |
| hasDragged = true; |
| hasChanged = true; |
| if (availablePixels == 0) { |
| return; |
| } |
| updateSize(currentSize + pixelsToSize(delta), context); |
| } |
| |
| /// Set the size to the new value. [newSize] should be a number between |
| /// [minSize] and [maxSize]. |
| /// |
| /// This can be triggered by a programmatic (e.g. controller triggered) change |
| /// or a user drag. |
| void updateSize(double newSize, BuildContext context) { |
| assert(newSize != null); |
| _currentSize.value = clampDouble(newSize, minSize, maxSize); |
| DraggableScrollableNotification( |
| minExtent: minSize, |
| maxExtent: maxSize, |
| extent: currentSize, |
| initialExtent: initialSize, |
| context: context, |
| ).dispatch(context); |
| } |
| |
| double pixelsToSize(double pixels) { |
| return pixels / availablePixels * maxSize; |
| } |
| |
| double sizeToPixels(double size) { |
| return size / maxSize * availablePixels; |
| } |
| |
| _DraggableSheetExtent copyWith({ |
| required double minSize, |
| required double maxSize, |
| required bool snap, |
| required List<double> snapSizes, |
| required double initialSize, |
| Duration? snapAnimationDuration, |
| }) { |
| return _DraggableSheetExtent( |
| minSize: minSize, |
| maxSize: maxSize, |
| snap: snap, |
| snapSizes: snapSizes, |
| snapAnimationDuration: snapAnimationDuration, |
| initialSize: initialSize, |
| // Set the current size to the possibly updated initial size if the sheet |
| // hasn't changed yet. |
| currentSize: ValueNotifier<double>(hasChanged |
| ? clampDouble(_currentSize.value, minSize, maxSize) |
| : initialSize), |
| hasDragged: hasDragged, |
| hasChanged: hasChanged, |
| ); |
| } |
| } |
| |
| class _DraggableScrollableSheetState extends State<DraggableScrollableSheet> { |
| late _DraggableScrollableSheetScrollController _scrollController; |
| late _DraggableSheetExtent _extent; |
| |
| @override |
| void initState() { |
| super.initState(); |
| _extent = _DraggableSheetExtent( |
| minSize: widget.minChildSize, |
| maxSize: widget.maxChildSize, |
| snap: widget.snap, |
| snapSizes: _impliedSnapSizes(), |
| snapAnimationDuration: widget.snapAnimationDuration, |
| initialSize: widget.initialChildSize, |
| ); |
| _scrollController = _DraggableScrollableSheetScrollController(extent: _extent); |
| widget.controller?._attach(_scrollController); |
| } |
| |
| 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. '); |
| } |
| // 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 didUpdateWidget(covariant DraggableScrollableSheet oldWidget) { |
| super.didUpdateWidget(oldWidget); |
| _replaceExtent(oldWidget); |
| } |
| |
| @override |
| void didChangeDependencies() { |
| super.didChangeDependencies(); |
| if (_InheritedResetNotifier.shouldReset(context)) { |
| _scrollController.reset(); |
| } |
| } |
| |
| @override |
| Widget build(BuildContext context) { |
| return ValueListenableBuilder<double>( |
| valueListenable: _extent._currentSize, |
| builder: (BuildContext context, double currentSize, Widget? child) => LayoutBuilder( |
| builder: (BuildContext context, BoxConstraints constraints) { |
| _extent.availablePixels = widget.maxChildSize * constraints.biggest.height; |
| final Widget sheet = FractionallySizedBox( |
| heightFactor: currentSize, |
| alignment: Alignment.bottomCenter, |
| child: child, |
| ); |
| return widget.expand ? SizedBox.expand(child: sheet) : sheet; |
| }, |
| ), |
| child: widget.builder(context, _scrollController), |
| ); |
| } |
| |
| @override |
| void dispose() { |
| widget.controller?._detach(); |
| _scrollController.dispose(); |
| super.dispose(); |
| } |
| |
| void _replaceExtent(covariant DraggableScrollableSheet oldWidget) { |
| final _DraggableSheetExtent previousExtent = _extent; |
| _extent = _extent.copyWith( |
| minSize: widget.minChildSize, |
| maxSize: widget.maxChildSize, |
| snap: widget.snap, |
| snapSizes: _impliedSnapSizes(), |
| snapAnimationDuration: widget.snapAnimationDuration, |
| initialSize: widget.initialChildSize, |
| ); |
| // Modify the existing scroll controller instead of replacing it so that |
| // developers listening to the controller do not have to rebuild their listeners. |
| _scrollController.extent = _extent; |
| // If an external facing controller was provided, let it know that the |
| // extent has been replaced. |
| widget.controller?._onExtentReplaced(previousExtent); |
| if (widget.snap |
| && (widget.snap != oldWidget.snap || widget.snapSizes != oldWidget.snapSizes) |
| && _scrollController.hasClients |
| ) { |
| // Trigger a snap in case snap or snapSizes has changed and there is a |
| // scroll position currently attached. We put this in a post frame |
| // callback so that `build` can update `_extent.availablePixels` before |
| // this runs-we can't use the previous extent's available pixels as it may |
| // have changed when the widget was updated. |
| WidgetsBinding.instance.addPostFrameCallback((Duration timeStamp) { |
| for (int index = 0; index < _scrollController.positions.length; index++) { |
| final _DraggableScrollableSheetScrollPosition position = |
| _scrollController.positions.elementAt(index) as _DraggableScrollableSheetScrollPosition; |
| position.goBallistic(0); |
| } |
| }); |
| } |
| } |
| |
| 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({ |
| required this.extent, |
| }) : assert(extent != null); |
| |
| _DraggableSheetExtent extent; |
| VoidCallback? onPositionDetached; |
| |
| @override |
| _DraggableScrollableSheetScrollPosition createScrollPosition( |
| ScrollPhysics physics, |
| ScrollContext context, |
| ScrollPosition? oldPosition, |
| ) { |
| return _DraggableScrollableSheetScrollPosition( |
| physics: const AlwaysScrollableScrollPhysics().applyTo(physics), |
| context: context, |
| oldPosition: oldPosition, |
| getExtent: () => extent, |
| ); |
| } |
| |
| @override |
| void debugFillDescription(List<String> description) { |
| super.debugFillDescription(description); |
| description.add('extent: $extent'); |
| } |
| |
| @override |
| _DraggableScrollableSheetScrollPosition get position => |
| super.position as _DraggableScrollableSheetScrollPosition; |
| |
| void reset() { |
| extent._cancelActivity?.call(); |
| extent.hasDragged = false; |
| extent.hasChanged = false; |
| // 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 (offset != 0.0) { |
| animateTo( |
| 0.0, |
| duration: const Duration(milliseconds: 1), |
| curve: Curves.linear, |
| ); |
| } |
| extent.updateSize(extent.initialSize, position.context.notificationContext!); |
| } |
| |
| @override |
| void detach(ScrollPosition position) { |
| onPositionDetached?.call(); |
| super.detach(position); |
| } |
| } |
| |
| /// 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.currentSize] or visible content offset in the |
| /// [Scrollable]'s [Viewport] |
| /// |
| /// See also: |
| /// |
| /// * [_DraggableScrollableSheetScrollController], which uses this as its [ScrollPosition]. |
| class _DraggableScrollableSheetScrollPosition extends ScrollPositionWithSingleContext { |
| _DraggableScrollableSheetScrollPosition({ |
| required super.physics, |
| required super.context, |
| super.oldPosition, |
| required this.getExtent, |
| }); |
| |
| VoidCallback? _dragCancelCallback; |
| final _DraggableSheetExtent Function() getExtent; |
| final Set<AnimationController> _ballisticControllers = <AnimationController>{}; |
| bool get listShouldScroll => pixels > 0.0; |
| |
| _DraggableSheetExtent get extent => getExtent(); |
| |
| @override |
| void absorb(ScrollPosition other) { |
| super.absorb(other); |
| assert(_dragCancelCallback == null); |
| |
| if (other is! _DraggableScrollableSheetScrollPosition) { |
| return; |
| } |
| |
| if (other._dragCancelCallback != null) { |
| _dragCancelCallback = other._dragCancelCallback; |
| other._dragCancelCallback = null; |
| } |
| } |
| |
| @override |
| void beginActivity(ScrollActivity? newActivity) { |
| // Cancel the running ballistic simulations |
| for (final AnimationController ballisticController in _ballisticControllers) { |
| ballisticController.stop(); |
| } |
| super.beginActivity(newActivity); |
| } |
| |
| @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.currentSize - snapSize).abs() <= extent.pixelsToSize(physics.tolerance.distance); |
| }, |
| ); |
| } |
| bool get _shouldSnap => extent.snap && extent.hasDragged && !_isAtSnapSize; |
| |
| @override |
| void dispose() { |
| for (final AnimationController ballisticController in _ballisticControllers) { |
| ballisticController.dispose(); |
| } |
| _ballisticControllers.clear(); |
| 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, |
| snapAnimationDuration: extent.snapAnimationDuration, |
| 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, |
| ); |
| _ballisticControllers.add(ballisticController); |
| |
| 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( |
| () { |
| if (_ballisticControllers.contains(ballisticController)) { |
| _ballisticControllers.remove(ballisticController); |
| 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. |
| /// |
| /// This is just a wrapper on top of [DraggableScrollableController]. It is |
| /// primarily useful for controlling a sheet in a part of the widget tree that |
| /// the current code does not control (e.g. library code trying to affect a sheet |
| /// in library users' code). Generally, it's easier to control the sheet |
| /// directly by creating a controller and passing the controller to the sheet in |
| /// its constructor (see [DraggableScrollableSheet.controller]). |
| 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({ |
| super.key, |
| required this.child, |
| }); |
| |
| /// 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 reset itself back to [DraggableScrollableSheet.initialChildSize]. |
| /// |
| /// The [child] and [notifier] properties must not be null. |
| const _InheritedResetNotifier({ |
| required super.child, |
| required _ResetNotifier super.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, |
| Duration? snapAnimationDuration, |
| super.tolerance, |
| }) { |
| _pixelSnapSize = _getSnapSize(initialVelocity, pixelSnapSize); |
| |
| if (snapAnimationDuration != null && snapAnimationDuration.inMilliseconds > 0) { |
| velocity = (_pixelSnapSize - position) * 1000 / snapAnimationDuration.inMilliseconds; |
| } |
| // 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. |
| else 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]; |
| } |
| } |