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 | import 'dart:math' as math; |
6 | |
7 | import 'package:flutter/foundation.dart'; |
8 | import 'package:flutter/gestures.dart'; |
9 | |
10 | import 'basic.dart'; |
11 | import 'binding.dart'; |
12 | import 'framework.dart'; |
13 | import 'inherited_notifier.dart'; |
14 | import 'layout_builder.dart'; |
15 | import 'notification_listener.dart'; |
16 | import 'scroll_activity.dart'; |
17 | import 'scroll_context.dart'; |
18 | import 'scroll_controller.dart'; |
19 | import 'scroll_notification.dart'; |
20 | import 'scroll_physics.dart'; |
21 | import 'scroll_position.dart'; |
22 | import 'scroll_position_with_single_context.dart'; |
23 | import 'scroll_simulation.dart'; |
24 | import '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. |
33 | typedef 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. |
54 | class 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} |
283 | class 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. |
415 | class 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`. |
478 | class _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 | |
624 | class _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. |
779 | class _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]. |
847 | class _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]). |
1006 | class 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 | |
1039 | class _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. |
1056 | class _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 | |
1080 | class _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 | |
1107 | class _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 | |