| // 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 'package:flutter/physics.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'; |
| |
| /// 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. |
| class DraggableScrollableController { |
| _DraggableScrollableSheetScrollController? _attachedController; |
| |
| /// 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); |
| } |
| |
| /// 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 [size] to the |
| /// provided new `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]. |
| /// |
| /// 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, |
| ); |
| _attachedController!.position.goIdle(); |
| // This disables any snapping until the next user interaction with the sheet. |
| _attachedController!.extent.hasDragged = false; |
| _attachedController!.extent.startActivity(onCanceled: () { |
| // Don't stop the controller if it's already finished and may have been disposed. |
| if (animationController.isAnimating) { |
| animationController.stop(); |
| } |
| }); |
| CurvedAnimation(parent: animationController, curve: curve).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); |
| } |
| |
| /// 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. |
| 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.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( |
| _attachedController != null, |
| '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; |
| } |
| |
| void _detach() { |
| _attachedController = null; |
| } |
| } |
| |
| /// 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, |
| 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(expand != null), |
| assert(builder != null), |
| super(key: key); |
| |
| /// 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. |
| /// |
| /// 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; |
| |
| /// 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, |
| required this.onSizeChanged, |
| ValueNotifier<double>? currentSize, |
| bool? hasDragged, |
| }) : 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)) |
| ..addListener(onSizeChanged), |
| availablePixels = double.infinity, |
| hasDragged = hasDragged ?? false; |
| |
| VoidCallback? _cancelActivity; |
| |
| final double minSize; |
| final double maxSize; |
| final bool snap; |
| final List<double> snapSizes; |
| final double initialSize; |
| final ValueNotifier<double> _currentSize; |
| final VoidCallback onSizeChanged; |
| double availablePixels; |
| |
| // Used to disable snapping until the user has dragged on the sheet. We do |
| // this because we don't want to snap away from an initial or programmatically set size. |
| bool hasDragged; |
| |
| bool get isAtMin => minSize >= _currentSize.value; |
| bool get isAtMax => maxSize <= _currentSize.value; |
| |
| double get currentSize => _currentSize.value; |
| double get currentPixels => sizeToPixels(_currentSize.value); |
| |
| double get additionalMinSize => isAtMin ? 0.0 : 1.0; |
| double get additionalMaxSize => isAtMax ? 0.0 : 1.0; |
| 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; |
| 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 = newSize.clamp(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; |
| } |
| |
| void dispose() { |
| _currentSize.removeListener(onSizeChanged); |
| } |
| |
| _DraggableSheetExtent copyWith({ |
| required double minSize, |
| required double maxSize, |
| required bool snap, |
| required List<double> snapSizes, |
| required double initialSize, |
| required VoidCallback onSizeChanged, |
| }) { |
| return _DraggableSheetExtent( |
| minSize: minSize, |
| maxSize: maxSize, |
| snap: snap, |
| snapSizes: snapSizes, |
| initialSize: initialSize, |
| onSizeChanged: onSizeChanged, |
| // Use the possibly updated initialSize if the user hasn't dragged yet. |
| currentSize: ValueNotifier<double>(hasDragged |
| ? _currentSize.value.clamp(minSize, maxSize) |
| : initialSize), |
| hasDragged: hasDragged, |
| ); |
| } |
| } |
| |
| 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(), |
| initialSize: widget.initialChildSize, |
| onSizeChanged: _setExtent, |
| ); |
| _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(); |
| } |
| |
| @override |
| void didChangeDependencies() { |
| super.didChangeDependencies(); |
| if (_InheritedResetNotifier.shouldReset(context)) { |
| _scrollController.reset(); |
| } |
| } |
| |
| 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.currentSize, |
| alignment: Alignment.bottomCenter, |
| child: widget.builder(context, _scrollController), |
| ); |
| return widget.expand ? SizedBox.expand(child: sheet) : sheet; |
| }, |
| ); |
| } |
| |
| @override |
| void dispose() { |
| widget.controller?._detach(); |
| _scrollController.dispose(); |
| _extent.dispose(); |
| super.dispose(); |
| } |
| |
| void _replaceExtent() { |
| _extent.dispose(); |
| _extent = _extent.copyWith( |
| minSize: widget.minChildSize, |
| maxSize: widget.maxChildSize, |
| snap: widget.snap, |
| snapSizes: _impliedSnapSizes(), |
| initialSize: widget.initialChildSize, |
| onSizeChanged: _setExtent, |
| ); |
| // 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 (widget.snap) { |
| // Trigger a snap in case snap or snapSizes has changed. 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) { |
| _scrollController.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({ |
| double initialScrollOffset = 0.0, |
| String? debugLabel, |
| required this.extent, |
| }) : assert(extent != null), |
| super( |
| debugLabel: debugLabel, |
| initialScrollOffset: initialScrollOffset, |
| ); |
| |
| _DraggableSheetExtent extent; |
| |
| @override |
| _DraggableScrollableSheetScrollPosition createScrollPosition( |
| ScrollPhysics physics, |
| ScrollContext context, |
| ScrollPosition? oldPosition, |
| ) { |
| return _DraggableScrollableSheetScrollPosition( |
| physics: 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; |
| // 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!); |
| } |
| } |
| |
| /// 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 ScrollPhysics physics, |
| required ScrollContext context, |
| double initialPixels = 0.0, |
| bool keepScrollOffset = true, |
| ScrollPosition? oldPosition, |
| String? debugLabel, |
| required this.getExtent, |
| }) : super( |
| physics: physics, |
| context: context, |
| initialPixels: initialPixels, |
| keepScrollOffset: keepScrollOffset, |
| oldPosition: oldPosition, |
| debugLabel: debugLabel, |
| ); |
| |
| VoidCallback? _dragCancelCallback; |
| VoidCallback? _ballisticCancelCallback; |
| final _DraggableSheetExtent Function() getExtent; |
| bool get listShouldScroll => pixels > 0.0; |
| |
| _DraggableSheetExtent get extent => getExtent(); |
| |
| @override |
| void beginActivity(ScrollActivity? newActivity) { |
| // Cancel the running ballistic simulation, if there is one. |
| _ballisticCancelCallback?.call(); |
| super.beginActivity(newActivity); |
| } |
| |
| @override |
| bool applyContentDimensions(double minScrollSize, double maxScrollSize) { |
| // We need to provide some extra size if we haven't yet reached the max or |
| // min sizes. Otherwise, a list with fewer children than the size of |
| // the available space will get stuck. |
| return super.applyContentDimensions( |
| minScrollSize - extent.additionalMinSize, |
| maxScrollSize + extent.additionalMaxSize, |
| ); |
| } |
| |
| @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() { |
| // 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. |
| /// |
| /// 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({ |
| 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 reset itself back to [DraggableScrollableSheet.initialChildSize]. |
| /// |
| /// 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]; |
| } |
| } |