Unverified Commit 70b19ff9 authored by Callum Moffat's avatar Callum Moffat Committed by GitHub

Add macOS-specific scroll physics (#108298)

parent df110ef0
......@@ -480,6 +480,9 @@ class CupertinoScrollBehavior extends ScrollBehavior {
@override
ScrollPhysics getScrollPhysics(BuildContext context) {
if (getPlatform(context) == TargetPlatform.macOS) {
return const BouncingScrollPhysics(decelerationRate: ScrollDecelerationRate.fast);
}
return const BouncingScrollPhysics();
}
}
......
......@@ -373,3 +373,61 @@ class IOSScrollViewFlingVelocityTracker extends VelocityTracker {
}
}
}
/// A [VelocityTracker] subclass that provides a close approximation of macOS
/// scroll view's velocity estimation strategy.
///
/// The estimated velocity reported by this class is a close approximation of
/// the velocity a macOS 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.
class MacOSScrollViewFlingVelocityTracker extends IOSScrollViewFlingVelocityTracker {
/// Create a new MacOSScrollViewFlingVelocityTracker.
MacOSScrollViewFlingVelocityTracker(super.kind);
@override
VelocityEstimate getVelocityEstimate() {
// The velocity estimated using this expression is an approximation of the
// scroll velocity of a macOS scroll view at the moment the user touch was
// released.
final Offset estimatedVelocity = _previousVelocityAt(-2) * 0.15
+ _previousVelocityAt(-1) * 0.65
+ _previousVelocityAt(0) * 0.2;
final _PointAtTime? newestSample = _touchSamples[_index];
_PointAtTime? oldestNonNullSample;
for (int i = 1; i <= IOSScrollViewFlingVelocityTracker._sampleSize; i += 1) {
oldestNonNullSample = _touchSamples[(_index + i) % IOSScrollViewFlingVelocityTracker._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,
);
}
}
}
......@@ -10,6 +10,22 @@ import 'simulation.dart';
export 'tolerance.dart' show Tolerance;
/// Numerically determine the input value which produces output value [target]
/// for a function [f], given its first-derivative [df].
double _newtonsMethod({
required double initialGuess,
required double target,
required double Function(double) f,
required double Function(double) df,
required int iterations
}) {
double guess = initialGuess;
for (int i = 0; i < iterations; i++) {
guess = guess - (f(guess) - target) / df(guess);
}
return guess;
}
/// A simulation that applies a drag to slow a particle down.
///
/// Models a particle affected by fluid drag, e.g. air resistance.
......@@ -26,10 +42,20 @@ class FrictionSimulation extends Simulation {
double position,
double velocity, {
super.tolerance,
double constantDeceleration = 0
}) : _drag = drag,
_dragLog = math.log(drag),
_x = position,
_v = velocity;
_v = velocity,
_constantDeceleration = constantDeceleration * velocity.sign {
_finalTime = _newtonsMethod(
initialGuess: 0,
target: 0,
f: dx,
df: (double time) => (_v * math.pow(_drag, time) * _dragLog) - _constantDeceleration,
iterations: 10
);
}
/// Creates a new friction simulation with its fluid drag coefficient (_cₓ_) set so
/// as to ensure that the simulation starts and ends at the specified
......@@ -58,6 +84,12 @@ class FrictionSimulation extends Simulation {
final double _dragLog;
final double _x;
final double _v;
final double _constantDeceleration;
// The time at which the simulation should be stopped.
// This is needed when constantDeceleration is not zero (on Desktop), when
// using the pure friction simulation, acceleration naturally reduces to zero
// and creates a stopping point.
double _finalTime = double.infinity; // needs to be infinity for newtonsMethod call in constructor.
// Return the drag value for a FrictionSimulation whose x() and dx() values pass
// through the specified start and end position/velocity values.
......@@ -71,13 +103,28 @@ class FrictionSimulation extends Simulation {
}
@override
double x(double time) => _x + _v * math.pow(_drag, time) / _dragLog - _v / _dragLog;
double x(double time) {
if (time > _finalTime) {
return finalX;
}
return _x + _v * math.pow(_drag, time) / _dragLog - _v / _dragLog - ((_constantDeceleration / 2) * time * time);
}
@override
double dx(double time) => _v * math.pow(_drag, time);
double dx(double time) {
if (time > _finalTime) {
return 0;
}
return _v * math.pow(_drag, time) - _constantDeceleration * time;
}
/// The value of [x] at `double.infinity`.
double get finalX => _x - _v / _dragLog;
double get finalX {
if (_constantDeceleration == 0) {
return _x - _v / _dragLog;
}
return x(_finalTime);
}
/// The time at which the value of `x(time)` will equal [x].
///
......@@ -89,11 +136,19 @@ class FrictionSimulation extends Simulation {
if (_v == 0.0 || (_v > 0 ? (x < _x || x > finalX) : (x > _x || x < finalX))) {
return double.infinity;
}
return math.log(_dragLog * (x - _x) / _v + 1.0) / _dragLog;
return _newtonsMethod(
target: x,
initialGuess: 0,
f: this.x,
df: dx,
iterations: 10
);
}
@override
bool isDone(double time) => dx(time).abs() < tolerance.velocity;
bool isDone(double time) {
return dx(time).abs() < tolerance.velocity;
}
@override
String toString() => '${objectRuntimeType(this, 'FrictionSimulation')}(cₓ: ${_drag.toStringAsFixed(1)}, x₀: ${_x.toStringAsFixed(1)}, dx₀: ${_v.toStringAsFixed(1)})';
......
......@@ -219,8 +219,9 @@ class ScrollBehavior {
GestureVelocityTrackerBuilder velocityTrackerBuilder(BuildContext context) {
switch (getPlatform(context)) {
case TargetPlatform.iOS:
case TargetPlatform.macOS:
return (PointerEvent event) => IOSScrollViewFlingVelocityTracker(event.kind);
case TargetPlatform.macOS:
return (PointerEvent event) => MacOSScrollViewFlingVelocityTracker(event.kind);
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
......@@ -230,6 +231,10 @@ class ScrollBehavior {
}
static const ScrollPhysics _bouncingPhysics = BouncingScrollPhysics(parent: RangeMaintainingScrollPhysics());
static const ScrollPhysics _bouncingDesktopPhysics = BouncingScrollPhysics(
decelerationRate: ScrollDecelerationRate.fast,
parent: RangeMaintainingScrollPhysics()
);
static const ScrollPhysics _clampingPhysics = ClampingScrollPhysics(parent: RangeMaintainingScrollPhysics());
/// The scroll physics to use for the platform given by [getPlatform].
......@@ -240,8 +245,9 @@ class ScrollBehavior {
ScrollPhysics getScrollPhysics(BuildContext context) {
switch (getPlatform(context)) {
case TargetPlatform.iOS:
case TargetPlatform.macOS:
return _bouncingPhysics;
case TargetPlatform.macOS:
return _bouncingDesktopPhysics;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
......
......@@ -16,6 +16,17 @@ import 'scroll_simulation.dart';
export 'package:flutter/physics.dart' show ScrollSpringSimulation, Simulation, Tolerance;
/// The rate at which scroll momentum will be decelerated.
enum ScrollDecelerationRate {
/// Standard deceleration, aligned with mobile software expectations.
normal,
/// Increased deceleration, aligned with desktop software expectations.
///
/// Appropriate for use with input devices more precise than touch screens,
/// such as trackpads or mouse wheels.
fast
}
// Examples can assume:
// class FooScrollPhysics extends ScrollPhysics {
// const FooScrollPhysics({ super.parent });
......@@ -608,7 +619,13 @@ class RangeMaintainingScrollPhysics extends ScrollPhysics {
/// of different types to get the desired scroll physics.
class BouncingScrollPhysics extends ScrollPhysics {
/// Creates scroll physics that bounce back from the edge.
const BouncingScrollPhysics({ super.parent });
const BouncingScrollPhysics({
this.decelerationRate = ScrollDecelerationRate.normal,
super.parent,
});
/// Used to determine parameters for friction simulations.
final ScrollDecelerationRate decelerationRate;
@override
BouncingScrollPhysics applyTo(ScrollPhysics? ancestor) {
......@@ -623,7 +640,14 @@ class BouncingScrollPhysics extends ScrollPhysics {
/// This factor starts at 0.52 and progressively becomes harder to overscroll
/// as more of the area past the edge is dragged in (represented by an increasing
/// `overscrollFraction` which starts at 0 when there is no overscroll).
double frictionFactor(double overscrollFraction) => 0.52 * math.pow(1 - overscrollFraction, 2);
double frictionFactor(double overscrollFraction) {
switch (decelerationRate) {
case ScrollDecelerationRate.fast:
return 0.07 * math.pow(1 - overscrollFraction, 2);
case ScrollDecelerationRate.normal:
return 0.52 * math.pow(1 - overscrollFraction, 2);
}
}
@override
double applyPhysicsToUserOffset(ScrollMetrics position, double offset) {
......@@ -670,6 +694,15 @@ class BouncingScrollPhysics extends ScrollPhysics {
Simulation? createBallisticSimulation(ScrollMetrics position, double velocity) {
final Tolerance tolerance = this.tolerance;
if (velocity.abs() >= tolerance.velocity || position.outOfRange) {
double constantDeceleration;
switch (decelerationRate) {
case ScrollDecelerationRate.fast:
constantDeceleration = 1400;
break;
case ScrollDecelerationRate.normal:
constantDeceleration = 0;
break;
}
return BouncingScrollSimulation(
spring: spring,
position: position.pixels,
......@@ -677,6 +710,7 @@ class BouncingScrollPhysics extends ScrollPhysics {
leadingExtent: position.minScrollExtent,
trailingExtent: position.maxScrollExtent,
tolerance: tolerance,
constantDeceleration: constantDeceleration
);
}
return null;
......@@ -711,6 +745,30 @@ class BouncingScrollPhysics extends ScrollPhysics {
// from the natural motion of lifting the finger after a scroll.
@override
double get dragStartDistanceMotionThreshold => 3.5;
@override
double get maxFlingVelocity {
switch (decelerationRate) {
case ScrollDecelerationRate.fast:
return kMaxFlingVelocity * 8.0;
case ScrollDecelerationRate.normal:
return super.maxFlingVelocity;
}
}
@override
SpringDescription get spring {
switch (decelerationRate) {
case ScrollDecelerationRate.fast:
return SpringDescription.withDampingRatio(
mass: 0.3,
stiffness: 75.0,
ratio: 1.3,
);
case ScrollDecelerationRate.normal:
return super.spring;
}
}
}
/// Scroll physics for environments that prevent the scroll offset from reaching
......
......@@ -34,6 +34,7 @@ class BouncingScrollSimulation extends Simulation {
required this.leadingExtent,
required this.trailingExtent,
required this.spring,
double constantDeceleration = 0,
super.tolerance,
}) : assert(position != null),
assert(velocity != null),
......@@ -50,7 +51,7 @@ class BouncingScrollSimulation extends Simulation {
} else {
// Taken from UIScrollView.decelerationRate (.normal = 0.998)
// 0.998^1000 = ~0.135
_frictionSimulation = FrictionSimulation(0.135, position, velocity);
_frictionSimulation = FrictionSimulation(0.135, position, velocity, constantDeceleration: constantDeceleration);
final double finalX = _frictionSimulation.finalX;
if (velocity > 0.0 && finalX > trailingExtent) {
_springTime = _frictionSimulation.timeAtX(trailingExtent);
......
......@@ -3,6 +3,7 @@
// found in the LICENSE file.
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
......@@ -397,9 +398,11 @@ void main() {
warnIfMissed: false, // has an IgnorePointer
);
// Should have been flung far enough that even the first item goes off
// screen and gets removed.
expect(find.widgetWithText(SizedBox, '0').evaluate().isEmpty, true);
if (debugDefaultTargetPlatformOverride == TargetPlatform.iOS) {
// Should have been flung far enough that even the first item goes off
// screen and gets removed.
expect(find.widgetWithText(SizedBox, '0').evaluate().isEmpty, true);
}
expect(
selectedItems,
......@@ -421,7 +424,7 @@ void main() {
// Falling back to 0 shouldn't produce more callbacks.
<int>[8, 6, 4, 2, 0],
);
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
});
testWidgets('Picker adapts to MaterialApp dark mode', (WidgetTester tester) async {
......
......@@ -4,6 +4,7 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
......@@ -406,12 +407,17 @@ void main() {
),
);
await tester.fling(find.text('A'), const Offset(0.0, 300.0), 1000.0);
if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) {
await tester.fling(find.text('A'), const Offset(0.0, 1500.0), 10000.0);
}
else {
await tester.fling(find.text('A'), const Offset(0.0, 300.0), 1000.0);
}
await tester.pump(const Duration(milliseconds: 100));
expect(lastScrollOffset = controller.offset, lessThan(0.0));
expect(refreshCalled, isFalse);
await tester.pump(const Duration(milliseconds: 100));
await tester.pump(const Duration(milliseconds: 400));
expect(controller.offset, greaterThan(lastScrollOffset));
expect(controller.offset, lessThan(0.0));
expect(refreshCalled, isTrue);
......
......@@ -47,4 +47,25 @@ void main() {
expect(friction.timeAtX(101.0), double.infinity);
expect(friction.timeAtX(40.0), double.infinity);
});
test('Friction simulation constant deceleration', () {
final FrictionSimulation friction = FrictionSimulation(0.135, 100.0, -100.0, constantDeceleration: 100);
expect(friction.x(0.0), moreOrLessEquals(100.0));
expect(friction.dx(0.0), moreOrLessEquals(-100.0));
expect(friction.x(0.1), moreOrLessEquals(91.0, epsilon: 1.0));
expect(friction.x(0.5), moreOrLessEquals(80.0, epsilon: 1.0));
expect(friction.x(2.0), moreOrLessEquals(80.0, epsilon: 1.0));
expect(friction.finalX, moreOrLessEquals(80.0, epsilon: 1.0));
expect(friction.timeAtX(100.0), 0.0);
expect(friction.timeAtX(friction.x(0.1)), moreOrLessEquals(0.1));
expect(friction.timeAtX(friction.x(0.2)), moreOrLessEquals(0.2));
expect(friction.timeAtX(friction.x(0.3)), moreOrLessEquals(0.3));
expect(friction.timeAtX(101.0), double.infinity);
expect(friction.timeAtX(40.0), double.infinity);
});
}
......@@ -287,7 +287,12 @@ void main() {
expect(taps, 1);
expect(find.text('Item 1'), findsNothing);
expect(find.text('Item 21'), findsNothing);
expect(find.text('Item 70'), findsOneWidget);
if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) {
expect(find.text('Item 40'), findsOneWidget);
}
else {
expect(find.text('Item 70'), findsOneWidget);
}
}, variant: TargetPlatformVariant.all());
testWidgets('Can be flung down when not full height', (WidgetTester tester) async {
......@@ -321,9 +326,14 @@ void main() {
expect(taps, 1);
expect(find.text('Item 1'), findsNothing);
expect(find.text('Item 21'), findsNothing);
expect(find.text('Item 70'), findsOneWidget);
await tester.fling(find.text('Item 70'), const Offset(0, 200), 2000);
if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) {
expect(find.text('Item 40'), findsOneWidget);
await tester.fling(find.text('Item 40'), const Offset(0, 200), 2000);
}
else {
expect(find.text('Item 70'), findsOneWidget);
await tester.fling(find.text('Item 70'), const Offset(0, 200), 2000);
}
await tester.pumpAndSettle();
expect(find.text('TapHere'), findsOneWidget);
await tester.tap(find.text('TapHere'), warnIfMissed: false);
......
......@@ -6,6 +6,7 @@
// machines.
@Tags(<String>['reduced-test-set'])
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
......@@ -1352,7 +1353,8 @@ void main() {
find.byType(ListWheelScrollView),
// High and random numbers that's unlikely to land on exact multiples of 100.
const Offset(0.0, -567.0),
678.0,
// macOS has reduced ballistic distance, need to increase speed to compensate.
debugDefaultTargetPlatformOverride == TargetPlatform.macOS ? 1678.0 : 678.0,
);
// After the drag, 40 + 567px should be on the 46th item.
......
......@@ -227,7 +227,12 @@ void main() {
expect(find.text('aaa2'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 250));
final Offset point1 = tester.getCenter(find.text('aaa1'));
await tester.dragFrom(point1, const Offset(0.0, 200.0));
if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) {
await tester.dragFrom(point1, const Offset(0.0, 400.0));
}
else {
await tester.dragFrom(point1, const Offset(0.0, 200.0));
}
await tester.pump();
expect(
tester.renderObject<RenderBox>(find.byType(AppBar)).size.height,
......@@ -245,7 +250,12 @@ void main() {
expect(find.text('aaa2'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 250));
final Offset point = tester.getCenter(find.text('aaa1'));
await tester.flingFrom(point, const Offset(0.0, 200.0), 5000.0);
if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) {
await tester.flingFrom(point, const Offset(0.0, 200.0), 15000.0);
}
else {
await tester.flingFrom(point, const Offset(0.0, 200.0), 5000.0);
}
await tester.pump(const Duration(milliseconds: 10));
await tester.pump(const Duration(milliseconds: 10));
await tester.pump(const Duration(milliseconds: 10));
......
......@@ -2,6 +2,7 @@
// 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 'package:flutter/gestures.dart' show DragStartBehavior;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
......@@ -271,7 +272,12 @@ void main() {
expect(sizeOf(0), equals(const Size(800.0, 600.0)));
// Easing overscroll past overscroll limit.
await tester.drag(find.byType(PageView), const Offset(-200.0, 0.0));
if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) {
await tester.drag(find.byType(PageView), const Offset(-500.0, 0.0));
}
else {
await tester.drag(find.byType(PageView), const Offset(-200.0, 0.0));
}
await tester.pump();
expect(leftOf(0), lessThan(0.0));
......
......@@ -85,11 +85,12 @@ void main() {
final double macOSResult = getCurrentOffset();
expect(androidResult, lessThan(iOSResult)); // iOS is slipperier than Android
expect(androidResult, lessThan(macOSResult)); // macOS is slipperier than Android
expect(macOSResult, lessThan(iOSResult)); // iOS is slipperier than macOS
expect(macOSResult, lessThan(androidResult)); // Android is slipperier than macOS
expect(linuxResult, lessThan(iOSResult)); // iOS is slipperier than Linux
expect(linuxResult, lessThan(macOSResult)); // macOS is slipperier than Linux
expect(macOSResult, lessThan(linuxResult)); // Linux is slipperier than macOS
expect(windowsResult, lessThan(iOSResult)); // iOS is slipperier than Windows
expect(windowsResult, lessThan(macOSResult)); // macOS is slipperier than Windows
expect(macOSResult, lessThan(windowsResult)); // Windows is slipperier than macOS
expect(windowsResult, equals(androidResult));
expect(windowsResult, equals(androidResult));
expect(linuxResult, equals(androidResult));
......
......@@ -134,8 +134,9 @@ void main() {
await tester.pump(const Duration(seconds: 5));
final double macOSResult = getScrollOffset(tester);
expect(macOSResult, lessThan(androidResult)); // macOS is slipperier than Android
expect(androidResult, lessThan(iOSResult)); // iOS is slipperier than Android
expect(androidResult, lessThan(macOSResult)); // macOS is slipperier than Android
expect(macOSResult, lessThan(iOSResult)); // iOS is slipperier than macOS
});
testWidgets('Holding scroll', (WidgetTester tester) async {
......
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