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 | import 'package:flutter/painting.dart' show AxisDirection; |
10 | import 'package:flutter/physics.dart'; |
11 | |
12 | import 'binding.dart' show WidgetsBinding; |
13 | import 'framework.dart'; |
14 | import 'overscroll_indicator.dart'; |
15 | import 'scroll_metrics.dart'; |
16 | import 'scroll_simulation.dart'; |
17 | import 'view.dart'; |
18 | |
19 | export 'package:flutter/physics.dart' show ScrollSpringSimulation, Simulation, Tolerance; |
20 | |
21 | /// The rate at which scroll momentum will be decelerated. |
22 | enum ScrollDecelerationRate { |
23 | /// Standard deceleration, aligned with mobile software expectations. |
24 | normal, |
25 | /// Increased deceleration, aligned with desktop software expectations. |
26 | /// |
27 | /// Appropriate for use with input devices more precise than touch screens, |
28 | /// such as trackpads or mouse wheels. |
29 | fast |
30 | } |
31 | |
32 | // Examples can assume: |
33 | // class FooScrollPhysics extends ScrollPhysics { |
34 | // const FooScrollPhysics({ super.parent }); |
35 | // @override |
36 | // FooScrollPhysics applyTo(ScrollPhysics? ancestor) { |
37 | // return FooScrollPhysics(parent: buildParent(ancestor)); |
38 | // } |
39 | // } |
40 | // class BarScrollPhysics extends ScrollPhysics { |
41 | // const BarScrollPhysics({ super.parent }); |
42 | // } |
43 | |
44 | /// Determines the physics of a [Scrollable] widget. |
45 | /// |
46 | /// For example, determines how the [Scrollable] will behave when the user |
47 | /// reaches the maximum scroll extent or when the user stops scrolling. |
48 | /// |
49 | /// When starting a physics [Simulation], the current scroll position and |
50 | /// velocity are used as the initial conditions for the particle in the |
51 | /// simulation. The movement of the particle in the simulation is then used to |
52 | /// determine the scroll position for the widget. |
53 | /// |
54 | /// Instead of creating your own subclasses, [parent] can be used to combine |
55 | /// [ScrollPhysics] objects of different types to get the desired scroll physics. |
56 | /// For example: |
57 | /// |
58 | /// ```dart |
59 | /// const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()) |
60 | /// ``` |
61 | /// |
62 | /// You can also use `applyTo`, which is useful when you already have |
63 | /// an instance of [ScrollPhysics]: |
64 | /// |
65 | /// ```dart |
66 | /// ScrollPhysics physics = const BouncingScrollPhysics(); |
67 | /// // ... |
68 | /// final ScrollPhysics mergedPhysics = physics.applyTo(const AlwaysScrollableScrollPhysics()); |
69 | /// ``` |
70 | /// |
71 | /// When implementing a subclass, you must override [applyTo] so that it returns |
72 | /// an appropriate instance of your subclass. Otherwise, classes like |
73 | /// [Scrollable] that inform a [ScrollPosition] will combine them with |
74 | /// the default [ScrollPhysics] object instead of your custom subclass. |
75 | @immutable |
76 | class ScrollPhysics { |
77 | /// Creates an object with the default scroll physics. |
78 | const ScrollPhysics({ this.parent }); |
79 | |
80 | /// If non-null, determines the default behavior for each method. |
81 | /// |
82 | /// If a subclass of [ScrollPhysics] does not override a method, that subclass |
83 | /// will inherit an implementation from this base class that defers to |
84 | /// [parent]. This mechanism lets you assemble novel combinations of |
85 | /// [ScrollPhysics] subclasses at runtime. For example: |
86 | /// |
87 | /// ```dart |
88 | /// const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()) |
89 | /// ``` |
90 | /// |
91 | /// will result in a [ScrollPhysics] that has the combined behavior |
92 | /// of [BouncingScrollPhysics] and [AlwaysScrollableScrollPhysics]: |
93 | /// behaviors that are not specified in [BouncingScrollPhysics] |
94 | /// (e.g. [shouldAcceptUserOffset]) will defer to [AlwaysScrollableScrollPhysics]. |
95 | final ScrollPhysics? parent; |
96 | |
97 | /// If [parent] is null then return ancestor, otherwise recursively build a |
98 | /// ScrollPhysics that has [ancestor] as its parent. |
99 | /// |
100 | /// This method is typically used to define [applyTo] methods like: |
101 | /// |
102 | /// ```dart |
103 | /// class MyScrollPhysics extends ScrollPhysics { |
104 | /// const MyScrollPhysics({ super.parent }); |
105 | /// |
106 | /// @override |
107 | /// MyScrollPhysics applyTo(ScrollPhysics? ancestor) { |
108 | /// return MyScrollPhysics(parent: buildParent(ancestor)); |
109 | /// } |
110 | /// |
111 | /// // ... |
112 | /// } |
113 | /// ``` |
114 | @protected |
115 | ScrollPhysics? buildParent(ScrollPhysics? ancestor) => parent?.applyTo(ancestor) ?? ancestor; |
116 | |
117 | /// Combines this [ScrollPhysics] instance with the given physics. |
118 | /// |
119 | /// The returned object uses this instance's physics when it has an |
120 | /// opinion, and defers to the given `ancestor` object's physics |
121 | /// when it does not. |
122 | /// |
123 | /// If [parent] is null then this returns a [ScrollPhysics] with the |
124 | /// same [runtimeType], but where the [parent] has been replaced |
125 | /// with the [ancestor]. |
126 | /// |
127 | /// If this scroll physics object already has a parent, then this |
128 | /// method is applied recursively and ancestor will appear at the |
129 | /// end of the existing chain of parents. |
130 | /// |
131 | /// Calling this method with a null argument will copy the current |
132 | /// object. This is inefficient. |
133 | /// |
134 | /// {@tool snippet} |
135 | /// |
136 | /// In the following example, the [applyTo] method is used to combine the |
137 | /// scroll physics of two [ScrollPhysics] objects. The resulting [ScrollPhysics] |
138 | /// `x` has the same behavior as `y`. |
139 | /// |
140 | /// ```dart |
141 | /// final FooScrollPhysics x = const FooScrollPhysics().applyTo(const BarScrollPhysics()); |
142 | /// const FooScrollPhysics y = FooScrollPhysics(parent: BarScrollPhysics()); |
143 | /// ``` |
144 | /// {@end-tool} |
145 | /// |
146 | /// ## Implementing [applyTo] |
147 | /// |
148 | /// When creating a custom [ScrollPhysics] subclass, this method |
149 | /// must be implemented. If the physics class has no constructor |
150 | /// arguments, then implementing this method is merely a matter of |
151 | /// calling the constructor with a [parent] constructed using |
152 | /// [buildParent], as follows: |
153 | /// |
154 | /// ```dart |
155 | /// class MyScrollPhysics extends ScrollPhysics { |
156 | /// const MyScrollPhysics({ super.parent }); |
157 | /// |
158 | /// @override |
159 | /// MyScrollPhysics applyTo(ScrollPhysics? ancestor) { |
160 | /// return MyScrollPhysics(parent: buildParent(ancestor)); |
161 | /// } |
162 | /// |
163 | /// // ... |
164 | /// } |
165 | /// ``` |
166 | /// |
167 | /// If the physics class has constructor arguments, they must be passed to |
168 | /// the constructor here as well, so as to create a clone. |
169 | /// |
170 | /// See also: |
171 | /// |
172 | /// * [buildParent], a utility method that's often used to define [applyTo] |
173 | /// methods for [ScrollPhysics] subclasses. |
174 | ScrollPhysics applyTo(ScrollPhysics? ancestor) { |
175 | return ScrollPhysics(parent: buildParent(ancestor)); |
176 | } |
177 | |
178 | /// Used by [DragScrollActivity] and other user-driven activities to convert |
179 | /// an offset in logical pixels as provided by the [DragUpdateDetails] into a |
180 | /// delta to apply (subtract from the current position) using |
181 | /// [ScrollActivityDelegate.setPixels]. |
182 | /// |
183 | /// This is used by some [ScrollPosition] subclasses to apply friction during |
184 | /// overscroll situations. |
185 | /// |
186 | /// This method must not adjust parts of the offset that are entirely within |
187 | /// the bounds described by the given `position`. |
188 | /// |
189 | /// The given `position` is only valid during this method call. Do not keep a |
190 | /// reference to it to use later, as the values may update, may not update, or |
191 | /// may update to reflect an entirely unrelated scrollable. |
192 | double applyPhysicsToUserOffset(ScrollMetrics position, double offset) { |
193 | if (parent == null) { |
194 | return offset; |
195 | } |
196 | return parent!.applyPhysicsToUserOffset(position, offset); |
197 | } |
198 | |
199 | /// Whether the scrollable should let the user adjust the scroll offset, for |
200 | /// example by dragging. If [allowUserScrolling] is false, the scrollable |
201 | /// will never allow user input to change the scroll position. |
202 | /// |
203 | /// By default, the user can manipulate the scroll offset if, and only if, |
204 | /// there is actually content outside the viewport to reveal. |
205 | /// |
206 | /// The given `position` is only valid during this method call. Do not keep a |
207 | /// reference to it to use later, as the values may update, may not update, or |
208 | /// may update to reflect an entirely unrelated scrollable. |
209 | bool shouldAcceptUserOffset(ScrollMetrics position) { |
210 | if (!allowUserScrolling) { |
211 | return false; |
212 | } |
213 | |
214 | if (parent == null) { |
215 | return position.pixels != 0.0 || position.minScrollExtent != position.maxScrollExtent; |
216 | } |
217 | return parent!.shouldAcceptUserOffset(position); |
218 | } |
219 | |
220 | /// Provides a heuristic to determine if expensive frame-bound tasks should be |
221 | /// deferred. |
222 | /// |
223 | /// The `velocity` parameter may be positive, negative, or zero. |
224 | /// |
225 | /// The `context` parameter normally refers to the [BuildContext] of the widget |
226 | /// making the call, such as an [Image] widget in a [ListView]. |
227 | /// |
228 | /// This can be used to determine whether decoding or fetching complex data |
229 | /// for the currently visible part of the viewport should be delayed |
230 | /// to avoid doing work that will not have a chance to appear before a new |
231 | /// frame is rendered. |
232 | /// |
233 | /// For example, a list of images could use this logic to delay decoding |
234 | /// images until scrolling is slow enough to actually render the decoded |
235 | /// image to the screen. |
236 | /// |
237 | /// The default implementation is a heuristic that compares the current |
238 | /// scroll velocity in local logical pixels to the longest side of the window |
239 | /// in physical pixels. Implementers can change this heuristic by overriding |
240 | /// this method and providing their custom physics to the scrollable widget. |
241 | /// For example, an application that changes the local coordinate system with |
242 | /// a large perspective transform could provide a more or less aggressive |
243 | /// heuristic depending on whether the transform was increasing or decreasing |
244 | /// the overall scale between the global screen and local scrollable |
245 | /// coordinate systems. |
246 | /// |
247 | /// The default implementation is stateless, and provides a point-in-time |
248 | /// decision about how fast the scrollable is scrolling. It would always |
249 | /// return true for a scrollable that is animating back and forth at high |
250 | /// velocity in a loop. It is assumed that callers will handle such |
251 | /// a case, or that a custom stateful implementation would be written that |
252 | /// tracks the sign of the velocity on successive calls. |
253 | /// |
254 | /// Returning true from this method indicates that the current scroll velocity |
255 | /// is great enough that expensive operations impacting the UI should be |
256 | /// deferred. |
257 | bool recommendDeferredLoading(double velocity, ScrollMetrics metrics, BuildContext context) { |
258 | if (parent == null) { |
259 | final double maxPhysicalPixels = View.of(context).physicalSize.longestSide; |
260 | return velocity.abs() > maxPhysicalPixels; |
261 | } |
262 | return parent!.recommendDeferredLoading(velocity, metrics, context); |
263 | } |
264 | |
265 | /// Determines the overscroll by applying the boundary conditions. |
266 | /// |
267 | /// Called by [ScrollPosition.applyBoundaryConditions], which is called by |
268 | /// [ScrollPosition.setPixels] just before the [ScrollPosition.pixels] value |
269 | /// is updated, to determine how much of the offset is to be clamped off and |
270 | /// sent to [ScrollPosition.didOverscrollBy]. |
271 | /// |
272 | /// The `value` argument is guaranteed to not equal the [ScrollMetrics.pixels] |
273 | /// of the `position` argument when this is called. |
274 | /// |
275 | /// It is possible for this method to be called when the `position` describes |
276 | /// an already-out-of-bounds position. In that case, the boundary conditions |
277 | /// should usually only prevent a further increase in the extent to which the |
278 | /// position is out of bounds, allowing a decrease to be applied successfully, |
279 | /// so that (for instance) an animation can smoothly snap an out of bounds |
280 | /// position to the bounds. See [BallisticScrollActivity]. |
281 | /// |
282 | /// This method must not clamp parts of the offset that are entirely within |
283 | /// the bounds described by the given `position`. |
284 | /// |
285 | /// The given `position` is only valid during this method call. Do not keep a |
286 | /// reference to it to use later, as the values may update, may not update, or |
287 | /// may update to reflect an entirely unrelated scrollable. |
288 | /// |
289 | /// ## Examples |
290 | /// |
291 | /// [BouncingScrollPhysics] returns zero. In other words, it allows scrolling |
292 | /// past the boundary unhindered. |
293 | /// |
294 | /// [ClampingScrollPhysics] returns the amount by which the value is beyond |
295 | /// the position or the boundary, whichever is furthest from the content. In |
296 | /// other words, it disallows scrolling past the boundary, but allows |
297 | /// scrolling back from being overscrolled, if for some reason the position |
298 | /// ends up overscrolled. |
299 | double applyBoundaryConditions(ScrollMetrics position, double value) { |
300 | if (parent == null) { |
301 | return 0.0; |
302 | } |
303 | return parent!.applyBoundaryConditions(position, value); |
304 | } |
305 | |
306 | /// Describes what the scroll position should be given new viewport dimensions. |
307 | /// |
308 | /// This is called by [ScrollPosition.correctForNewDimensions]. |
309 | /// |
310 | /// The arguments consist of the scroll metrics as they stood in the previous |
311 | /// frame and the scroll metrics as they now stand after the last layout, |
312 | /// including the position and minimum and maximum scroll extents; a flag |
313 | /// indicating if the current [ScrollActivity] considers that the user is |
314 | /// actively scrolling (see [ScrollActivity.isScrolling]); and the current |
315 | /// velocity of the scroll position, if it is being driven by the scroll |
316 | /// activity (this is 0.0 during a user gesture) (see |
317 | /// [ScrollActivity.velocity]). |
318 | /// |
319 | /// The scroll metrics will be identical except for the |
320 | /// [ScrollMetrics.minScrollExtent] and [ScrollMetrics.maxScrollExtent]. They |
321 | /// are referred to as the `oldPosition` and `newPosition` (even though they |
322 | /// both technically have the same "position", in the form of |
323 | /// [ScrollMetrics.pixels]) because they are generated from the |
324 | /// [ScrollPosition] before and after updating the scroll extents. |
325 | /// |
326 | /// If the returned value does not exactly match the scroll offset given by |
327 | /// the `newPosition` argument (see [ScrollMetrics.pixels]), then the |
328 | /// [ScrollPosition] will call [ScrollPosition.correctPixels] to update the |
329 | /// new scroll position to the returned value, and layout will be re-run. This |
330 | /// is expensive. The new value is subject to further manipulation by |
331 | /// [applyBoundaryConditions]. |
332 | /// |
333 | /// If the returned value _does_ match the `newPosition.pixels` scroll offset |
334 | /// exactly, then [ScrollPosition.applyNewDimensions] will be called next. In |
335 | /// that case, [applyBoundaryConditions] is not applied to the return value. |
336 | /// |
337 | /// The given [ScrollMetrics] are only valid during this method call. Do not |
338 | /// keep references to them to use later, as the values may update, may not |
339 | /// update, or may update to reflect an entirely unrelated scrollable. |
340 | /// |
341 | /// The default implementation returns the [ScrollMetrics.pixels] of the |
342 | /// `newPosition`, which indicates that the current scroll offset is |
343 | /// acceptable. |
344 | /// |
345 | /// See also: |
346 | /// |
347 | /// * [RangeMaintainingScrollPhysics], which is enabled by default, and |
348 | /// which prevents unexpected changes to the content dimensions from |
349 | /// causing the scroll position to get any further out of bounds. |
350 | double adjustPositionForNewDimensions({ |
351 | required ScrollMetrics oldPosition, |
352 | required ScrollMetrics newPosition, |
353 | required bool isScrolling, |
354 | required double velocity, |
355 | }) { |
356 | if (parent == null) { |
357 | return newPosition.pixels; |
358 | } |
359 | return parent!.adjustPositionForNewDimensions(oldPosition: oldPosition, newPosition: newPosition, isScrolling: isScrolling, velocity: velocity); |
360 | } |
361 | |
362 | /// Returns a simulation for ballistic scrolling starting from the given |
363 | /// position with the given velocity. |
364 | /// |
365 | /// This is used by [ScrollPositionWithSingleContext] in the |
366 | /// [ScrollPositionWithSingleContext.goBallistic] method. If the result |
367 | /// is non-null, [ScrollPositionWithSingleContext] will begin a |
368 | /// [BallisticScrollActivity] with the returned value. Otherwise, it will |
369 | /// begin an idle activity instead. |
370 | /// |
371 | /// The given `position` is only valid during this method call. Do not keep a |
372 | /// reference to it to use later, as the values may update, may not update, or |
373 | /// may update to reflect an entirely unrelated scrollable. |
374 | /// |
375 | /// This method can potentially be called in every frame, even in the middle |
376 | /// of what the user perceives as a single ballistic scroll. For example, in |
377 | /// a [ListView] when previously off-screen items come into view and are laid |
378 | /// out, this method may be called with a new [ScrollMetrics.maxScrollExtent]. |
379 | /// The method implementation should ensure that when the same ballistic |
380 | /// scroll motion is still intended, these calls have no side effects on the |
381 | /// physics beyond continuing that motion. |
382 | /// |
383 | /// Generally this is ensured by having the [Simulation] conform to a physical |
384 | /// metaphor of a particle in ballistic flight, where the forces on the |
385 | /// particle depend only on its position, velocity, and environment, and not |
386 | /// on the current time or any internal state. This means that the |
387 | /// time-derivative of [Simulation.dx] should be possible to write |
388 | /// mathematically as a function purely of the values of [Simulation.x], |
389 | /// [Simulation.dx], and the parameters used to construct the [Simulation], |
390 | /// independent of the time. |
391 | // TODO(gnprice): Some scroll physics in the framework violate that invariant; fix them. |
392 | // An audit found three cases violating the invariant: |
393 | // https://github.com/flutter/flutter/issues/120338 |
394 | // https://github.com/flutter/flutter/issues/120340 |
395 | // https://github.com/flutter/flutter/issues/109675 |
396 | Simulation? createBallisticSimulation(ScrollMetrics position, double velocity) { |
397 | if (parent == null) { |
398 | return null; |
399 | } |
400 | return parent!.createBallisticSimulation(position, velocity); |
401 | } |
402 | |
403 | static final SpringDescription _kDefaultSpring = SpringDescription.withDampingRatio( |
404 | mass: 0.5, |
405 | stiffness: 100.0, |
406 | ratio: 1.1, |
407 | ); |
408 | |
409 | /// The spring to use for ballistic simulations. |
410 | SpringDescription get spring => parent?.spring ?? _kDefaultSpring; |
411 | |
412 | /// Deprecated. Call [toleranceFor] instead. |
413 | @Deprecated( |
414 | 'Call toleranceFor instead. ' |
415 | 'This feature was deprecated after v3.7.0-13.0.pre.' , |
416 | ) |
417 | Tolerance get tolerance { |
418 | return toleranceFor(FixedScrollMetrics( |
419 | minScrollExtent: null, |
420 | maxScrollExtent: null, |
421 | pixels: null, |
422 | viewportDimension: null, |
423 | axisDirection: AxisDirection.down, |
424 | devicePixelRatio: WidgetsBinding.instance.window.devicePixelRatio, |
425 | )); |
426 | } |
427 | |
428 | /// The tolerance to use for ballistic simulations. |
429 | Tolerance toleranceFor(ScrollMetrics metrics) { |
430 | return parent?.toleranceFor(metrics) ?? Tolerance( |
431 | velocity: 1.0 / (0.050 * metrics.devicePixelRatio), // logical pixels per second |
432 | distance: 1.0 / metrics.devicePixelRatio, // logical pixels |
433 | ); |
434 | } |
435 | |
436 | /// The minimum distance an input pointer drag must have moved to be |
437 | /// considered a scroll fling gesture. |
438 | /// |
439 | /// This value is typically compared with the distance traveled along the |
440 | /// scrolling axis. |
441 | /// |
442 | /// See also: |
443 | /// |
444 | /// * [VelocityTracker.getVelocityEstimate], which computes the velocity |
445 | /// of a press-drag-release gesture. |
446 | double get minFlingDistance => parent?.minFlingDistance ?? kTouchSlop; |
447 | |
448 | /// The minimum velocity for an input pointer drag to be considered a |
449 | /// scroll fling. |
450 | /// |
451 | /// This value is typically compared with the magnitude of fling gesture's |
452 | /// velocity along the scrolling axis. |
453 | /// |
454 | /// See also: |
455 | /// |
456 | /// * [VelocityTracker.getVelocityEstimate], which computes the velocity |
457 | /// of a press-drag-release gesture. |
458 | double get minFlingVelocity => parent?.minFlingVelocity ?? kMinFlingVelocity; |
459 | |
460 | /// Scroll fling velocity magnitudes will be clamped to this value. |
461 | double get maxFlingVelocity => parent?.maxFlingVelocity ?? kMaxFlingVelocity; |
462 | |
463 | /// Returns the velocity carried on repeated flings. |
464 | /// |
465 | /// The function is applied to the existing scroll velocity when another |
466 | /// scroll drag is applied in the same direction. |
467 | /// |
468 | /// By default, physics for platforms other than iOS doesn't carry momentum. |
469 | double carriedMomentum(double existingVelocity) { |
470 | if (parent == null) { |
471 | return 0.0; |
472 | } |
473 | return parent!.carriedMomentum(existingVelocity); |
474 | } |
475 | |
476 | /// The minimum amount of pixel distance drags must move by to start motion |
477 | /// the first time or after each time the drag motion stopped. |
478 | /// |
479 | /// If null, no minimum threshold is enforced. |
480 | double? get dragStartDistanceMotionThreshold => parent?.dragStartDistanceMotionThreshold; |
481 | |
482 | /// Whether a viewport is allowed to change its scroll position implicitly in |
483 | /// response to a call to [RenderObject.showOnScreen]. |
484 | /// |
485 | /// [RenderObject.showOnScreen] is for example used to bring a text field |
486 | /// fully on screen after it has received focus. This property controls |
487 | /// whether the viewport associated with this object is allowed to change the |
488 | /// scroll position to fulfill such a request. |
489 | bool get allowImplicitScrolling => true; |
490 | |
491 | /// Whether a viewport is allowed to change the scroll position as the result of user input. |
492 | bool get allowUserScrolling => true; |
493 | |
494 | @override |
495 | String toString() { |
496 | if (parent == null) { |
497 | return objectRuntimeType(this, 'ScrollPhysics' ); |
498 | } |
499 | return ' ${objectRuntimeType(this, 'ScrollPhysics' )} -> $parent' ; |
500 | } |
501 | } |
502 | |
503 | /// Scroll physics that attempt to keep the scroll position in range when the |
504 | /// contents change dimensions suddenly. |
505 | /// |
506 | /// This attempts to maintain the amount of overscroll or underscroll already present, |
507 | /// if the scroll position is already out of range _and_ the extents |
508 | /// have decreased, meaning that some content was removed. The reason for this |
509 | /// condition is that when new content is added, keeping the same overscroll |
510 | /// would mean that instead of showing it to the user, all of it is |
511 | /// being skipped by jumping right to the max extent. |
512 | /// |
513 | /// If the scroll activity is animating the scroll position, sudden changes to |
514 | /// the scroll dimensions are allowed to happen (so as to prevent animations |
515 | /// from jumping back and forth between in-range and out-of-range values). |
516 | /// |
517 | /// These physics should be combined with other scroll physics, e.g. |
518 | /// [BouncingScrollPhysics] or [ClampingScrollPhysics], to obtain a complete |
519 | /// description of typical scroll physics. See [applyTo]. |
520 | /// |
521 | /// ## Implementation details |
522 | /// |
523 | /// Specifically, these physics perform two adjustments. |
524 | /// |
525 | /// The first is to maintain overscroll when the position is out of range. |
526 | /// |
527 | /// The second is to enforce the boundary when the position is in range. |
528 | /// |
529 | /// If the current velocity is non-zero, neither adjustment is made. The |
530 | /// assumption is that there is an ongoing animation and therefore |
531 | /// further changing the scroll position would disrupt the experience. |
532 | /// |
533 | /// If the extents haven't changed, then the overscroll adjustment is |
534 | /// not made. The assumption is that if the position is overscrolled, |
535 | /// it is intentional, otherwise the position could not have reached |
536 | /// that position. (Consider [ClampingScrollPhysics] vs |
537 | /// [BouncingScrollPhysics] for example.) |
538 | /// |
539 | /// If the position itself changed since the last animation frame, |
540 | /// then the overscroll is not maintained. The assumption is similar |
541 | /// to the previous case: the position would not have been placed out |
542 | /// of range unless it was intentional. |
543 | /// |
544 | /// In addition, if the position changed and the boundaries were and |
545 | /// still are finite, then the boundary isn't enforced either, for |
546 | /// the same reason. However, if any of the boundaries were or are |
547 | /// now infinite, the boundary _is_ enforced, on the assumption that |
548 | /// infinite boundaries indicate a lazy-loading scroll view, which |
549 | /// cannot enforce boundaries while the full list has not loaded. |
550 | /// |
551 | /// If the range was out of range, then the boundary is not enforced |
552 | /// even if the range is not maintained. If the range is maintained, |
553 | /// then the distance between the old position and the old boundary is |
554 | /// applied to the new boundary to obtain the new position. |
555 | /// |
556 | /// If the range was in range, and the boundary is to be enforced, |
557 | /// then the new position is obtained by deferring to the other physics, |
558 | /// if any, and then clamped to the new range. |
559 | class RangeMaintainingScrollPhysics extends ScrollPhysics { |
560 | /// Creates scroll physics that maintain the scroll position in range. |
561 | const RangeMaintainingScrollPhysics({ super.parent }); |
562 | |
563 | @override |
564 | RangeMaintainingScrollPhysics applyTo(ScrollPhysics? ancestor) { |
565 | return RangeMaintainingScrollPhysics(parent: buildParent(ancestor)); |
566 | } |
567 | |
568 | @override |
569 | double adjustPositionForNewDimensions({ |
570 | required ScrollMetrics oldPosition, |
571 | required ScrollMetrics newPosition, |
572 | required bool isScrolling, |
573 | required double velocity, |
574 | }) { |
575 | bool maintainOverscroll = true; |
576 | bool enforceBoundary = true; |
577 | if (velocity != 0.0) { |
578 | // Don't try to adjust an animating position, the jumping around |
579 | // would be distracting. |
580 | maintainOverscroll = false; |
581 | enforceBoundary = false; |
582 | } |
583 | if ((oldPosition.minScrollExtent == newPosition.minScrollExtent) && |
584 | (oldPosition.maxScrollExtent == newPosition.maxScrollExtent)) { |
585 | // If the extents haven't changed then ignore overscroll. |
586 | maintainOverscroll = false; |
587 | } |
588 | if (oldPosition.pixels != newPosition.pixels) { |
589 | // If the position has been changed already, then it might have |
590 | // been adjusted to expect new overscroll, so don't try to |
591 | // maintain the relative overscroll. |
592 | maintainOverscroll = false; |
593 | if (oldPosition.minScrollExtent.isFinite && oldPosition.maxScrollExtent.isFinite && |
594 | newPosition.minScrollExtent.isFinite && newPosition.maxScrollExtent.isFinite) { |
595 | // In addition, if the position changed then we don't enforce the new |
596 | // boundary if both the new and previous boundaries are entirely finite. |
597 | // A common case where the position changes while one |
598 | // of the extents is infinite is a lazily-loaded list. (If the |
599 | // boundaries were finite, and the position changed, then we |
600 | // assume it was intentional.) |
601 | enforceBoundary = false; |
602 | } |
603 | } |
604 | if ((oldPosition.pixels < oldPosition.minScrollExtent) || |
605 | (oldPosition.pixels > oldPosition.maxScrollExtent)) { |
606 | // If the old position was out of range, then we should |
607 | // not try to keep the new position in range. |
608 | enforceBoundary = false; |
609 | } |
610 | if (maintainOverscroll) { |
611 | // Force the new position to be no more out of range than it was before, if: |
612 | // * it was overscrolled, and |
613 | // * the extents have decreased, meaning that some content was removed. The |
614 | // reason for this condition is that when new content is added, keeping |
615 | // the same overscroll would mean that instead of showing it to the user, |
616 | // all of it is being skipped by jumping right to the max extent. |
617 | if (oldPosition.pixels < oldPosition.minScrollExtent && |
618 | newPosition.minScrollExtent > oldPosition.minScrollExtent) { |
619 | final double oldDelta = oldPosition.minScrollExtent - oldPosition.pixels; |
620 | return newPosition.minScrollExtent - oldDelta; |
621 | } |
622 | if (oldPosition.pixels > oldPosition.maxScrollExtent && |
623 | newPosition.maxScrollExtent < oldPosition.maxScrollExtent) { |
624 | final double oldDelta = oldPosition.pixels - oldPosition.maxScrollExtent; |
625 | return newPosition.maxScrollExtent + oldDelta; |
626 | } |
627 | } |
628 | // If we're not forcing the overscroll, defer to other physics. |
629 | double result = super.adjustPositionForNewDimensions(oldPosition: oldPosition, newPosition: newPosition, isScrolling: isScrolling, velocity: velocity); |
630 | if (enforceBoundary) { |
631 | // ...but if they put us out of range then reinforce the boundary. |
632 | result = clampDouble(result, newPosition.minScrollExtent, newPosition.maxScrollExtent); |
633 | } |
634 | return result; |
635 | } |
636 | } |
637 | |
638 | /// Scroll physics for environments that allow the scroll offset to go beyond |
639 | /// the bounds of the content, but then bounce the content back to the edge of |
640 | /// those bounds. |
641 | /// |
642 | /// This is the behavior typically seen on iOS. |
643 | /// |
644 | /// [BouncingScrollPhysics] by itself will not create an overscroll effect if |
645 | /// the contents of the scroll view do not extend beyond the size of the |
646 | /// viewport. To create the overscroll and bounce effect regardless of the |
647 | /// length of your scroll view, combine with [AlwaysScrollableScrollPhysics]. |
648 | /// |
649 | /// {@tool snippet} |
650 | /// ```dart |
651 | /// const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()) |
652 | /// ``` |
653 | /// {@end-tool} |
654 | /// |
655 | /// See also: |
656 | /// |
657 | /// * [ScrollConfiguration], which uses this to provide the default |
658 | /// scroll behavior on iOS. |
659 | /// * [ClampingScrollPhysics], which is the analogous physics for Android's |
660 | /// clamping behavior. |
661 | /// * [ScrollPhysics], for more examples of combining [ScrollPhysics] objects |
662 | /// of different types to get the desired scroll physics. |
663 | class BouncingScrollPhysics extends ScrollPhysics { |
664 | /// Creates scroll physics that bounce back from the edge. |
665 | const BouncingScrollPhysics({ |
666 | this.decelerationRate = ScrollDecelerationRate.normal, |
667 | super.parent, |
668 | }); |
669 | |
670 | /// Used to determine parameters for friction simulations. |
671 | final ScrollDecelerationRate decelerationRate; |
672 | |
673 | @override |
674 | BouncingScrollPhysics applyTo(ScrollPhysics? ancestor) { |
675 | return BouncingScrollPhysics( |
676 | parent: buildParent(ancestor), |
677 | decelerationRate: decelerationRate |
678 | ); |
679 | } |
680 | |
681 | /// The multiple applied to overscroll to make it appear that scrolling past |
682 | /// the edge of the scrollable contents is harder than scrolling the list. |
683 | /// This is done by reducing the ratio of the scroll effect output vs the |
684 | /// scroll gesture input. |
685 | /// |
686 | /// This factor starts at 0.52 and progressively becomes harder to overscroll |
687 | /// as more of the area past the edge is dragged in (represented by an increasing |
688 | /// `overscrollFraction` which starts at 0 when there is no overscroll). |
689 | double frictionFactor(double overscrollFraction) { |
690 | switch (decelerationRate) { |
691 | case ScrollDecelerationRate.fast: |
692 | return 0.26 * math.pow(1 - overscrollFraction, 2); |
693 | case ScrollDecelerationRate.normal: |
694 | return 0.52 * math.pow(1 - overscrollFraction, 2); |
695 | } |
696 | } |
697 | |
698 | @override |
699 | double applyPhysicsToUserOffset(ScrollMetrics position, double offset) { |
700 | assert(offset != 0.0); |
701 | assert(position.minScrollExtent <= position.maxScrollExtent); |
702 | |
703 | if (!position.outOfRange) { |
704 | return offset; |
705 | } |
706 | |
707 | final double overscrollPastStart = math.max(position.minScrollExtent - position.pixels, 0.0); |
708 | final double overscrollPastEnd = math.max(position.pixels - position.maxScrollExtent, 0.0); |
709 | final double overscrollPast = math.max(overscrollPastStart, overscrollPastEnd); |
710 | final bool easing = (overscrollPastStart > 0.0 && offset < 0.0) |
711 | || (overscrollPastEnd > 0.0 && offset > 0.0); |
712 | |
713 | final double friction = easing |
714 | // Apply less resistance when easing the overscroll vs tensioning. |
715 | ? frictionFactor((overscrollPast - offset.abs()) / position.viewportDimension) |
716 | : frictionFactor(overscrollPast / position.viewportDimension); |
717 | final double direction = offset.sign; |
718 | |
719 | if (easing && decelerationRate == ScrollDecelerationRate.fast) { |
720 | return direction * offset.abs(); |
721 | } |
722 | return direction * _applyFriction(overscrollPast, offset.abs(), friction); |
723 | } |
724 | |
725 | static double _applyFriction(double extentOutside, double absDelta, double gamma) { |
726 | assert(absDelta > 0); |
727 | double total = 0.0; |
728 | if (extentOutside > 0) { |
729 | final double deltaToLimit = extentOutside / gamma; |
730 | if (absDelta < deltaToLimit) { |
731 | return absDelta * gamma; |
732 | } |
733 | total += extentOutside; |
734 | absDelta -= deltaToLimit; |
735 | } |
736 | return total + absDelta; |
737 | } |
738 | |
739 | @override |
740 | double applyBoundaryConditions(ScrollMetrics position, double value) => 0.0; |
741 | |
742 | @override |
743 | Simulation? createBallisticSimulation(ScrollMetrics position, double velocity) { |
744 | final Tolerance tolerance = toleranceFor(position); |
745 | if (velocity.abs() >= tolerance.velocity || position.outOfRange) { |
746 | double constantDeceleration; |
747 | switch (decelerationRate) { |
748 | case ScrollDecelerationRate.fast: |
749 | constantDeceleration = 1400; |
750 | case ScrollDecelerationRate.normal: |
751 | constantDeceleration = 0; |
752 | } |
753 | return BouncingScrollSimulation( |
754 | spring: spring, |
755 | position: position.pixels, |
756 | velocity: velocity, |
757 | leadingExtent: position.minScrollExtent, |
758 | trailingExtent: position.maxScrollExtent, |
759 | tolerance: tolerance, |
760 | constantDeceleration: constantDeceleration |
761 | ); |
762 | } |
763 | return null; |
764 | } |
765 | |
766 | // The ballistic simulation here decelerates more slowly than the one for |
767 | // ClampingScrollPhysics so we require a more deliberate input gesture |
768 | // to trigger a fling. |
769 | @override |
770 | double get minFlingVelocity => kMinFlingVelocity * 2.0; |
771 | |
772 | // Methodology: |
773 | // 1- Use https://github.com/flutter/platform_tests/tree/master/scroll_overlay to test with |
774 | // Flutter and platform scroll views superimposed. |
775 | // 3- If the scrollables stopped overlapping at any moment, adjust the desired |
776 | // output value of this function at that input speed. |
777 | // 4- Feed new input/output set into a power curve fitter. Change function |
778 | // and repeat from 2. |
779 | // 5- Repeat from 2 with medium and slow flings. |
780 | /// Momentum build-up function that mimics iOS's scroll speed increase with repeated flings. |
781 | /// |
782 | /// The velocity of the last fling is not an important factor. Existing speed |
783 | /// and (related) time since last fling are factors for the velocity transfer |
784 | /// calculations. |
785 | @override |
786 | double carriedMomentum(double existingVelocity) { |
787 | return existingVelocity.sign * |
788 | math.min(0.000816 * math.pow(existingVelocity.abs(), 1.967).toDouble(), 40000.0); |
789 | } |
790 | |
791 | // Eyeballed from observation to counter the effect of an unintended scroll |
792 | // from the natural motion of lifting the finger after a scroll. |
793 | @override |
794 | double get dragStartDistanceMotionThreshold => 3.5; |
795 | |
796 | @override |
797 | double get maxFlingVelocity { |
798 | switch (decelerationRate) { |
799 | case ScrollDecelerationRate.fast: |
800 | return kMaxFlingVelocity * 8.0; |
801 | case ScrollDecelerationRate.normal: |
802 | return super.maxFlingVelocity; |
803 | } |
804 | } |
805 | |
806 | @override |
807 | SpringDescription get spring { |
808 | switch (decelerationRate) { |
809 | case ScrollDecelerationRate.fast: |
810 | return SpringDescription.withDampingRatio( |
811 | mass: 0.3, |
812 | stiffness: 75.0, |
813 | ratio: 1.3, |
814 | ); |
815 | case ScrollDecelerationRate.normal: |
816 | return super.spring; |
817 | } |
818 | } |
819 | } |
820 | |
821 | /// Scroll physics for environments that prevent the scroll offset from reaching |
822 | /// beyond the bounds of the content. |
823 | /// |
824 | /// This is the behavior typically seen on Android. |
825 | /// |
826 | /// See also: |
827 | /// |
828 | /// * [ScrollConfiguration], which uses this to provide the default |
829 | /// scroll behavior on Android. |
830 | /// * [BouncingScrollPhysics], which is the analogous physics for iOS' bouncing |
831 | /// behavior. |
832 | /// * [GlowingOverscrollIndicator], which is used by [ScrollConfiguration] to |
833 | /// provide the glowing effect that is usually found with this clamping effect |
834 | /// on Android. When using a [MaterialApp], the [GlowingOverscrollIndicator]'s |
835 | /// glow color is specified to use the overall theme's |
836 | /// [ColorScheme.secondary] color. |
837 | class ClampingScrollPhysics extends ScrollPhysics { |
838 | /// Creates scroll physics that prevent the scroll offset from exceeding the |
839 | /// bounds of the content. |
840 | const ClampingScrollPhysics({ super.parent }); |
841 | |
842 | @override |
843 | ClampingScrollPhysics applyTo(ScrollPhysics? ancestor) { |
844 | return ClampingScrollPhysics(parent: buildParent(ancestor)); |
845 | } |
846 | |
847 | @override |
848 | double applyBoundaryConditions(ScrollMetrics position, double value) { |
849 | assert(() { |
850 | if (value == position.pixels) { |
851 | throw FlutterError.fromParts(<DiagnosticsNode>[ |
852 | ErrorSummary(' $runtimeType.applyBoundaryConditions() was called redundantly.' ), |
853 | ErrorDescription( |
854 | 'The proposed new position, $value, is exactly equal to the current position of the ' |
855 | 'given ${position.runtimeType}, ${position.pixels}.\n' |
856 | 'The applyBoundaryConditions method should only be called when the value is ' |
857 | 'going to actually change the pixels, otherwise it is redundant.' , |
858 | ), |
859 | DiagnosticsProperty<ScrollPhysics>('The physics object in question was' , this, style: DiagnosticsTreeStyle.errorProperty), |
860 | DiagnosticsProperty<ScrollMetrics>('The position object in question was' , position, style: DiagnosticsTreeStyle.errorProperty), |
861 | ]); |
862 | } |
863 | return true; |
864 | }()); |
865 | if (value < position.pixels && position.pixels <= position.minScrollExtent) { |
866 | // Underscroll. |
867 | return value - position.pixels; |
868 | } |
869 | if (position.maxScrollExtent <= position.pixels && position.pixels < value) { |
870 | // Overscroll. |
871 | return value - position.pixels; |
872 | } |
873 | if (value < position.minScrollExtent && position.minScrollExtent < position.pixels) { |
874 | // Hit top edge. |
875 | return value - position.minScrollExtent; |
876 | } |
877 | if (position.pixels < position.maxScrollExtent && position.maxScrollExtent < value) { |
878 | // Hit bottom edge. |
879 | return value - position.maxScrollExtent; |
880 | } |
881 | return 0.0; |
882 | } |
883 | |
884 | @override |
885 | Simulation? createBallisticSimulation(ScrollMetrics position, double velocity) { |
886 | final Tolerance tolerance = toleranceFor(position); |
887 | if (position.outOfRange) { |
888 | double? end; |
889 | if (position.pixels > position.maxScrollExtent) { |
890 | end = position.maxScrollExtent; |
891 | } |
892 | if (position.pixels < position.minScrollExtent) { |
893 | end = position.minScrollExtent; |
894 | } |
895 | assert(end != null); |
896 | return ScrollSpringSimulation( |
897 | spring, |
898 | position.pixels, |
899 | end!, |
900 | math.min(0.0, velocity), |
901 | tolerance: tolerance, |
902 | ); |
903 | } |
904 | if (velocity.abs() < tolerance.velocity) { |
905 | return null; |
906 | } |
907 | if (velocity > 0.0 && position.pixels >= position.maxScrollExtent) { |
908 | return null; |
909 | } |
910 | if (velocity < 0.0 && position.pixels <= position.minScrollExtent) { |
911 | return null; |
912 | } |
913 | return ClampingScrollSimulation( |
914 | position: position.pixels, |
915 | velocity: velocity, |
916 | tolerance: tolerance, |
917 | ); |
918 | } |
919 | } |
920 | |
921 | /// Scroll physics that always lets the user scroll. |
922 | /// |
923 | /// This overrides the default behavior which is to disable scrolling |
924 | /// when there is no content to scroll. It does not override the |
925 | /// handling of overscrolling. |
926 | /// |
927 | /// On Android, overscrolls will be clamped by default and result in an |
928 | /// overscroll glow. On iOS, overscrolls will load a spring that will return the |
929 | /// scroll view to its normal range when released. |
930 | /// |
931 | /// See also: |
932 | /// |
933 | /// * [ScrollPhysics], which can be used instead of this class when the default |
934 | /// behavior is desired instead. |
935 | /// * [BouncingScrollPhysics], which provides the bouncing overscroll behavior |
936 | /// found on iOS. |
937 | /// * [ClampingScrollPhysics], which provides the clamping overscroll behavior |
938 | /// found on Android. |
939 | class AlwaysScrollableScrollPhysics extends ScrollPhysics { |
940 | /// Creates scroll physics that always lets the user scroll. |
941 | const AlwaysScrollableScrollPhysics({ super.parent }); |
942 | |
943 | @override |
944 | AlwaysScrollableScrollPhysics applyTo(ScrollPhysics? ancestor) { |
945 | return AlwaysScrollableScrollPhysics(parent: buildParent(ancestor)); |
946 | } |
947 | |
948 | @override |
949 | bool shouldAcceptUserOffset(ScrollMetrics position) => true; |
950 | } |
951 | |
952 | /// Scroll physics that does not allow the user to scroll. |
953 | /// |
954 | /// See also: |
955 | /// |
956 | /// * [ScrollPhysics], which can be used instead of this class when the default |
957 | /// behavior is desired instead. |
958 | /// * [BouncingScrollPhysics], which provides the bouncing overscroll behavior |
959 | /// found on iOS. |
960 | /// * [ClampingScrollPhysics], which provides the clamping overscroll behavior |
961 | /// found on Android. |
962 | class NeverScrollableScrollPhysics extends ScrollPhysics { |
963 | /// Creates scroll physics that does not let the user scroll. |
964 | const NeverScrollableScrollPhysics({ super.parent }); |
965 | |
966 | @override |
967 | NeverScrollableScrollPhysics applyTo(ScrollPhysics? ancestor) { |
968 | return NeverScrollableScrollPhysics(parent: buildParent(ancestor)); |
969 | } |
970 | |
971 | @override |
972 | bool get allowUserScrolling => false; |
973 | |
974 | @override |
975 | bool get allowImplicitScrolling => false; |
976 | } |
977 | |