1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/physics.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'basic.dart';
import 'framework.dart';
import 'gesture_detector.dart';
import 'scroll_metrics.dart';
import 'scroll_notification.dart';
import 'ticker_provider.dart';
/// A backend for a [ScrollActivity].
///
/// Used by subclasses of [ScrollActivity] to manipulate the scroll view that
/// they are acting upon.
///
/// See also:
///
/// * [ScrollActivity], which uses this class as its delegate.
/// * [ScrollPositionWithSingleContext], the main implementation of this interface.
abstract class ScrollActivityDelegate {
/// The direction in which the scroll view scrolls.
AxisDirection get axisDirection;
/// Update the scroll position to the given pixel value.
///
/// Returns the overscroll, if any. See [ScrollPosition.setPixels] for more
/// information.
double setPixels(double pixels);
/// Updates the scroll position by the given amount.
///
/// Appropriate for when the user is directly manipulating the scroll
/// position, for example by dragging the scroll view. Typically applies
/// [ScrollPhysics.applyPhysicsToUserOffset] and other transformations that
/// are appropriate for user-driving scrolling.
void applyUserOffset(double delta);
/// Terminate the current activity and start an idle activity.
void goIdle();
/// Terminate the current activity and start a ballistic activity with the
/// given velocity.
void goBallistic(double velocity);
}
/// Base class for scrolling activities like dragging and flinging.
///
/// See also:
///
/// * [ScrollPosition], which uses [ScrollActivity] objects to manage the
/// [ScrollPosition] of a [Scrollable].
abstract class ScrollActivity {
/// Initializes [delegate] for subclasses.
ScrollActivity(this._delegate);
/// The delegate that this activity will use to actuate the scroll view.
ScrollActivityDelegate get delegate => _delegate;
ScrollActivityDelegate _delegate;
/// Updates the activity's link to the [ScrollActivityDelegate].
///
/// This should only be called when an activity is being moved from a defunct
/// (or about-to-be defunct) [ScrollActivityDelegate] object to a new one.
void updateDelegate(ScrollActivityDelegate value) {
assert(_delegate != value);
_delegate = value;
}
/// Called by the [ScrollActivityDelegate] when it has changed type (for
/// example, when changing from an Android-style scroll position to an
/// iOS-style scroll position). If this activity can differ between the two
/// modes, then it should tell the position to restart that activity
/// appropriately.
///
/// For example, [BallisticScrollActivity]'s implementation calls
/// [ScrollActivityDelegate.goBallistic].
void resetActivity() { }
/// Dispatch a [ScrollStartNotification] with the given metrics.
void dispatchScrollStartNotification(ScrollMetrics metrics, BuildContext? context) {
ScrollStartNotification(metrics: metrics, context: context).dispatch(context);
}
/// Dispatch a [ScrollUpdateNotification] with the given metrics and scroll delta.
void dispatchScrollUpdateNotification(ScrollMetrics metrics, BuildContext context, double scrollDelta) {
ScrollUpdateNotification(metrics: metrics, context: context, scrollDelta: scrollDelta).dispatch(context);
}
/// Dispatch an [OverscrollNotification] with the given metrics and overscroll.
void dispatchOverscrollNotification(ScrollMetrics metrics, BuildContext context, double overscroll) {
OverscrollNotification(metrics: metrics, context: context, overscroll: overscroll).dispatch(context);
}
/// Dispatch a [ScrollEndNotification] with the given metrics and overscroll.
void dispatchScrollEndNotification(ScrollMetrics metrics, BuildContext context) {
ScrollEndNotification(metrics: metrics, context: context).dispatch(context);
}
/// Called when the scroll view that is performing this activity changes its metrics.
void applyNewDimensions() { }
/// Whether the scroll view should ignore pointer events while performing this
/// activity.
///
/// See also:
///
/// * [isScrolling], which describes whether the activity is considered
/// to represent user interaction or not.
bool get shouldIgnorePointer;
/// Whether performing this activity constitutes scrolling.
///
/// Used, for example, to determine whether the user scroll
/// direction (see [ScrollPosition.userScrollDirection]) is
/// [ScrollDirection.idle].
///
/// See also:
///
/// * [shouldIgnorePointer], which controls whether pointer events
/// are allowed while the activity is live.
/// * [UserScrollNotification], which exposes this status.
bool get isScrolling;
/// If applicable, the velocity at which the scroll offset is currently
/// independently changing (i.e. without external stimuli such as a dragging
/// gestures) in logical pixels per second for this activity.
double get velocity;
/// Called when the scroll view stops performing this activity.
@mustCallSuper
void dispose() { }
@override
String toString() => describeIdentity(this);
}
/// A scroll activity that does nothing.
///
/// When a scroll view is not scrolling, it is performing the idle activity.
///
/// If the [Scrollable] changes dimensions, this activity triggers a ballistic
/// activity to restore the view.
class IdleScrollActivity extends ScrollActivity {
/// Creates a scroll activity that does nothing.
IdleScrollActivity(ScrollActivityDelegate delegate) : super(delegate);
@override
void applyNewDimensions() {
delegate.goBallistic(0.0);
}
@override
bool get shouldIgnorePointer => false;
@override
bool get isScrolling => false;
@override
double get velocity => 0.0;
}
/// Interface for holding a [Scrollable] stationary.
///
/// An object that implements this interface is returned by
/// [ScrollPosition.hold]. It holds the scrollable stationary until an activity
/// is started or the [cancel] method is called.
abstract class ScrollHoldController {
/// Release the [Scrollable], potentially letting it go ballistic if
/// necessary.
void cancel();
}
/// A scroll activity that does nothing but can be released to resume
/// normal idle behavior.
///
/// This is used while the user is touching the [Scrollable] but before the
/// touch has become a [Drag].
///
/// For the purposes of [ScrollNotification]s, this activity does not constitute
/// scrolling, and does not prevent the user from interacting with the contents
/// of the [Scrollable] (unlike when a drag has begun or there is a scroll
/// animation underway).
class HoldScrollActivity extends ScrollActivity implements ScrollHoldController {
/// Creates a scroll activity that does nothing.
HoldScrollActivity({
required ScrollActivityDelegate delegate,
this.onHoldCanceled,
}) : super(delegate);
/// Called when [dispose] is called.
final VoidCallback? onHoldCanceled;
@override
bool get shouldIgnorePointer => false;
@override
bool get isScrolling => false;
@override
double get velocity => 0.0;
@override
void cancel() {
delegate.goBallistic(0.0);
}
@override
void dispose() {
if (onHoldCanceled != null)
onHoldCanceled!();
super.dispose();
}
}
/// Scrolls a scroll view as the user drags their finger across the screen.
///
/// See also:
///
/// * [DragScrollActivity], which is the activity the scroll view performs
/// while a drag is underway.
class ScrollDragController implements Drag {
/// Creates an object that scrolls a scroll view as the user drags their
/// finger across the screen.
///
/// The [delegate] and `details` arguments must not be null.
ScrollDragController({
required ScrollActivityDelegate delegate,
required DragStartDetails details,
this.onDragCanceled,
this.carriedVelocity,
this.motionStartDistanceThreshold,
}) : assert(delegate != null),
assert(details != null),
assert(
motionStartDistanceThreshold == null || motionStartDistanceThreshold > 0.0,
'motionStartDistanceThreshold must be a positive number or null'
),
_delegate = delegate,
_lastDetails = details,
_retainMomentum = carriedVelocity != null && carriedVelocity != 0.0,
_lastNonStationaryTimestamp = details.sourceTimeStamp,
_offsetSinceLastStop = motionStartDistanceThreshold == null ? null : 0.0;
/// The object that will actuate the scroll view as the user drags.
ScrollActivityDelegate get delegate => _delegate;
ScrollActivityDelegate _delegate;
/// Called when [dispose] is called.
final VoidCallback? onDragCanceled;
/// Velocity that was present from a previous [ScrollActivity] when this drag
/// began.
final double? carriedVelocity;
/// Amount of pixels in either direction the drag has to move by to start
/// scroll movement again after each time scrolling came to a stop.
final double? motionStartDistanceThreshold;
Duration? _lastNonStationaryTimestamp;
bool _retainMomentum;
/// Null if already in motion or has no [motionStartDistanceThreshold].
double? _offsetSinceLastStop;
/// Maximum amount of time interval the drag can have consecutive stationary
/// pointer update events before losing the momentum carried from a previous
/// scroll activity.
static const Duration momentumRetainStationaryDurationThreshold =
Duration(milliseconds: 20);
/// Maximum amount of time interval the drag can have consecutive stationary
/// pointer update events before needing to break the
/// [motionStartDistanceThreshold] to start motion again.
static const Duration motionStoppedDurationThreshold =
Duration(milliseconds: 50);
/// The drag distance past which, a [motionStartDistanceThreshold] breaking
/// drag is considered a deliberate fling.
static const double _bigThresholdBreakDistance = 24.0;
bool get _reversed => axisDirectionIsReversed(delegate.axisDirection);
/// Updates the controller's link to the [ScrollActivityDelegate].
///
/// This should only be called when a controller is being moved from a defunct
/// (or about-to-be defunct) [ScrollActivityDelegate] object to a new one.
void updateDelegate(ScrollActivityDelegate value) {
assert(_delegate != value);
_delegate = value;
}
/// Determines whether to lose the existing incoming velocity when starting
/// the drag.
void _maybeLoseMomentum(double offset, Duration? timestamp) {
if (_retainMomentum &&
offset == 0.0 &&
(timestamp == null || // If drag event has no timestamp, we lose momentum.
timestamp - _lastNonStationaryTimestamp! > momentumRetainStationaryDurationThreshold)) {
// If pointer is stationary for too long, we lose momentum.
_retainMomentum = false;
}
}
/// If a motion start threshold exists, determine whether the threshold needs
/// to be broken to scroll. Also possibly apply an offset adjustment when
/// threshold is first broken.
///
/// Returns `0.0` when stationary or within threshold. Returns `offset`
/// transparently when already in motion.
double _adjustForScrollStartThreshold(double offset, Duration? timestamp) {
if (timestamp == null) {
// If we can't track time, we can't apply thresholds.
// May be null for proxied drags like via accessibility.
return offset;
}
if (offset == 0.0) {
if (motionStartDistanceThreshold != null &&
_offsetSinceLastStop == null &&
timestamp - _lastNonStationaryTimestamp! > motionStoppedDurationThreshold) {
// Enforce a new threshold.
_offsetSinceLastStop = 0.0;
}
// Not moving can't break threshold.
return 0.0;
} else {
if (_offsetSinceLastStop == null) {
// Already in motion or no threshold behavior configured such as for
// Android. Allow transparent offset transmission.
return offset;
} else {
_offsetSinceLastStop = _offsetSinceLastStop! + offset;
if (_offsetSinceLastStop!.abs() > motionStartDistanceThreshold!) {
// Threshold broken.
_offsetSinceLastStop = null;
if (offset.abs() > _bigThresholdBreakDistance) {
// This is heuristically a very deliberate fling. Leave the motion
// unaffected.
return offset;
} else {
// This is a normal speed threshold break.
return math.min(
// Ease into the motion when the threshold is initially broken
// to avoid a visible jump.
motionStartDistanceThreshold! / 3.0,
offset.abs(),
) * offset.sign;
}
} else {
return 0.0;
}
}
}
}
@override
void update(DragUpdateDetails details) {
assert(details.primaryDelta != null);
_lastDetails = details;
double offset = details.primaryDelta!;
if (offset != 0.0) {
_lastNonStationaryTimestamp = details.sourceTimeStamp;
}
// By default, iOS platforms carries momentum and has a start threshold
// (configured in [BouncingScrollPhysics]). The 2 operations below are
// no-ops on Android.
_maybeLoseMomentum(offset, details.sourceTimeStamp);
offset = _adjustForScrollStartThreshold(offset, details.sourceTimeStamp);
if (offset == 0.0) {
return;
}
if (_reversed) // e.g. an AxisDirection.up scrollable
offset = -offset;
delegate.applyUserOffset(offset);
}
@override
void end(DragEndDetails details) {
assert(details.primaryVelocity != null);
// We negate the velocity here because if the touch is moving downwards,
// the scroll has to move upwards. It's the same reason that update()
// above negates the delta before applying it to the scroll offset.
double velocity = -details.primaryVelocity!;
if (_reversed) // e.g. an AxisDirection.up scrollable
velocity = -velocity;
_lastDetails = details;
// Build momentum only if dragging in the same direction.
if (_retainMomentum && velocity.sign == carriedVelocity!.sign)
velocity += carriedVelocity!;
delegate.goBallistic(velocity);
}
@override
void cancel() {
delegate.goBallistic(0.0);
}
/// Called by the delegate when it is no longer sending events to this object.
@mustCallSuper
void dispose() {
_lastDetails = null;
if (onDragCanceled != null)
onDragCanceled!();
}
/// The most recently observed [DragStartDetails], [DragUpdateDetails], or
/// [DragEndDetails] object.
dynamic get lastDetails => _lastDetails;
dynamic _lastDetails;
@override
String toString() => describeIdentity(this);
}
/// The activity a scroll view performs when a the user drags their finger
/// across the screen.
///
/// See also:
///
/// * [ScrollDragController], which listens to the [Drag] and actually scrolls
/// the scroll view.
class DragScrollActivity extends ScrollActivity {
/// Creates an activity for when the user drags their finger across the
/// screen.
DragScrollActivity(
ScrollActivityDelegate delegate,
ScrollDragController controller,
) : _controller = controller,
super(delegate);
ScrollDragController? _controller;
@override
void dispatchScrollStartNotification(ScrollMetrics metrics, BuildContext? context) {
final dynamic lastDetails = _controller!.lastDetails;
assert(lastDetails is DragStartDetails);
ScrollStartNotification(metrics: metrics, context: context, dragDetails: lastDetails as DragStartDetails).dispatch(context);
}
@override
void dispatchScrollUpdateNotification(ScrollMetrics metrics, BuildContext context, double scrollDelta) {
final dynamic lastDetails = _controller!.lastDetails;
assert(lastDetails is DragUpdateDetails);
ScrollUpdateNotification(metrics: metrics, context: context, scrollDelta: scrollDelta, dragDetails: lastDetails as DragUpdateDetails).dispatch(context);
}
@override
void dispatchOverscrollNotification(ScrollMetrics metrics, BuildContext context, double overscroll) {
final dynamic lastDetails = _controller!.lastDetails;
assert(lastDetails is DragUpdateDetails);
OverscrollNotification(metrics: metrics, context: context, overscroll: overscroll, dragDetails: lastDetails as DragUpdateDetails).dispatch(context);
}
@override
void dispatchScrollEndNotification(ScrollMetrics metrics, BuildContext context) {
// We might not have DragEndDetails yet if we're being called from beginActivity.
final dynamic lastDetails = _controller!.lastDetails;
ScrollEndNotification(
metrics: metrics,
context: context,
dragDetails: lastDetails is DragEndDetails ? lastDetails : null,
).dispatch(context);
}
@override
bool get shouldIgnorePointer => true;
@override
bool get isScrolling => true;
// DragScrollActivity is not independently changing velocity yet
// until the drag is ended.
@override
double get velocity => 0.0;
@override
void dispose() {
_controller = null;
super.dispose();
}
@override
String toString() {
return '${describeIdentity(this)}($_controller)';
}
}
/// An activity that animates a scroll view based on a physics [Simulation].
///
/// A [BallisticScrollActivity] is typically used when the user lifts their
/// finger off the screen to continue the scrolling gesture with the current velocity.
///
/// [BallisticScrollActivity] is also used to restore a scroll view to a valid
/// scroll offset when the geometry of the scroll view changes. In these
/// situations, the [Simulation] typically starts with a zero velocity.
///
/// See also:
///
/// * [DrivenScrollActivity], which animates a scroll view based on a set of
/// animation parameters.
class BallisticScrollActivity extends ScrollActivity {
/// Creates an activity that animates a scroll view based on a [simulation].
///
/// The [delegate], [simulation], and [vsync] arguments must not be null.
BallisticScrollActivity(
ScrollActivityDelegate delegate,
Simulation simulation,
TickerProvider vsync,
) : super(delegate) {
_controller = AnimationController.unbounded(
debugLabel: kDebugMode ? objectRuntimeType(this, 'BallisticScrollActivity') : null,
vsync: vsync,
)
..addListener(_tick)
..animateWith(simulation)
.whenComplete(_end); // won't trigger if we dispose _controller first
}
late AnimationController _controller;
@override
void resetActivity() {
delegate.goBallistic(velocity);
}
@override
void applyNewDimensions() {
delegate.goBallistic(velocity);
}
void _tick() {
if (!applyMoveTo(_controller.value))
delegate.goIdle();
}
/// Move the position to the given location.
///
/// If the new position was fully applied, returns true. If there was any
/// overflow, returns false.
///
/// The default implementation calls [ScrollActivityDelegate.setPixels]
/// and returns true if the overflow was zero.
@protected
bool applyMoveTo(double value) {
return delegate.setPixels(value) == 0.0;
}
void _end() {
delegate.goBallistic(0.0);
}
@override
void dispatchOverscrollNotification(ScrollMetrics metrics, BuildContext context, double overscroll) {
OverscrollNotification(metrics: metrics, context: context, overscroll: overscroll, velocity: velocity).dispatch(context);
}
@override
bool get shouldIgnorePointer => true;
@override
bool get isScrolling => true;
@override
double get velocity => _controller.velocity;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
String toString() {
return '${describeIdentity(this)}($_controller)';
}
}
/// An activity that animates a scroll view based on animation parameters.
///
/// For example, a [DrivenScrollActivity] is used to implement
/// [ScrollController.animateTo].
///
/// See also:
///
/// * [BallisticScrollActivity], which animates a scroll view based on a
/// physics [Simulation].
class DrivenScrollActivity extends ScrollActivity {
/// Creates an activity that animates a scroll view based on animation
/// parameters.
///
/// All of the parameters must be non-null.
DrivenScrollActivity(
ScrollActivityDelegate delegate, {
required double from,
required double to,
required Duration duration,
required Curve curve,
required TickerProvider vsync,
}) : assert(from != null),
assert(to != null),
assert(duration != null),
assert(duration > Duration.zero),
assert(curve != null),
super(delegate) {
_completer = Completer<void>();
_controller = AnimationController.unbounded(
value: from,
debugLabel: objectRuntimeType(this, 'DrivenScrollActivity'),
vsync: vsync,
)
..addListener(_tick)
..animateTo(to, duration: duration, curve: curve)
.whenComplete(_end); // won't trigger if we dispose _controller first
}
late final Completer<void> _completer;
late final AnimationController _controller;
/// A [Future] that completes when the activity stops.
///
/// For example, this [Future] will complete if the animation reaches the end
/// or if the user interacts with the scroll view in way that causes the
/// animation to stop before it reaches the end.
Future<void> get done => _completer.future;
void _tick() {
if (delegate.setPixels(_controller.value) != 0.0)
delegate.goIdle();
}
void _end() {
delegate.goBallistic(velocity);
}
@override
void dispatchOverscrollNotification(ScrollMetrics metrics, BuildContext context, double overscroll) {
OverscrollNotification(metrics: metrics, context: context, overscroll: overscroll, velocity: velocity).dispatch(context);
}
@override
bool get shouldIgnorePointer => true;
@override
bool get isScrolling => true;
@override
double get velocity => _controller.velocity;
@override
void dispose() {
_completer.complete();
_controller.dispose();
super.dispose();
}
@override
String toString() {
return '${describeIdentity(this)}($_controller)';
}
}