blob: 3c626b950907cb0eb2c9e963550299b3d6b5623e [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 'package:flutter/foundation.dart';
import 'events.dart';
import 'lsq_solver.dart';
export 'dart:ui' show Offset;
/// A velocity in two dimensions.
@immutable
class Velocity {
/// Creates a velocity.
///
/// The [pixelsPerSecond] argument must not be null.
const Velocity({
required this.pixelsPerSecond,
}) : assert(pixelsPerSecond != null);
/// A velocity that isn't moving at all.
static const Velocity zero = Velocity(pixelsPerSecond: Offset.zero);
/// The number of pixels per second of velocity in the x and y directions.
final Offset pixelsPerSecond;
/// Return the negation of a velocity.
Velocity operator -() => Velocity(pixelsPerSecond: -pixelsPerSecond);
/// Return the difference of two velocities.
Velocity operator -(Velocity other) {
return Velocity(pixelsPerSecond: pixelsPerSecond - other.pixelsPerSecond);
}
/// Return the sum of two velocities.
Velocity operator +(Velocity other) {
return Velocity(pixelsPerSecond: pixelsPerSecond + other.pixelsPerSecond);
}
/// Return a velocity whose magnitude has been clamped to [minValue]
/// and [maxValue].
///
/// If the magnitude of this Velocity is less than minValue then return a new
/// Velocity with the same direction and with magnitude [minValue]. Similarly,
/// if the magnitude of this Velocity is greater than maxValue then return a
/// new Velocity with the same direction and magnitude [maxValue].
///
/// If the magnitude of this Velocity is within the specified bounds then
/// just return this.
Velocity clampMagnitude(double minValue, double maxValue) {
assert(minValue != null && minValue >= 0.0);
assert(maxValue != null && maxValue >= 0.0 && maxValue >= minValue);
final double valueSquared = pixelsPerSecond.distanceSquared;
if (valueSquared > maxValue * maxValue)
return Velocity(pixelsPerSecond: (pixelsPerSecond / pixelsPerSecond.distance) * maxValue);
if (valueSquared < minValue * minValue)
return Velocity(pixelsPerSecond: (pixelsPerSecond / pixelsPerSecond.distance) * minValue);
return this;
}
@override
bool operator ==(Object other) {
return other is Velocity
&& other.pixelsPerSecond == pixelsPerSecond;
}
@override
int get hashCode => pixelsPerSecond.hashCode;
@override
String toString() => 'Velocity(${pixelsPerSecond.dx.toStringAsFixed(1)}, ${pixelsPerSecond.dy.toStringAsFixed(1)})';
}
/// A two dimensional velocity estimate.
///
/// VelocityEstimates are computed by [VelocityTracker.getVelocityEstimate]. An
/// estimate's [confidence] measures how well the velocity tracker's position
/// data fit a straight line, [duration] is the time that elapsed between the
/// first and last position sample used to compute the velocity, and [offset]
/// is similarly the difference between the first and last positions.
///
/// See also:
///
/// * [VelocityTracker], which computes [VelocityEstimate]s.
/// * [Velocity], which encapsulates (just) a velocity vector and provides some
/// useful velocity operations.
class VelocityEstimate {
/// Creates a dimensional velocity estimate.
///
/// [pixelsPerSecond], [confidence], [duration], and [offset] must not be null.
const VelocityEstimate({
required this.pixelsPerSecond,
required this.confidence,
required this.duration,
required this.offset,
}) : assert(pixelsPerSecond != null),
assert(confidence != null),
assert(duration != null),
assert(offset != null);
/// The number of pixels per second of velocity in the x and y directions.
final Offset pixelsPerSecond;
/// A value between 0.0 and 1.0 that indicates how well [VelocityTracker]
/// was able to fit a straight line to its position data.
///
/// The value of this property is 1.0 for a perfect fit, 0.0 for a poor fit.
final double confidence;
/// The time that elapsed between the first and last position sample used
/// to compute [pixelsPerSecond].
final Duration duration;
/// The difference between the first and last position sample used
/// to compute [pixelsPerSecond].
final Offset offset;
@override
String toString() => 'VelocityEstimate(${pixelsPerSecond.dx.toStringAsFixed(1)}, ${pixelsPerSecond.dy.toStringAsFixed(1)}; offset: $offset, duration: $duration, confidence: ${confidence.toStringAsFixed(1)})';
}
class _PointAtTime {
const _PointAtTime(this.point, this.time)
: assert(point != null),
assert(time != null);
final Duration time;
final Offset point;
@override
String toString() => '_PointAtTime($point at $time)';
}
/// Computes a pointer's velocity based on data from [PointerMoveEvent]s.
///
/// The input data is provided by calling [addPosition]. Adding data is cheap.
///
/// To obtain a velocity, call [getVelocity] or [getVelocityEstimate]. This will
/// compute the velocity based on the data added so far. Only call these when
/// you need to use the velocity, as they are comparatively expensive.
///
/// The quality of the velocity estimation will be better if more data points
/// have been received.
class VelocityTracker {
/// Create a new velocity tracker for a pointer [kind].
@Deprecated(
'Use VelocityTracker.withKind and provide the PointerDeviceKind associated with the gesture being tracked. '
'This feature was deprecated after v1.22.0-12.1.pre.',
)
VelocityTracker([this.kind = PointerDeviceKind.touch]);
/// Create a new velocity tracker for a pointer [kind].
VelocityTracker.withKind(this.kind);
static const int _assumePointerMoveStoppedMilliseconds = 40;
static const int _historySize = 20;
static const int _horizonMilliseconds = 100;
static const int _minSampleSize = 3;
/// The kind of pointer this tracker is for.
final PointerDeviceKind kind;
// Circular buffer; current sample at _index.
final List<_PointAtTime?> _samples = List<_PointAtTime?>.filled(_historySize, null);
int _index = 0;
/// Adds a position as the given time to the tracker.
void addPosition(Duration time, Offset position) {
_index += 1;
if (_index == _historySize)
_index = 0;
_samples[_index] = _PointAtTime(position, time);
}
/// Returns an estimate of the velocity of the object being tracked by the
/// tracker given the current information available to the tracker.
///
/// Information is added using [addPosition].
///
/// Returns null if there is no data on which to base an estimate.
VelocityEstimate? getVelocityEstimate() {
final List<double> x = <double>[];
final List<double> y = <double>[];
final List<double> w = <double>[];
final List<double> time = <double>[];
int sampleCount = 0;
int index = _index;
final _PointAtTime? newestSample = _samples[index];
if (newestSample == null)
return null;
_PointAtTime previousSample = newestSample;
_PointAtTime oldestSample = newestSample;
// Starting with the most recent PointAtTime sample, iterate backwards while
// the samples represent continuous motion.
do {
final _PointAtTime? sample = _samples[index];
if (sample == null)
break;
final double age = (newestSample.time - sample.time).inMicroseconds.toDouble() / 1000;
final double delta = (sample.time - previousSample.time).inMicroseconds.abs().toDouble() / 1000;
previousSample = sample;
if (age > _horizonMilliseconds || delta > _assumePointerMoveStoppedMilliseconds)
break;
oldestSample = sample;
final Offset position = sample.point;
x.add(position.dx);
y.add(position.dy);
w.add(1.0);
time.add(-age);
index = (index == 0 ? _historySize : index) - 1;
sampleCount += 1;
} while (sampleCount < _historySize);
if (sampleCount >= _minSampleSize) {
final LeastSquaresSolver xSolver = LeastSquaresSolver(time, x, w);
final PolynomialFit? xFit = xSolver.solve(2);
if (xFit != null) {
final LeastSquaresSolver ySolver = LeastSquaresSolver(time, y, w);
final PolynomialFit? yFit = ySolver.solve(2);
if (yFit != null) {
return VelocityEstimate( // convert from pixels/ms to pixels/s
pixelsPerSecond: Offset(xFit.coefficients[1] * 1000, yFit.coefficients[1] * 1000),
confidence: xFit.confidence * yFit.confidence,
duration: newestSample.time - oldestSample.time,
offset: newestSample.point - oldestSample.point,
);
}
}
}
// We're unable to make a velocity estimate but we did have at least one
// valid pointer position.
return VelocityEstimate(
pixelsPerSecond: Offset.zero,
confidence: 1.0,
duration: newestSample.time - oldestSample.time,
offset: newestSample.point - oldestSample.point,
);
}
/// Computes the velocity of the pointer at the time of the last
/// provided data point.
///
/// This can be expensive. Only call this when you need the velocity.
///
/// Returns [Velocity.zero] if there is no data from which to compute an
/// estimate or if the estimated velocity is zero.
Velocity getVelocity() {
final VelocityEstimate? estimate = getVelocityEstimate();
if (estimate == null || estimate.pixelsPerSecond == Offset.zero)
return Velocity.zero;
return Velocity(pixelsPerSecond: estimate.pixelsPerSecond);
}
}
/// A [VelocityTracker] subclass that provides a close approximation of iOS
/// scroll view's velocity estimation strategy.
///
/// The estimated velocity reported by this class is a close approximation of
/// the velocity an iOS scroll view would report with the same
/// [PointerMoveEvent]s, when the touch that initiates a fling is released.
///
/// This class differs from the [VelocityTracker] class in that it uses weighted
/// average of the latest few velocity samples of the tracked pointer, instead
/// of doing a linear regression on a relatively large amount of data points, to
/// estimate the velocity of the tracked pointer. Adding data points and
/// estimating the velocity are both cheap.
///
/// To obtain a velocity, call [getVelocity] or [getVelocityEstimate]. The
/// estimated velocity is typically used as the initial flinging velocity of a
/// `Scrollable`, when its drag gesture ends.
///
/// See also:
///
/// * [scrollViewWillEndDragging(_:withVelocity:targetContentOffset:)](https://developer.apple.com/documentation/uikit/uiscrollviewdelegate/1619385-scrollviewwillenddragging),
/// the iOS method that reports the fling velocity when the touch is released.
class IOSScrollViewFlingVelocityTracker extends VelocityTracker {
/// Create a new IOSScrollViewFlingVelocityTracker.
IOSScrollViewFlingVelocityTracker(PointerDeviceKind kind) : super.withKind(kind);
/// The velocity estimation uses at most 4 `_PointAtTime` samples. The extra
/// samples are there to make the `VelocityEstimate.offset` sufficiently large
/// to be recognized as a fling. See
/// `VerticalDragGestureRecognizer.isFlingGesture`.
static const int _sampleSize = 20;
final List<_PointAtTime?> _touchSamples = List<_PointAtTime?>.filled(_sampleSize, null);
@override
void addPosition(Duration time, Offset position) {
assert(() {
final _PointAtTime? previousPoint = _touchSamples[_index];
if (previousPoint == null || previousPoint.time <= time)
return true;
throw FlutterError(
'The position being added ($position) has a smaller timestamp ($time) '
'than its predecessor: $previousPoint.',
);
}());
_index = (_index + 1) % _sampleSize;
_touchSamples[_index] = _PointAtTime(position, time);
}
// Computes the velocity using 2 adjacent points in history. When index = 0,
// it uses the latest point recorded and the point recorded immediately before
// it. The smaller index is, the earlier in history the points used are.
Offset _previousVelocityAt(int index) {
final int endIndex = (_index + index) % _sampleSize;
final int startIndex = (_index + index - 1) % _sampleSize;
final _PointAtTime? end = _touchSamples[endIndex];
final _PointAtTime? start = _touchSamples[startIndex];
if (end == null || start == null) {
return Offset.zero;
}
final int dt = (end.time - start.time).inMicroseconds;
assert(dt >= 0);
return dt > 0
// Convert dt to milliseconds to preserve floating point precision.
? (end.point - start.point) * 1000 / (dt.toDouble() / 1000)
: Offset.zero;
}
@override
VelocityEstimate getVelocityEstimate() {
// The velocity estimated using this expression is an approximation of the
// scroll velocity of an iOS scroll view at the moment the user touch was
// released, not the final velocity of the iOS pan gesture recognizer
// installed on the scroll view would report. Typically in an iOS scroll
// view the velocity values are different between the two, because the
// scroll view usually slows down when the touch is released.
final Offset estimatedVelocity = _previousVelocityAt(-2) * 0.6
+ _previousVelocityAt(-1) * 0.35
+ _previousVelocityAt(0) * 0.05;
final _PointAtTime? newestSample = _touchSamples[_index];
_PointAtTime? oldestNonNullSample;
for (int i = 1; i <= _sampleSize; i += 1) {
oldestNonNullSample = _touchSamples[(_index + i) % _sampleSize];
if (oldestNonNullSample != null)
break;
}
if (oldestNonNullSample == null || newestSample == null) {
assert(false, 'There must be at least 1 point in _touchSamples: $_touchSamples');
return const VelocityEstimate(
pixelsPerSecond: Offset.zero,
confidence: 0.0,
duration: Duration.zero,
offset: Offset.zero,
);
} else {
return VelocityEstimate(
pixelsPerSecond: estimatedVelocity,
confidence: 1.0,
duration: newestSample.time - oldestNonNullSample.time,
offset: newestSample.point - oldestNonNullSample.point,
);
}
}
}