Commit dac80aac authored by Hans Muller's avatar Hans Muller Committed by GitHub

Make min/max fling velocity and min fling distance ScrollPhysics properties (#8928)

parent 14933de9
......@@ -204,12 +204,6 @@ typedef void GestureDragEndCallback(DragEndDetails details);
/// See [DragGestureRecognizer.onCancel].
typedef void GestureDragCancelCallback();
bool _isFlingGesture(Velocity velocity) {
assert(velocity != null);
final double speedSquared = velocity.pixelsPerSecond.distanceSquared;
return speedSquared > kMinFlingVelocity * kMinFlingVelocity;
}
/// Recognizes movement.
///
/// In contrast to [MultiDragGestureRecognizer], [DragGestureRecognizer]
......@@ -256,10 +250,30 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer {
/// The pointer that previously triggered [onDown] did not complete.
GestureDragCancelCallback onCancel;
/// The minimum distance an input pointer drag must have moved to
/// to be considered a fling gesture.
///
/// This value is typically compared with the distance traveled along the
/// scrolling axis. If null then [kTouchSlop] is used.
double minFlingDistance;
/// The minimum velocity for an input pointer drag to be considered fling.
///
/// This value is typically compared with the magnitude of fling gesture's
/// velocity along the scrolling axis. If null then [kMinFlingVelocity]
/// is used.
double minFlingVelocity;
/// Fling velocity magnitudes will be clamped to this value.
///
/// If null then [kMaxFlingVelocity] is used.
double maxFlingVelocity;
_DragState _state = _DragState.ready;
Point _initialPosition;
Offset _pendingDragOffset;
bool _isFlingGesture(VelocityEstimate estimate);
Offset _getDeltaForDetails(Offset delta);
double _getPrimaryValueFromOffset(Offset value);
bool get _hasSufficientPendingDragDeltaToAccept;
......@@ -345,11 +359,10 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer {
final VelocityTracker tracker = _velocityTrackers[pointer];
assert(tracker != null);
Velocity velocity = tracker.getVelocity();
if (velocity != null && _isFlingGesture(velocity)) {
final Offset pixelsPerSecond = velocity.pixelsPerSecond;
if (pixelsPerSecond.distanceSquared > kMaxFlingVelocity * kMaxFlingVelocity)
velocity = new Velocity(pixelsPerSecond: (pixelsPerSecond / pixelsPerSecond.distance) * kMaxFlingVelocity);
final VelocityEstimate estimate = tracker.getVelocityEstimate();
if (estimate != null && _isFlingGesture(estimate)) {
final Velocity velocity = new Velocity(pixelsPerSecond: estimate.pixelsPerSecond)
.clampMagnitude(minFlingVelocity ?? kMinFlingVelocity, maxFlingVelocity ?? kMaxFlingVelocity);
invokeCallback<Null>('onEnd', () => onEnd(new DragEndDetails( // ignore: STRONG_MODE_INVALID_CAST_FUNCTION_EXPR, https://github.com/dart-lang/sdk/issues/27504
velocity: velocity,
primaryVelocity: _getPrimaryValueFromOffset(velocity.pixelsPerSecond),
......@@ -379,6 +392,13 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer {
///
/// * [VerticalMultiDragGestureRecognizer]
class VerticalDragGestureRecognizer extends DragGestureRecognizer {
@override
bool _isFlingGesture(VelocityEstimate estimate) {
final double minVelocity = minFlingVelocity ?? kMinFlingVelocity;
final double minDistance = minFlingDistance ?? kTouchSlop;
return estimate.pixelsPerSecond.dy.abs() > minVelocity && estimate.offset.dy.abs() > minDistance;
}
@override
bool get _hasSufficientPendingDragDeltaToAccept => _pendingDragOffset.dy.abs() > kTouchSlop;
......@@ -400,6 +420,13 @@ class VerticalDragGestureRecognizer extends DragGestureRecognizer {
///
/// * [HorizontalMultiDragGestureRecognizer]
class HorizontalDragGestureRecognizer extends DragGestureRecognizer {
@override
bool _isFlingGesture(VelocityEstimate estimate) {
final double minVelocity = minFlingVelocity ?? kMinFlingVelocity;
final double minDistance = minFlingDistance ?? kTouchSlop;
return estimate.pixelsPerSecond.dx.abs() > minVelocity && estimate.offset.dx.abs() > minDistance;
}
@override
bool get _hasSufficientPendingDragDeltaToAccept => _pendingDragOffset.dx.abs() > kTouchSlop;
......@@ -420,6 +447,14 @@ class HorizontalDragGestureRecognizer extends DragGestureRecognizer {
/// * [ImmediateMultiDragGestureRecognizer]
/// * [DelayedMultiDragGestureRecognizer]
class PanGestureRecognizer extends DragGestureRecognizer {
@override
bool _isFlingGesture(VelocityEstimate estimate) {
final double minVelocity = minFlingVelocity ?? kMinFlingVelocity;
final double minDistance = minFlingDistance ?? kTouchSlop;
return estimate.pixelsPerSecond.distanceSquared > minVelocity * minVelocity &&
estimate.offset.distanceSquared > minDistance * minDistance;
}
@override
bool get _hasSufficientPendingDragDeltaToAccept {
return _pendingDragOffset.distance > kPanSlop;
......
......@@ -8,135 +8,6 @@ import 'lsq_solver.dart';
export 'dart:ui' show Point, Offset;
class _Estimate {
const _Estimate({ this.xCoefficients, this.yCoefficients, this.time, this.degree, this.confidence });
final List<double> xCoefficients;
final List<double> yCoefficients;
final Duration time;
final int degree;
final double confidence;
}
abstract class _VelocityTrackerStrategy {
void addMovement(Duration timeStamp, Point position);
_Estimate getEstimate();
void clear();
}
class _Movement {
const _Movement(this.eventTime, this.position);
final Duration eventTime;
final Point position;
@override
String toString() => 'Movement($position at $eventTime)';
}
// TODO: On iOS we're not necccessarily seeing all of the motion events. See:
// https://github.com/flutter/flutter/issues/4737#issuecomment-241076994
class _LeastSquaresVelocityTrackerStrategy extends _VelocityTrackerStrategy {
_LeastSquaresVelocityTrackerStrategy(this.degree);
final int degree;
final List<_Movement> _movements = new List<_Movement>(kHistorySize);
int _index = 0;
static const int kHistorySize = 20;
static const int kHorizonMilliseconds = 100;
// The maximum length of time between two move events to allow before
// assuming the pointer stopped.
static const int kAssumePointerMoveStoppedMilliseconds = 40;
@override
void addMovement(Duration timeStamp, Point position) {
_index += 1;
if (_index == kHistorySize)
_index = 0;
_movements[_index] = new _Movement(timeStamp, position);
}
@override
_Estimate getEstimate() {
// Iterate over movement samples in reverse time order and collect samples.
final List<double> x = <double>[];
final List<double> y = <double>[];
final List<double> w = <double>[];
final List<double> time = <double>[];
int m = 0;
int index = _index;
final _Movement newestMovement = _movements[index];
_Movement previousMovement = newestMovement;
if (newestMovement == null)
return null;
do {
final _Movement movement = _movements[index];
if (movement == null)
break;
final double age = (newestMovement.eventTime - movement.eventTime).inMilliseconds.toDouble();
final double delta = (movement.eventTime - previousMovement.eventTime).inMilliseconds.abs().toDouble();
previousMovement = movement;
if (age > kHorizonMilliseconds || delta > kAssumePointerMoveStoppedMilliseconds)
break;
final Point position = movement.position;
x.add(position.x);
y.add(position.y);
w.add(1.0);
time.add(-age);
index = (index == 0 ? kHistorySize : index) - 1;
m += 1;
} while (m < kHistorySize);
// Calculate a least squares polynomial fit.
int n = degree;
if (n > m - 1)
n = m - 1;
if (n >= 1) {
final LeastSquaresSolver xSolver = new LeastSquaresSolver(time, x, w);
final PolynomialFit xFit = xSolver.solve(n);
if (xFit != null) {
final LeastSquaresSolver ySolver = new LeastSquaresSolver(time, y, w);
final PolynomialFit yFit = ySolver.solve(n);
if (yFit != null) {
return new _Estimate(
xCoefficients: xFit.coefficients,
yCoefficients: yFit.coefficients,
time: newestMovement.eventTime,
degree: n,
confidence: xFit.confidence * yFit.confidence
);
}
}
}
// No velocity data available for this pointer, but we do have its current
// position.
return new _Estimate(
xCoefficients: <double>[ x[0] ],
yCoefficients: <double>[ y[0] ],
time: newestMovement.eventTime,
degree: 0,
confidence: 1.0
);
}
@override
void clear() {
_index = -1;
}
}
/// A velocity in two dimensions.
class Velocity {
/// Creates a velocity.
......@@ -165,6 +36,27 @@ class 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 new Velocity(pixelsPerSecond: (pixelsPerSecond / pixelsPerSecond.distance) * maxValue);
if (valueSquared < minValue * minValue)
return new Velocity(pixelsPerSecond: (pixelsPerSecond / pixelsPerSecond.distance) * minValue);
return this;
}
@override
bool operator ==(dynamic other) {
if (other is! Velocity)
......@@ -180,39 +72,152 @@ class Velocity {
String toString() => 'Velocity(${pixelsPerSecond.dx.toStringAsFixed(1)}, ${pixelsPerSecond.dy.toStringAsFixed(1)})';
}
/// Computes a pointer velocity based on data from PointerMove events.
/// A two dimensional velocity estimate.
///
/// VelocityEstimates are computed by [VelocityTracker.getVelocityEstimate]. An
/// estimate's [confidence] measures how well the 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.
const VelocityEstimate({
this.pixelsPerSecond,
this.confidence,
this.duration,
this.offset,
});
/// 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)})';
}
class _PointAtTime {
const _PointAtTime(this.point, this.time);
final Duration time;
final Point point;
@override
String toString() => '_PointAtTime($point at $time)';
}
/// Computes a pointer's velocity based on data from PointerMove events.
///
/// The input data is provided by calling addPosition(). Adding data
/// is cheap.
///
/// To obtain a velocity, call getVelocity(). This will compute the
/// velocity based on the data added so far. Only call this when you
/// need to use the velocity, as it is comparatively expensive.
/// To obtain a velocity, call [getVelocity] or [getVelocityEstimate].
/// This will compute the velocity based on the data added so far. Only
/// call this when you need to use the velocity, as it is comparatively
/// expensive.
///
/// The quality of the velocity estimation will be better if more data
/// points have been received.
class VelocityTracker {
/// Creates a velocity tracker.
VelocityTracker() : _strategy = _createStrategy();
// VelocityTracker is designed to easily be adapted to using different
// algorithms in the future, potentially picking algorithms on the fly based
// on hardware or other environment factors.
//
// For now, though, we just use the _LeastSquaresVelocityTrackerStrategy
// defined above.
static const int _kAssumePointerMoveStoppedMilliseconds = 40;
static const int _kHistorySize = 20;
static const int _kHorizonMilliseconds = 100;
static const int _kMinSampleSize = 3;
// TODO(ianh): Simplify this. We don't see to need multiple stategies.
// Circular buffer; current sample at _index.
final List<_PointAtTime> _samples = new List<_PointAtTime>(_kHistorySize);
int _index = 0;
static _VelocityTrackerStrategy _createStrategy() {
return new _LeastSquaresVelocityTrackerStrategy(2);
void addPosition(Duration time, Point position) {
_index += 1;
if (_index == _kHistorySize)
_index = 0;
_samples[_index] = new _PointAtTime(position, time);
}
_VelocityTrackerStrategy _strategy;
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).inMilliseconds.toDouble();
final double delta = (sample.time - previousSample.time).inMilliseconds.abs().toDouble();
previousSample = sample;
if (age > _kHorizonMilliseconds || delta > _kAssumePointerMoveStoppedMilliseconds)
break;
oldestSample = sample;
final Point position = sample.point;
x.add(position.x);
y.add(position.y);
w.add(1.0);
time.add(-age);
index = (index == 0 ? _kHistorySize : index) - 1;
sampleCount += 1;
} while (sampleCount < _kHistorySize);
/// Add a given position corresponding to a specific time.
void addPosition(Duration timeStamp, Point position) {
_strategy.addMovement(timeStamp, position);
if (sampleCount >= _kMinSampleSize) {
final LeastSquaresSolver xSolver = new LeastSquaresSolver(time, x, w);
final PolynomialFit xFit = xSolver.solve(2);
if (xFit != null) {
final LeastSquaresSolver ySolver = new LeastSquaresSolver(time, y, w);
final PolynomialFit yFit = ySolver.solve(2);
if (yFit != null) {
return new VelocityEstimate( // convert from pixels/ms to pixels/s
pixelsPerSecond: new 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 new 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
......@@ -223,15 +228,9 @@ class VelocityTracker {
/// getVelocity() will return null if no estimate is available or if
/// the velocity is zero.
Velocity getVelocity() {
final _Estimate estimate = _strategy.getEstimate();
if (estimate != null && estimate.degree >= 1) {
return new Velocity(
pixelsPerSecond: new Offset( // convert from pixels/ms to pixels/s
estimate.xCoefficients[1] * 1000,
estimate.yCoefficients[1] * 1000
)
);
}
final VelocityEstimate estimate = getVelocityEstimate();
if (estimate == null || estimate.pixelsPerSecond == Offset.zero)
return null;
return new Velocity(pixelsPerSecond: estimate.pixelsPerSecond);
}
}
......@@ -4,6 +4,7 @@
import 'dart:math' as math;
import 'package:flutter/gestures.dart' show kMinFlingVelocity;
import 'package:flutter/physics.dart';
import 'overscroll_indicator.dart';
......@@ -81,6 +82,12 @@ class BouncingScrollPhysics extends ScrollPhysics {
}
return null;
}
// The ballistic simulation here decelerates more slowly than the one for
// ClampingScrollPhysics so we require a more deliberate input gesture
// to trigger a fling.
@override
double get minFlingVelocity => kMinFlingVelocity * 2.0;
}
/// Scroll physics for environments that prevent the scroll offset from reaching
......
......@@ -106,6 +106,33 @@ abstract class ScrollPhysics {
Tolerance get tolerance => parent?.tolerance ?? _kDefaultTolerance;
/// The minimum distance an input pointer drag must have moved to
/// to be considered a scroll fling gesture.
///
/// This value is typically compared with the distance traveled along the
/// scrolling axis.
///
/// See also:
///
/// * [VelocityTracker.getVelocityEstimate], which computes the velocity
/// of a press-drag-release gesture.
double get minFlingDistance => parent?.minFlingDistance ?? kTouchSlop;
/// The minimum velocity for an input pointer drag to be considered a
/// scroll fling.
///
/// This value is typically compared with the magnitude of fling gesture's
/// velocity along the scrolling axis.
///
/// See also:
///
/// * [VelocityTracker.getVelocityEstimate], which computes the velocity
/// of a press-drag-release gesture.
double get minFlingVelocity => parent?.minFlingVelocity ?? kMinFlingVelocity;
/// Scroll fling velocity magnitudes will be clamped to this value.
double get maxFlingVelocity => parent?.maxFlingVelocity ?? kMaxFlingVelocity;
@override
String toString() {
if (parent == null)
......
......@@ -118,13 +118,14 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin
ScrollPosition _position;
ScrollBehavior _configuration;
ScrollPhysics _physics;
// only call this from places that will definitely trigger a rebuild
// Only call this from places that will definitely trigger a rebuild.
void _updatePosition() {
_configuration = ScrollConfiguration.of(context);
ScrollPhysics physics = _configuration.getScrollPhysics(context);
_physics = _configuration.getScrollPhysics(context);
if (config.physics != null)
physics = config.physics.applyTo(physics);
_physics = config.physics.applyTo(_physics);
final ScrollController controller = config.controller;
final ScrollPosition oldPosition = position;
if (oldPosition != null) {
......@@ -135,8 +136,8 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin
scheduleMicrotask(oldPosition.dispose);
}
_position = controller?.createScrollPosition(physics, this, oldPosition)
?? ScrollController.createDefaultScrollPosition(physics, this, oldPosition);
_position = controller?.createScrollPosition(_physics, this, oldPosition)
?? ScrollController.createDefaultScrollPosition(_physics, this, oldPosition);
assert(position != null);
controller?.attach(position);
}
......@@ -201,7 +202,10 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin
..onDown = _handleDragDown
..onStart = _handleDragStart
..onUpdate = _handleDragUpdate
..onEnd = _handleDragEnd;
..onEnd = _handleDragEnd
..minFlingDistance = _physics?.minFlingDistance
..minFlingVelocity = _physics?.minFlingVelocity
..maxFlingVelocity = _physics?.maxFlingVelocity;
}
};
break;
......@@ -212,7 +216,10 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin
..onDown = _handleDragDown
..onStart = _handleDragStart
..onUpdate = _handleDragUpdate
..onEnd = _handleDragEnd;
..onEnd = _handleDragEnd
..minFlingDistance = _physics?.minFlingDistance
..minFlingVelocity = _physics?.minFlingVelocity
..maxFlingVelocity = _physics?.maxFlingVelocity;
}
};
break;
......
......@@ -102,7 +102,7 @@ void main() {
expect(showBottomSheetThenCalled, isFalse);
expect(find.text('BottomSheet'), findsOneWidget);
await tester.fling(find.text('BottomSheet'), const Offset(0.0, 20.0), 1000.0);
await tester.fling(find.text('BottomSheet'), const Offset(0.0, 30.0), 1000.0);
await tester.pump(); // drain the microtask queue (Future completion callback)
expect(showBottomSheetThenCalled, isTrue);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment