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

Provided by KDAB

Privacy Policy
Learn more about Flutter for embedded and desktop on industrialflutter.com