blob: 9ce7ecdcc53daf9f66dc65046372391ded74be5f [file] [log] [blame]
// 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/rendering.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(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) {
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) {
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(
super.delegate,
ScrollDragController controller,
) : _controller = controller;
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(
super.delegate,
Simulation simulation,
TickerProvider vsync,
) {
_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(
super.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) {
_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)';
}
}