1// Copyright 2014 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import 'dart:math' as math;
6
7import 'package:flutter/foundation.dart';
8import 'package:flutter/gestures.dart';
9
10import 'basic.dart';
11import 'binding.dart';
12import 'framework.dart';
13import 'inherited_notifier.dart';
14import 'layout_builder.dart';
15import 'notification_listener.dart';
16import 'scroll_activity.dart';
17import 'scroll_context.dart';
18import 'scroll_controller.dart';
19import 'scroll_notification.dart';
20import 'scroll_physics.dart';
21import 'scroll_position.dart';
22import 'scroll_position_with_single_context.dart';
23import 'scroll_simulation.dart';
24import 'value_listenable_builder.dart';
25
26/// The signature of a method that provides a [BuildContext] and
27/// [ScrollController] for building a widget that may overflow the draggable
28/// [Axis] of the containing [DraggableScrollableSheet].
29///
30/// Users should apply the [scrollController] to a [ScrollView] subclass, such
31/// as a [SingleChildScrollView], [ListView] or [GridView], to have the whole
32/// sheet be draggable.
33typedef ScrollableWidgetBuilder = Widget Function(
34 BuildContext context,
35 ScrollController scrollController,
36);
37
38/// Controls a [DraggableScrollableSheet].
39///
40/// Draggable scrollable controllers are typically stored as member variables in
41/// [State] objects and are reused in each [State.build]. Controllers can only
42/// be used to control one sheet at a time. A controller can be reused with a
43/// new sheet if the previous sheet has been disposed.
44///
45/// The controller's methods cannot be used until after the controller has been
46/// passed into a [DraggableScrollableSheet] and the sheet has run initState.
47///
48/// A [DraggableScrollableController] is a [Listenable]. It notifies its
49/// listeners whenever an attached sheet changes sizes. It does not notify its
50/// listeners when a sheet is first attached or when an attached sheet's
51/// parameters change without affecting the sheet's current size. It does not
52/// fire when [pixels] changes without [size] changing. For example, if the
53/// constraints provided to an attached sheet change.
54class DraggableScrollableController extends ChangeNotifier {
55 /// Creates a controller for [DraggableScrollableSheet].
56 DraggableScrollableController() {
57 if (kFlutterMemoryAllocationsEnabled) {
58 ChangeNotifier.maybeDispatchObjectCreation(this);
59 }
60 }
61
62 _DraggableScrollableSheetScrollController? _attachedController;
63 final Set<AnimationController> _animationControllers = <AnimationController>{};
64
65 /// Get the current size (as a fraction of the parent height) of the attached sheet.
66 double get size {
67 _assertAttached();
68 return _attachedController!.extent.currentSize;
69 }
70
71 /// Get the current pixel height of the attached sheet.
72 double get pixels {
73 _assertAttached();
74 return _attachedController!.extent.currentPixels;
75 }
76
77 /// Convert a sheet's size (fractional value of parent container height) to pixels.
78 double sizeToPixels(double size) {
79 _assertAttached();
80 return _attachedController!.extent.sizeToPixels(size);
81 }
82
83 /// Returns Whether any [DraggableScrollableController] objects have attached themselves to the
84 /// [DraggableScrollableSheet].
85 ///
86 /// If this is false, then members that interact with the [ScrollPosition],
87 /// such as [sizeToPixels], [size], [animateTo], and [jumpTo], must not be
88 /// called.
89 bool get isAttached => _attachedController != null && _attachedController!.hasClients;
90
91 /// Convert a sheet's pixel height to size (fractional value of parent container height).
92 double pixelsToSize(double pixels) {
93 _assertAttached();
94 return _attachedController!.extent.pixelsToSize(pixels);
95 }
96
97 /// Animates the attached sheet from its current size to the given [size], a
98 /// fractional value of the parent container's height.
99 ///
100 /// Any active sheet animation is canceled. If the sheet's internal scrollable
101 /// is currently animating (e.g. responding to a user fling), that animation is
102 /// canceled as well.
103 ///
104 /// An animation will be interrupted whenever the user attempts to scroll
105 /// manually, whenever another activity is started, or when the sheet hits its
106 /// max or min size (e.g. if you animate to 1 but the max size is .8, the
107 /// animation will stop playing when it reaches .8).
108 ///
109 /// The duration must not be zero. To jump to a particular value without an
110 /// animation, use [jumpTo].
111 ///
112 /// The sheet will not snap after calling [animateTo] even if [DraggableScrollableSheet.snap]
113 /// is true. Snapping only occurs after user drags.
114 ///
115 /// When calling [animateTo] in widget tests, `await`ing the returned
116 /// [Future] may cause the test to hang and timeout. Instead, use
117 /// [WidgetTester.pumpAndSettle].
118 Future<void> animateTo(
119 double size, {
120 required Duration duration,
121 required Curve curve,
122 }) async {
123 _assertAttached();
124 assert(size >= 0 && size <= 1);
125 assert(duration != Duration.zero);
126 final AnimationController animationController = AnimationController.unbounded(
127 vsync: _attachedController!.position.context.vsync,
128 value: _attachedController!.extent.currentSize,
129 );
130 _animationControllers.add(animationController);
131 _attachedController!.position.goIdle();
132 // This disables any snapping until the next user interaction with the sheet.
133 _attachedController!.extent.hasDragged = false;
134 _attachedController!.extent.hasChanged = true;
135 _attachedController!.extent.startActivity(onCanceled: () {
136 // Don't stop the controller if it's already finished and may have been disposed.
137 if (animationController.isAnimating) {
138 animationController.stop();
139 }
140 });
141 animationController.addListener(() {
142 _attachedController!.extent.updateSize(
143 animationController.value,
144 _attachedController!.position.context.notificationContext!,
145 );
146 });
147 await animationController.animateTo(
148 clampDouble(size, _attachedController!.extent.minSize, _attachedController!.extent.maxSize),
149 duration: duration,
150 curve: curve,
151 );
152 }
153
154 /// Jumps the attached sheet from its current size to the given [size], a
155 /// fractional value of the parent container's height.
156 ///
157 /// If [size] is outside of a the attached sheet's min or max child size,
158 /// [jumpTo] will jump the sheet to the nearest valid size instead.
159 ///
160 /// Any active sheet animation is canceled. If the sheet's inner scrollable
161 /// is currently animating (e.g. responding to a user fling), that animation is
162 /// canceled as well.
163 ///
164 /// The sheet will not snap after calling [jumpTo] even if [DraggableScrollableSheet.snap]
165 /// is true. Snapping only occurs after user drags.
166 void jumpTo(double size) {
167 _assertAttached();
168 assert(size >= 0 && size <= 1);
169 // Call start activity to interrupt any other playing activities.
170 _attachedController!.extent.startActivity(onCanceled: () {});
171 _attachedController!.position.goIdle();
172 _attachedController!.extent.hasDragged = false;
173 _attachedController!.extent.hasChanged = true;
174 _attachedController!.extent.updateSize(size, _attachedController!.position.context.notificationContext!);
175 }
176
177 /// Reset the attached sheet to its initial size (see: [DraggableScrollableSheet.initialChildSize]).
178 void reset() {
179 _assertAttached();
180 _attachedController!.reset();
181 }
182
183 void _assertAttached() {
184 assert(
185 isAttached,
186 'DraggableScrollableController is not attached to a sheet. A DraggableScrollableController '
187 'must be used in a DraggableScrollableSheet before any of its methods are called.',
188 );
189 }
190
191 void _attach(_DraggableScrollableSheetScrollController scrollController) {
192 assert(_attachedController == null, 'Draggable scrollable controller is already attached to a sheet.');
193 _attachedController = scrollController;
194 _attachedController!.extent._currentSize.addListener(notifyListeners);
195 _attachedController!.onPositionDetached = _disposeAnimationControllers;
196 }
197
198 void _onExtentReplaced(_DraggableSheetExtent previousExtent) {
199 // When the extent has been replaced, the old extent is already disposed and
200 // the controller will point to a new extent. We have to add our listener to
201 // the new extent.
202 _attachedController!.extent._currentSize.addListener(notifyListeners);
203 if (previousExtent.currentSize != _attachedController!.extent.currentSize) {
204 // The listener won't fire for a change in size between two extent
205 // objects so we have to fire it manually here.
206 notifyListeners();
207 }
208 }
209
210 void _detach({bool disposeExtent = false}) {
211 if (disposeExtent) {
212 _attachedController?.extent.dispose();
213 } else {
214 _attachedController?.extent._currentSize.removeListener(notifyListeners);
215 }
216 _disposeAnimationControllers();
217 _attachedController = null;
218 }
219
220 void _disposeAnimationControllers() {
221 for (final AnimationController animationController in _animationControllers) {
222 animationController.dispose();
223 }
224 _animationControllers.clear();
225 }
226}
227
228/// A container for a [Scrollable] that responds to drag gestures by resizing
229/// the scrollable until a limit is reached, and then scrolling.
230///
231/// {@youtube 560 315 https://www.youtube.com/watch?v=Hgw819mL_78}
232///
233/// This widget can be dragged along the vertical axis between its
234/// [minChildSize], which defaults to `0.25` and [maxChildSize], which defaults
235/// to `1.0`. These sizes are percentages of the height of the parent container.
236///
237/// The widget coordinates resizing and scrolling of the widget returned by
238/// builder as the user drags along the horizontal axis.
239///
240/// The widget will initially be displayed at its initialChildSize which
241/// defaults to `0.5`, meaning half the height of its parent. Dragging will work
242/// between the range of minChildSize and maxChildSize (as percentages of the
243/// parent container's height) as long as the builder creates a widget which
244/// uses the provided [ScrollController]. If the widget created by the
245/// [ScrollableWidgetBuilder] does not use the provided [ScrollController], the
246/// sheet will remain at the initialChildSize.
247///
248/// By default, the widget will stay at whatever size the user drags it to. To
249/// make the widget snap to specific sizes whenever they lift their finger
250/// during a drag, set [snap] to `true`. The sheet will snap between
251/// [minChildSize] and [maxChildSize]. Use [snapSizes] to add more sizes for
252/// the sheet to snap between.
253///
254/// The snapping effect is only applied on user drags. Programmatically
255/// manipulating the sheet size via [DraggableScrollableController.animateTo] or
256/// [DraggableScrollableController.jumpTo] will ignore [snap] and [snapSizes].
257///
258/// By default, the widget will expand its non-occupied area to fill available
259/// space in the parent. If this is not desired, e.g. because the parent wants
260/// to position sheet based on the space it is taking, the [expand] property
261/// may be set to false.
262///
263/// {@tool dartpad}
264///
265/// This is a sample widget which shows a [ListView] that has 25 [ListTile]s.
266/// It starts out as taking up half the body of the [Scaffold], and can be
267/// dragged up to the full height of the scaffold or down to 25% of the height
268/// of the scaffold. Upon reaching full height, the list contents will be
269/// scrolled up or down, until they reach the top of the list again and the user
270/// drags the sheet back down.
271///
272/// On desktop and web running on desktop platforms, dragging to scroll with a mouse is disabled by default
273/// to align with the natural behavior found in other desktop applications.
274///
275/// This behavior is dictated by the [ScrollBehavior], and can be changed by adding
276/// [PointerDeviceKind.mouse] to [ScrollBehavior.dragDevices].
277/// For more info on this, please refer to https://docs.flutter.dev/release/breaking-changes/default-scroll-behavior-drag
278///
279/// Alternatively, this example illustrates how to add a drag handle for desktop applications.
280///
281/// ** See code in examples/api/lib/widgets/draggable_scrollable_sheet/draggable_scrollable_sheet.0.dart **
282/// {@end-tool}
283class DraggableScrollableSheet extends StatefulWidget {
284 /// Creates a widget that can be dragged and scrolled in a single gesture.
285 const DraggableScrollableSheet({
286 super.key,
287 this.initialChildSize = 0.5,
288 this.minChildSize = 0.25,
289 this.maxChildSize = 1.0,
290 this.expand = true,
291 this.snap = false,
292 this.snapSizes,
293 this.snapAnimationDuration,
294 this.controller,
295 this.shouldCloseOnMinExtent = true,
296 required this.builder,
297 }) : assert(minChildSize >= 0.0),
298 assert(maxChildSize <= 1.0),
299 assert(minChildSize <= initialChildSize),
300 assert(initialChildSize <= maxChildSize),
301 assert(snapAnimationDuration == null || snapAnimationDuration > Duration.zero);
302
303 /// The initial fractional value of the parent container's height to use when
304 /// displaying the widget.
305 ///
306 /// Rebuilding the sheet with a new [initialChildSize] will only move
307 /// the sheet to the new value if the sheet has not yet been dragged since it
308 /// was first built or since the last call to [DraggableScrollableActuator.reset].
309 ///
310 /// The default value is `0.5`.
311 final double initialChildSize;
312
313 /// The minimum fractional value of the parent container's height to use when
314 /// displaying the widget.
315 ///
316 /// The default value is `0.25`.
317 final double minChildSize;
318
319 /// The maximum fractional value of the parent container's height to use when
320 /// displaying the widget.
321 ///
322 /// The default value is `1.0`.
323 final double maxChildSize;
324
325 /// Whether the widget should expand to fill the available space in its parent
326 /// or not.
327 ///
328 /// In most cases, this should be true. However, in the case of a parent
329 /// widget that will position this one based on its desired size (such as a
330 /// [Center]), this should be set to false.
331 ///
332 /// The default value is true.
333 final bool expand;
334
335 /// Whether the widget should snap between [snapSizes] when the user lifts
336 /// their finger during a drag.
337 ///
338 /// If the user's finger was still moving when they lifted it, the widget will
339 /// snap to the next snap size (see [snapSizes]) in the direction of the drag.
340 /// If their finger was still, the widget will snap to the nearest snap size.
341 ///
342 /// Snapping is not applied when the sheet is programmatically moved by
343 /// calling [DraggableScrollableController.animateTo] or [DraggableScrollableController.jumpTo].
344 ///
345 /// Rebuilding the sheet with snap newly enabled will immediately trigger a
346 /// snap unless the sheet has not yet been dragged away from
347 /// [initialChildSize] since first being built or since the last call to
348 /// [DraggableScrollableActuator.reset].
349 final bool snap;
350
351 /// A list of target sizes that the widget should snap to.
352 ///
353 /// Snap sizes are fractional values of the parent container's height. They
354 /// must be listed in increasing order and be between [minChildSize] and
355 /// [maxChildSize].
356 ///
357 /// The [minChildSize] and [maxChildSize] are implicitly included in snap
358 /// sizes and do not need to be specified here. For example, `snapSizes = [.5]`
359 /// will result in a sheet that snaps between [minChildSize], `.5`, and
360 /// [maxChildSize].
361 ///
362 /// Any modifications to the [snapSizes] list will not take effect until the
363 /// `build` function containing this widget is run again.
364 ///
365 /// Rebuilding with a modified or new list will trigger a snap unless the
366 /// sheet has not yet been dragged away from [initialChildSize] since first
367 /// being built or since the last call to [DraggableScrollableActuator.reset].
368 final List<double>? snapSizes;
369
370 /// Defines a duration for the snap animations.
371 ///
372 /// If it's not set, then the animation duration is the distance to the snap
373 /// target divided by the velocity of the widget.
374 final Duration? snapAnimationDuration;
375
376 /// A controller that can be used to programmatically control this sheet.
377 final DraggableScrollableController? controller;
378
379 /// Whether the sheet, when dragged (or flung) to its minimum size, should
380 /// cause its parent sheet to close.
381 ///
382 /// Set on emitted [DraggableScrollableNotification]s. It is up to parent
383 /// classes to properly read and handle this value.
384 final bool shouldCloseOnMinExtent;
385
386 /// The builder that creates a child to display in this widget, which will
387 /// use the provided [ScrollController] to enable dragging and scrolling
388 /// of the contents.
389 final ScrollableWidgetBuilder builder;
390
391 @override
392 State<DraggableScrollableSheet> createState() => _DraggableScrollableSheetState();
393}
394
395/// A [Notification] related to the extent, which is the size, and scroll
396/// offset, which is the position of the child list, of the
397/// [DraggableScrollableSheet].
398///
399/// [DraggableScrollableSheet] widgets notify their ancestors when the size of
400/// the sheet changes. When the extent of the sheet changes via a drag,
401/// this notification bubbles up through the tree, which means a given
402/// [NotificationListener] will receive notifications for all descendant
403/// [DraggableScrollableSheet] widgets. To focus on notifications from the
404/// nearest [DraggableScrollableSheet] descendant, check that the [depth]
405/// property of the notification is zero.
406///
407/// When an extent notification is received by a [NotificationListener], the
408/// listener will already have completed build and layout, and it is therefore
409/// too late for that widget to call [State.setState]. Any attempt to adjust the
410/// build or layout based on an extent notification would result in a layout
411/// that lagged one frame behind, which is a poor user experience. Extent
412/// notifications are used primarily to drive animations. The [Scaffold] widget
413/// listens for extent notifications and responds by driving animations for the
414/// [FloatingActionButton] as the bottom sheet scrolls up.
415class DraggableScrollableNotification extends Notification with ViewportNotificationMixin {
416 /// Creates a notification that the extent of a [DraggableScrollableSheet] has
417 /// changed.
418 ///
419 /// All parameters are required. The [minExtent] must be >= 0. The [maxExtent]
420 /// must be <= 1.0. The [extent] must be between [minExtent] and [maxExtent].
421 DraggableScrollableNotification({
422 required this.extent,
423 required this.minExtent,
424 required this.maxExtent,
425 required this.initialExtent,
426 required this.context,
427 this.shouldCloseOnMinExtent = true,
428 }) : assert(0.0 <= minExtent),
429 assert(maxExtent <= 1.0),
430 assert(minExtent <= extent),
431 assert(minExtent <= initialExtent),
432 assert(extent <= maxExtent),
433 assert(initialExtent <= maxExtent);
434
435 /// The current value of the extent, between [minExtent] and [maxExtent].
436 final double extent;
437
438 /// The minimum value of [extent], which is >= 0.
439 final double minExtent;
440
441 /// The maximum value of [extent].
442 final double maxExtent;
443
444 /// The initially requested value for [extent].
445 final double initialExtent;
446
447 /// The build context of the widget that fired this notification.
448 ///
449 /// This can be used to find the sheet's render objects to determine the size
450 /// of the viewport, for instance. A listener can only assume this context
451 /// is live when it first gets the notification.
452 final BuildContext context;
453
454 /// Whether the widget that fired this notification, when dragged (or flung)
455 /// to minExtent, should cause its parent sheet to close.
456 ///
457 /// It is up to parent classes to properly read and handle this value.
458 final bool shouldCloseOnMinExtent;
459
460 @override
461 void debugFillDescription(List<String> description) {
462 super.debugFillDescription(description);
463 description.add('minExtent: $minExtent, extent: $extent, maxExtent: $maxExtent, initialExtent: $initialExtent');
464 }
465}
466
467/// Manages state between [_DraggableScrollableSheetState],
468/// [_DraggableScrollableSheetScrollController], and
469/// [_DraggableScrollableSheetScrollPosition].
470///
471/// The State knows the pixels available along the axis the widget wants to
472/// scroll, but expects to get a fraction of those pixels to render the sheet.
473///
474/// The ScrollPosition knows the number of pixels a user wants to move the sheet.
475///
476/// The [currentSize] will never be null.
477/// The [availablePixels] will never be null, but may be `double.infinity`.
478class _DraggableSheetExtent {
479 _DraggableSheetExtent({
480 required this.minSize,
481 required this.maxSize,
482 required this.snap,
483 required this.snapSizes,
484 required this.initialSize,
485 this.snapAnimationDuration,
486 ValueNotifier<double>? currentSize,
487 bool? hasDragged,
488 bool? hasChanged,
489 this.shouldCloseOnMinExtent = true,
490 }) : assert(minSize >= 0),
491 assert(maxSize <= 1),
492 assert(minSize <= initialSize),
493 assert(initialSize <= maxSize),
494 _currentSize = currentSize ?? ValueNotifier<double>(initialSize),
495 availablePixels = double.infinity,
496 hasDragged = hasDragged ?? false,
497 hasChanged = hasChanged ?? false;
498
499 VoidCallback? _cancelActivity;
500
501 final double minSize;
502 final double maxSize;
503 final bool snap;
504 final List<double> snapSizes;
505 final Duration? snapAnimationDuration;
506 final double initialSize;
507 final bool shouldCloseOnMinExtent;
508 final ValueNotifier<double> _currentSize;
509 double availablePixels;
510
511 // Used to disable snapping until the user has dragged on the sheet.
512 bool hasDragged;
513
514 // Used to determine if the sheet should move to a new initial size when it
515 // changes.
516 // We need both `hasChanged` and `hasDragged` to achieve the following
517 // behavior:
518 // 1. The sheet should only snap following user drags (as opposed to
519 // programmatic sheet changes). See docs for `animateTo` and `jumpTo`.
520 // 2. The sheet should move to a new initial child size on rebuild iff the
521 // sheet has not changed, either by drag or programmatic control. See
522 // docs for `initialChildSize`.
523 bool hasChanged;
524
525 bool get isAtMin => minSize >= _currentSize.value;
526 bool get isAtMax => maxSize <= _currentSize.value;
527
528 double get currentSize => _currentSize.value;
529 double get currentPixels => sizeToPixels(_currentSize.value);
530
531 List<double> get pixelSnapSizes => snapSizes.map(sizeToPixels).toList();
532
533 /// Start an activity that affects the sheet and register a cancel call back
534 /// that will be called if another activity starts.
535 ///
536 /// The `onCanceled` callback will get called even if the subsequent activity
537 /// started after this one finished, so `onCanceled` must be safe to call at
538 /// any time.
539 void startActivity({required VoidCallback onCanceled}) {
540 _cancelActivity?.call();
541 _cancelActivity = onCanceled;
542 }
543
544 /// The scroll position gets inputs in terms of pixels, but the size is
545 /// expected to be expressed as a number between 0..1.
546 ///
547 /// This should only be called to respond to a user drag. To update the
548 /// size in response to a programmatic call, use [updateSize] directly.
549 void addPixelDelta(double delta, BuildContext context) {
550 // Stop any playing sheet animations.
551 _cancelActivity?.call();
552 _cancelActivity = null;
553 // The user has interacted with the sheet, set `hasDragged` to true so that
554 // we'll snap if applicable.
555 hasDragged = true;
556 hasChanged = true;
557 if (availablePixels == 0) {
558 return;
559 }
560 updateSize(currentSize + pixelsToSize(delta), context);
561 }
562
563 /// Set the size to the new value. [newSize] should be a number between
564 /// [minSize] and [maxSize].
565 ///
566 /// This can be triggered by a programmatic (e.g. controller triggered) change
567 /// or a user drag.
568 void updateSize(double newSize, BuildContext context) {
569 final double clampedSize = clampDouble(newSize, minSize, maxSize);
570 if (_currentSize.value == clampedSize) {
571 return;
572 }
573 _currentSize.value = clampedSize;
574 DraggableScrollableNotification(
575 minExtent: minSize,
576 maxExtent: maxSize,
577 extent: currentSize,
578 initialExtent: initialSize,
579 context: context,
580 shouldCloseOnMinExtent: shouldCloseOnMinExtent,
581 ).dispatch(context);
582 }
583
584 double pixelsToSize(double pixels) {
585 return pixels / availablePixels * maxSize;
586 }
587
588 double sizeToPixels(double size) {
589 return size / maxSize * availablePixels;
590 }
591
592 void dispose() {
593 _currentSize.dispose();
594 }
595
596 _DraggableSheetExtent copyWith({
597 required double minSize,
598 required double maxSize,
599 required bool snap,
600 required List<double> snapSizes,
601 required double initialSize,
602 Duration? snapAnimationDuration,
603 bool shouldCloseOnMinExtent = true,
604 }) {
605 return _DraggableSheetExtent(
606 minSize: minSize,
607 maxSize: maxSize,
608 snap: snap,
609 snapSizes: snapSizes,
610 snapAnimationDuration: snapAnimationDuration,
611 initialSize: initialSize,
612 // Set the current size to the possibly updated initial size if the sheet
613 // hasn't changed yet.
614 currentSize: ValueNotifier<double>(hasChanged
615 ? clampDouble(_currentSize.value, minSize, maxSize)
616 : initialSize),
617 hasDragged: hasDragged,
618 hasChanged: hasChanged,
619 shouldCloseOnMinExtent: shouldCloseOnMinExtent,
620 );
621 }
622}
623
624class _DraggableScrollableSheetState extends State<DraggableScrollableSheet> {
625 late _DraggableScrollableSheetScrollController _scrollController;
626 late _DraggableSheetExtent _extent;
627
628 @override
629 void initState() {
630 super.initState();
631 _extent = _DraggableSheetExtent(
632 minSize: widget.minChildSize,
633 maxSize: widget.maxChildSize,
634 snap: widget.snap,
635 snapSizes: _impliedSnapSizes(),
636 snapAnimationDuration: widget.snapAnimationDuration,
637 initialSize: widget.initialChildSize,
638 shouldCloseOnMinExtent: widget.shouldCloseOnMinExtent,
639 );
640 _scrollController = _DraggableScrollableSheetScrollController(extent: _extent);
641 widget.controller?._attach(_scrollController);
642 }
643
644 List<double> _impliedSnapSizes() {
645 for (int index = 0; index < (widget.snapSizes?.length ?? 0); index += 1) {
646 final double snapSize = widget.snapSizes![index];
647 assert(snapSize >= widget.minChildSize && snapSize <= widget.maxChildSize,
648 '${_snapSizeErrorMessage(index)}\nSnap sizes must be between `minChildSize` and `maxChildSize`. ');
649 assert(index == 0 || snapSize > widget.snapSizes![index - 1],
650 '${_snapSizeErrorMessage(index)}\nSnap sizes must be in ascending order. ');
651 }
652 // Ensure the snap sizes start and end with the min and max child sizes.
653 if (widget.snapSizes == null || widget.snapSizes!.isEmpty) {
654 return <double>[
655 widget.minChildSize,
656 widget.maxChildSize,
657 ];
658 }
659 return <double>[
660 if (widget.snapSizes!.first != widget.minChildSize) widget.minChildSize,
661 ...widget.snapSizes!,
662 if (widget.snapSizes!.last != widget.maxChildSize) widget.maxChildSize,
663 ];
664 }
665
666 @override
667 void didUpdateWidget(covariant DraggableScrollableSheet oldWidget) {
668 super.didUpdateWidget(oldWidget);
669 if (widget.controller != oldWidget.controller) {
670 oldWidget.controller?._detach();
671 widget.controller?._attach(_scrollController);
672 }
673 _replaceExtent(oldWidget);
674 }
675
676 @override
677 void didChangeDependencies() {
678 super.didChangeDependencies();
679 if (_InheritedResetNotifier.shouldReset(context)) {
680 _scrollController.reset();
681 }
682 }
683
684 @override
685 Widget build(BuildContext context) {
686 return ValueListenableBuilder<double>(
687 valueListenable: _extent._currentSize,
688 builder: (BuildContext context, double currentSize, Widget? child) => LayoutBuilder(
689 builder: (BuildContext context, BoxConstraints constraints) {
690 _extent.availablePixels = widget.maxChildSize * constraints.biggest.height;
691 final Widget sheet = FractionallySizedBox(
692 heightFactor: currentSize,
693 alignment: Alignment.bottomCenter,
694 child: child,
695 );
696 return widget.expand ? SizedBox.expand(child: sheet) : sheet;
697 },
698 ),
699 child: widget.builder(context, _scrollController),
700 );
701 }
702
703 @override
704 void dispose() {
705 if (widget.controller == null) {
706 _extent.dispose();
707 } else {
708 widget.controller!._detach(disposeExtent: true);
709 }
710 _scrollController.dispose();
711 super.dispose();
712 }
713
714 void _replaceExtent(covariant DraggableScrollableSheet oldWidget) {
715 final _DraggableSheetExtent previousExtent = _extent;
716 _extent = previousExtent.copyWith(
717 minSize: widget.minChildSize,
718 maxSize: widget.maxChildSize,
719 snap: widget.snap,
720 snapSizes: _impliedSnapSizes(),
721 snapAnimationDuration: widget.snapAnimationDuration,
722 initialSize: widget.initialChildSize,
723 );
724 // Modify the existing scroll controller instead of replacing it so that
725 // developers listening to the controller do not have to rebuild their listeners.
726 _scrollController.extent = _extent;
727 // If an external facing controller was provided, let it know that the
728 // extent has been replaced.
729 widget.controller?._onExtentReplaced(previousExtent);
730 previousExtent.dispose();
731 if (widget.snap
732 && (widget.snap != oldWidget.snap || widget.snapSizes != oldWidget.snapSizes)
733 && _scrollController.hasClients
734 ) {
735 // Trigger a snap in case snap or snapSizes has changed and there is a
736 // scroll position currently attached. We put this in a post frame
737 // callback so that `build` can update `_extent.availablePixels` before
738 // this runs-we can't use the previous extent's available pixels as it may
739 // have changed when the widget was updated.
740 WidgetsBinding.instance.addPostFrameCallback((Duration timeStamp) {
741 for (int index = 0; index < _scrollController.positions.length; index++) {
742 final _DraggableScrollableSheetScrollPosition position =
743 _scrollController.positions.elementAt(index) as _DraggableScrollableSheetScrollPosition;
744 position.goBallistic(0);
745 }
746 }, debugLabel: 'DraggableScrollableSheet.snap');
747 }
748 }
749
750 String _snapSizeErrorMessage(int invalidIndex) {
751 final List<String> snapSizesWithIndicator = widget.snapSizes!.asMap().keys.map(
752 (int index) {
753 final String snapSizeString = widget.snapSizes![index].toString();
754 if (index == invalidIndex) {
755 return '>>> $snapSizeString <<<';
756 }
757 return snapSizeString;
758 },
759 ).toList();
760 return "Invalid snapSize '${widget.snapSizes![invalidIndex]}' at index $invalidIndex of:\n"
761 ' $snapSizesWithIndicator';
762 }
763}
764
765/// A [ScrollController] suitable for use in a [ScrollableWidgetBuilder] created
766/// by a [DraggableScrollableSheet].
767///
768/// If a [DraggableScrollableSheet] contains content that is exceeds the height
769/// of its container, this controller will allow the sheet to both be dragged to
770/// fill the container and then scroll the child content.
771///
772/// See also:
773///
774/// * [_DraggableScrollableSheetScrollPosition], which manages the positioning logic for
775/// this controller.
776/// * [PrimaryScrollController], which can be used to establish a
777/// [_DraggableScrollableSheetScrollController] as the primary controller for
778/// descendants.
779class _DraggableScrollableSheetScrollController extends ScrollController {
780 _DraggableScrollableSheetScrollController({
781 required this.extent,
782 });
783
784 _DraggableSheetExtent extent;
785 VoidCallback? onPositionDetached;
786
787 @override
788 _DraggableScrollableSheetScrollPosition createScrollPosition(
789 ScrollPhysics physics,
790 ScrollContext context,
791 ScrollPosition? oldPosition,
792 ) {
793 return _DraggableScrollableSheetScrollPosition(
794 physics: physics.applyTo(const AlwaysScrollableScrollPhysics()),
795 context: context,
796 oldPosition: oldPosition,
797 getExtent: () => extent,
798 );
799 }
800
801 @override
802 void debugFillDescription(List<String> description) {
803 super.debugFillDescription(description);
804 description.add('extent: $extent');
805 }
806
807 @override
808 _DraggableScrollableSheetScrollPosition get position =>
809 super.position as _DraggableScrollableSheetScrollPosition;
810
811 void reset() {
812 extent._cancelActivity?.call();
813 extent.hasDragged = false;
814 extent.hasChanged = false;
815 // jumpTo can result in trying to replace semantics during build.
816 // Just animate really fast.
817 // Avoid doing it at all if the offset is already 0.0.
818 if (offset != 0.0) {
819 animateTo(
820 0.0,
821 duration: const Duration(milliseconds: 1),
822 curve: Curves.linear,
823 );
824 }
825 extent.updateSize(extent.initialSize, position.context.notificationContext!);
826 }
827
828 @override
829 void detach(ScrollPosition position) {
830 onPositionDetached?.call();
831 super.detach(position);
832 }
833}
834
835/// A scroll position that manages scroll activities for
836/// [_DraggableScrollableSheetScrollController].
837///
838/// This class is a concrete subclass of [ScrollPosition] logic that handles a
839/// single [ScrollContext], such as a [Scrollable]. An instance of this class
840/// manages [ScrollActivity] instances, which changes the
841/// [_DraggableSheetExtent.currentSize] or visible content offset in the
842/// [Scrollable]'s [Viewport]
843///
844/// See also:
845///
846/// * [_DraggableScrollableSheetScrollController], which uses this as its [ScrollPosition].
847class _DraggableScrollableSheetScrollPosition extends ScrollPositionWithSingleContext {
848 _DraggableScrollableSheetScrollPosition({
849 required super.physics,
850 required super.context,
851 super.oldPosition,
852 required this.getExtent,
853 });
854
855 VoidCallback? _dragCancelCallback;
856 final _DraggableSheetExtent Function() getExtent;
857 final Set<AnimationController> _ballisticControllers = <AnimationController>{};
858 bool get listShouldScroll => pixels > 0.0;
859
860 _DraggableSheetExtent get extent => getExtent();
861
862 @override
863 void absorb(ScrollPosition other) {
864 super.absorb(other);
865 assert(_dragCancelCallback == null);
866
867 if (other is! _DraggableScrollableSheetScrollPosition) {
868 return;
869 }
870
871 if (other._dragCancelCallback != null) {
872 _dragCancelCallback = other._dragCancelCallback;
873 other._dragCancelCallback = null;
874 }
875 }
876
877 @override
878 void beginActivity(ScrollActivity? newActivity) {
879 // Cancel the running ballistic simulations
880 for (final AnimationController ballisticController in _ballisticControllers) {
881 ballisticController.stop();
882 }
883 super.beginActivity(newActivity);
884 }
885
886 @override
887 void applyUserOffset(double delta) {
888 if (!listShouldScroll &&
889 (!(extent.isAtMin || extent.isAtMax) ||
890 (extent.isAtMin && delta < 0) ||
891 (extent.isAtMax && delta > 0))) {
892 extent.addPixelDelta(-delta, context.notificationContext!);
893 } else {
894 super.applyUserOffset(delta);
895 }
896 }
897
898 bool get _isAtSnapSize {
899 return extent.snapSizes.any(
900 (double snapSize) {
901 return (extent.currentSize - snapSize).abs() <= extent.pixelsToSize(physics.toleranceFor(this).distance);
902 },
903 );
904 }
905 bool get _shouldSnap => extent.snap && extent.hasDragged && !_isAtSnapSize;
906
907 @override
908 void dispose() {
909 for (final AnimationController ballisticController in _ballisticControllers) {
910 ballisticController.dispose();
911 }
912 _ballisticControllers.clear();
913 super.dispose();
914 }
915
916 @override
917 void goBallistic(double velocity) {
918 if ((velocity == 0.0 && !_shouldSnap) ||
919 (velocity < 0.0 && listShouldScroll) ||
920 (velocity > 0.0 && extent.isAtMax)) {
921 super.goBallistic(velocity);
922 return;
923 }
924 // Scrollable expects that we will dispose of its current _dragCancelCallback
925 _dragCancelCallback?.call();
926 _dragCancelCallback = null;
927
928 late final Simulation simulation;
929 if (extent.snap) {
930 // Snap is enabled, simulate snapping instead of clamping scroll.
931 simulation = _SnappingSimulation(
932 position: extent.currentPixels,
933 initialVelocity: velocity,
934 pixelSnapSize: extent.pixelSnapSizes,
935 snapAnimationDuration: extent.snapAnimationDuration,
936 tolerance: physics.toleranceFor(this),
937 );
938 } else {
939 // The iOS bouncing simulation just isn't right here - once we delegate
940 // the ballistic back to the ScrollView, it will use the right simulation.
941 simulation = ClampingScrollSimulation(
942 // Run the simulation in terms of pixels, not extent.
943 position: extent.currentPixels,
944 velocity: velocity,
945 tolerance: physics.toleranceFor(this),
946 );
947 }
948
949 final AnimationController ballisticController = AnimationController.unbounded(
950 debugLabel: objectRuntimeType(this, '_DraggableScrollableSheetPosition'),
951 vsync: context.vsync,
952 );
953 _ballisticControllers.add(ballisticController);
954
955 double lastPosition = extent.currentPixels;
956 void tick() {
957 final double delta = ballisticController.value - lastPosition;
958 lastPosition = ballisticController.value;
959 extent.addPixelDelta(delta, context.notificationContext!);
960 if ((velocity > 0 && extent.isAtMax) || (velocity < 0 && extent.isAtMin)) {
961 // Make sure we pass along enough velocity to keep scrolling - otherwise
962 // we just "bounce" off the top making it look like the list doesn't
963 // have more to scroll.
964 velocity = ballisticController.velocity + (physics.toleranceFor(this).velocity * ballisticController.velocity.sign);
965 super.goBallistic(velocity);
966 ballisticController.stop();
967 } else if (ballisticController.isCompleted) {
968 super.goBallistic(0);
969 }
970 }
971
972 ballisticController
973 ..addListener(tick)
974 ..animateWith(simulation).whenCompleteOrCancel(
975 () {
976 if (_ballisticControllers.contains(ballisticController)) {
977 _ballisticControllers.remove(ballisticController);
978 ballisticController.dispose();
979 }
980 },
981 );
982 }
983
984 @override
985 Drag drag(DragStartDetails details, VoidCallback dragCancelCallback) {
986 // Save this so we can call it later if we have to [goBallistic] on our own.
987 _dragCancelCallback = dragCancelCallback;
988 return super.drag(details, dragCancelCallback);
989 }
990}
991
992/// A widget that can notify a descendent [DraggableScrollableSheet] that it
993/// should reset its position to the initial state.
994///
995/// The [Scaffold] uses this widget to notify a persistent bottom sheet that
996/// the user has tapped back if the sheet has started to cover more of the body
997/// than when at its initial position. This is important for users of assistive
998/// technology, where dragging may be difficult to communicate.
999///
1000/// This is just a wrapper on top of [DraggableScrollableController]. It is
1001/// primarily useful for controlling a sheet in a part of the widget tree that
1002/// the current code does not control (e.g. library code trying to affect a sheet
1003/// in library users' code). Generally, it's easier to control the sheet
1004/// directly by creating a controller and passing the controller to the sheet in
1005/// its constructor (see [DraggableScrollableSheet.controller]).
1006class DraggableScrollableActuator extends StatefulWidget {
1007 /// Creates a widget that can notify descendent [DraggableScrollableSheet]s
1008 /// to reset to their initial position.
1009 ///
1010 /// The [child] parameter is required.
1011 const DraggableScrollableActuator({
1012 super.key,
1013 required this.child,
1014 });
1015
1016 /// This child's [DraggableScrollableSheet] descendant will be reset when the
1017 /// [reset] method is applied to a context that includes it.
1018 final Widget child;
1019
1020
1021 /// Notifies any descendant [DraggableScrollableSheet] that it should reset
1022 /// to its initial position.
1023 ///
1024 /// Returns `true` if a [DraggableScrollableActuator] is available and
1025 /// some [DraggableScrollableSheet] is listening for updates, `false`
1026 /// otherwise.
1027 static bool reset(BuildContext context) {
1028 final _InheritedResetNotifier? notifier = context.dependOnInheritedWidgetOfExactType<_InheritedResetNotifier>();
1029 if (notifier == null) {
1030 return false;
1031 }
1032 return notifier._sendReset();
1033 }
1034
1035 @override
1036 State<DraggableScrollableActuator> createState() => _DraggableScrollableActuatorState();
1037}
1038
1039class _DraggableScrollableActuatorState extends State<DraggableScrollableActuator> {
1040 final _ResetNotifier _notifier = _ResetNotifier();
1041
1042 @override
1043 Widget build(BuildContext context) {
1044 return _InheritedResetNotifier(notifier: _notifier, child: widget.child);
1045 }
1046
1047 @override
1048 void dispose() {
1049 _notifier.dispose();
1050 super.dispose();
1051 }
1052}
1053
1054/// A [ChangeNotifier] to use with [InheritedResetNotifier] to notify
1055/// descendants that they should reset to initial state.
1056class _ResetNotifier extends ChangeNotifier {
1057 _ResetNotifier() {
1058 if (kFlutterMemoryAllocationsEnabled) {
1059 ChangeNotifier.maybeDispatchObjectCreation(this);
1060 }
1061 }
1062 /// Whether someone called [sendReset] or not.
1063 ///
1064 /// This flag should be reset after checking it.
1065 bool _wasCalled = false;
1066
1067 /// Fires a reset notification to descendants.
1068 ///
1069 /// Returns false if there are no listeners.
1070 bool sendReset() {
1071 if (!hasListeners) {
1072 return false;
1073 }
1074 _wasCalled = true;
1075 notifyListeners();
1076 return true;
1077 }
1078}
1079
1080class _InheritedResetNotifier extends InheritedNotifier<_ResetNotifier> {
1081 /// Creates an [InheritedNotifier] that the [DraggableScrollableSheet] will
1082 /// listen to for an indication that it should reset itself back to [DraggableScrollableSheet.initialChildSize].
1083 const _InheritedResetNotifier({
1084 required super.child,
1085 required _ResetNotifier super.notifier,
1086 });
1087
1088 bool _sendReset() => notifier!.sendReset();
1089
1090 /// Specifies whether the [DraggableScrollableSheet] should reset to its
1091 /// initial position.
1092 ///
1093 /// Returns true if the notifier requested a reset, false otherwise.
1094 static bool shouldReset(BuildContext context) {
1095 final InheritedWidget? widget = context.dependOnInheritedWidgetOfExactType<_InheritedResetNotifier>();
1096 if (widget == null) {
1097 return false;
1098 }
1099 assert(widget is _InheritedResetNotifier);
1100 final _InheritedResetNotifier inheritedNotifier = widget as _InheritedResetNotifier;
1101 final bool wasCalled = inheritedNotifier.notifier!._wasCalled;
1102 inheritedNotifier.notifier!._wasCalled = false;
1103 return wasCalled;
1104 }
1105}
1106
1107class _SnappingSimulation extends Simulation {
1108 _SnappingSimulation({
1109 required this.position,
1110 required double initialVelocity,
1111 required List<double> pixelSnapSize,
1112 Duration? snapAnimationDuration,
1113 super.tolerance,
1114 }) {
1115 _pixelSnapSize = _getSnapSize(initialVelocity, pixelSnapSize);
1116
1117 if (snapAnimationDuration != null && snapAnimationDuration.inMilliseconds > 0) {
1118 velocity = (_pixelSnapSize - position) * 1000 / snapAnimationDuration.inMilliseconds;
1119 }
1120 // Check the direction of the target instead of the sign of the velocity because
1121 // we may snap in the opposite direction of velocity if velocity is very low.
1122 else if (_pixelSnapSize < position) {
1123 velocity = math.min(-minimumSpeed, initialVelocity);
1124 } else {
1125 velocity = math.max(minimumSpeed, initialVelocity);
1126 }
1127 }
1128
1129 final double position;
1130 late final double velocity;
1131
1132 // A minimum speed to snap at. Used to ensure that the snapping animation
1133 // does not play too slowly.
1134 static const double minimumSpeed = 1600.0;
1135
1136 late final double _pixelSnapSize;
1137
1138 @override
1139 double dx(double time) {
1140 if (isDone(time)) {
1141 return 0;
1142 }
1143 return velocity;
1144 }
1145
1146 @override
1147 bool isDone(double time) {
1148 return x(time) == _pixelSnapSize;
1149 }
1150
1151 @override
1152 double x(double time) {
1153 final double newPosition = position + velocity * time;
1154 if ((velocity >= 0 && newPosition > _pixelSnapSize) ||
1155 (velocity < 0 && newPosition < _pixelSnapSize)) {
1156 // We're passed the snap size, return it instead.
1157 return _pixelSnapSize;
1158 }
1159 return newPosition;
1160 }
1161
1162 // Find the two closest snap sizes to the position. If the velocity is
1163 // non-zero, select the size in the velocity's direction. Otherwise,
1164 // the nearest snap size.
1165 double _getSnapSize(double initialVelocity, List<double> pixelSnapSizes) {
1166 final int indexOfNextSize = pixelSnapSizes
1167 .indexWhere((double size) => size >= position);
1168 if (indexOfNextSize == 0) {
1169 return pixelSnapSizes.first;
1170 }
1171 final double nextSize = pixelSnapSizes[indexOfNextSize];
1172 final double previousSize = pixelSnapSizes[indexOfNextSize - 1];
1173 if (initialVelocity.abs() <= tolerance.velocity) {
1174 // If velocity is zero, snap to the nearest snap size with the minimum velocity.
1175 if (position - previousSize < nextSize - position) {
1176 return previousSize;
1177 } else {
1178 return nextSize;
1179 }
1180 }
1181 // Snap forward or backward depending on current velocity.
1182 if (initialVelocity < 0.0) {
1183 return pixelSnapSizes[indexOfNextSize - 1];
1184 }
1185 return pixelSnapSizes[indexOfNextSize];
1186 }
1187}
1188