// 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 'scroll_metrics.dart'; import 'scroll_notification.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() { onHoldCanceled?.call(); 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); /// The minimum amount of velocity needed to apply the [carriedVelocity] at /// the end of a drag. Expressed as a factor. For example with a /// [carriedVelocity] of 2000, we will need a velocity of at least 1000 to /// apply the [carriedVelocity] as well. If the velocity does not meet the /// threshold, the [carriedVelocity] is lost. Decided by fair eyeballing /// with the scroll_overlay platform test. static const double momentumRetainVelocityThresholdFactor = 0.5; /// 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; if (_retainMomentum) { // Build momentum only if dragging in the same direction. final bool isFlingingInSameDirection = velocity.sign == carriedVelocity!.sign; // Build momentum only if the velocity of the last drag was not // substantially lower than the carried momentum. final bool isVelocityNotSubstantiallyLessThanCarriedMomentum = velocity.abs() > carriedVelocity!.abs() * momentumRetainVelocityThresholdFactor; if(isFlingingInSameDirection && isVelocityNotSubstantiallyLessThanCarriedMomentum) { 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; onDragCanceled?.call(); } /// 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 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).abs() < precisionErrorTolerance; } 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)'; } }