Skip to content

Commit 70b19ff

Browse files
authored
Add macOS-specific scroll physics (#108298)
1 parent df110ef commit 70b19ff

16 files changed

+513
-107
lines changed

packages/flutter/lib/src/cupertino/app.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,9 @@ class CupertinoScrollBehavior extends ScrollBehavior {
480480

481481
@override
482482
ScrollPhysics getScrollPhysics(BuildContext context) {
483+
if (getPlatform(context) == TargetPlatform.macOS) {
484+
return const BouncingScrollPhysics(decelerationRate: ScrollDecelerationRate.fast);
485+
}
483486
return const BouncingScrollPhysics();
484487
}
485488
}

packages/flutter/lib/src/gestures/velocity_tracker.dart

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,3 +373,61 @@ class IOSScrollViewFlingVelocityTracker extends VelocityTracker {
373373
}
374374
}
375375
}
376+
377+
/// A [VelocityTracker] subclass that provides a close approximation of macOS
378+
/// scroll view's velocity estimation strategy.
379+
///
380+
/// The estimated velocity reported by this class is a close approximation of
381+
/// the velocity a macOS scroll view would report with the same
382+
/// [PointerMoveEvent]s, when the touch that initiates a fling is released.
383+
///
384+
/// This class differs from the [VelocityTracker] class in that it uses weighted
385+
/// average of the latest few velocity samples of the tracked pointer, instead
386+
/// of doing a linear regression on a relatively large amount of data points, to
387+
/// estimate the velocity of the tracked pointer. Adding data points and
388+
/// estimating the velocity are both cheap.
389+
///
390+
/// To obtain a velocity, call [getVelocity] or [getVelocityEstimate]. The
391+
/// estimated velocity is typically used as the initial flinging velocity of a
392+
/// `Scrollable`, when its drag gesture ends.
393+
class MacOSScrollViewFlingVelocityTracker extends IOSScrollViewFlingVelocityTracker {
394+
/// Create a new MacOSScrollViewFlingVelocityTracker.
395+
MacOSScrollViewFlingVelocityTracker(super.kind);
396+
397+
@override
398+
VelocityEstimate getVelocityEstimate() {
399+
// The velocity estimated using this expression is an approximation of the
400+
// scroll velocity of a macOS scroll view at the moment the user touch was
401+
// released.
402+
final Offset estimatedVelocity = _previousVelocityAt(-2) * 0.15
403+
+ _previousVelocityAt(-1) * 0.65
404+
+ _previousVelocityAt(0) * 0.2;
405+
406+
final _PointAtTime? newestSample = _touchSamples[_index];
407+
_PointAtTime? oldestNonNullSample;
408+
409+
for (int i = 1; i <= IOSScrollViewFlingVelocityTracker._sampleSize; i += 1) {
410+
oldestNonNullSample = _touchSamples[(_index + i) % IOSScrollViewFlingVelocityTracker._sampleSize];
411+
if (oldestNonNullSample != null) {
412+
break;
413+
}
414+
}
415+
416+
if (oldestNonNullSample == null || newestSample == null) {
417+
assert(false, 'There must be at least 1 point in _touchSamples: $_touchSamples');
418+
return const VelocityEstimate(
419+
pixelsPerSecond: Offset.zero,
420+
confidence: 0.0,
421+
duration: Duration.zero,
422+
offset: Offset.zero,
423+
);
424+
} else {
425+
return VelocityEstimate(
426+
pixelsPerSecond: estimatedVelocity,
427+
confidence: 1.0,
428+
duration: newestSample.time - oldestNonNullSample.time,
429+
offset: newestSample.point - oldestNonNullSample.point,
430+
);
431+
}
432+
}
433+
}

packages/flutter/lib/src/physics/friction_simulation.dart

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,22 @@ import 'simulation.dart';
1010

1111
export 'tolerance.dart' show Tolerance;
1212

13+
/// Numerically determine the input value which produces output value [target]
14+
/// for a function [f], given its first-derivative [df].
15+
double _newtonsMethod({
16+
required double initialGuess,
17+
required double target,
18+
required double Function(double) f,
19+
required double Function(double) df,
20+
required int iterations
21+
}) {
22+
double guess = initialGuess;
23+
for (int i = 0; i < iterations; i++) {
24+
guess = guess - (f(guess) - target) / df(guess);
25+
}
26+
return guess;
27+
}
28+
1329
/// A simulation that applies a drag to slow a particle down.
1430
///
1531
/// Models a particle affected by fluid drag, e.g. air resistance.
@@ -26,10 +42,20 @@ class FrictionSimulation extends Simulation {
2642
double position,
2743
double velocity, {
2844
super.tolerance,
45+
double constantDeceleration = 0
2946
}) : _drag = drag,
3047
_dragLog = math.log(drag),
3148
_x = position,
32-
_v = velocity;
49+
_v = velocity,
50+
_constantDeceleration = constantDeceleration * velocity.sign {
51+
_finalTime = _newtonsMethod(
52+
initialGuess: 0,
53+
target: 0,
54+
f: dx,
55+
df: (double time) => (_v * math.pow(_drag, time) * _dragLog) - _constantDeceleration,
56+
iterations: 10
57+
);
58+
}
3359

3460
/// Creates a new friction simulation with its fluid drag coefficient (_cₓ_) set so
3561
/// as to ensure that the simulation starts and ends at the specified
@@ -58,6 +84,12 @@ class FrictionSimulation extends Simulation {
5884
final double _dragLog;
5985
final double _x;
6086
final double _v;
87+
final double _constantDeceleration;
88+
// The time at which the simulation should be stopped.
89+
// This is needed when constantDeceleration is not zero (on Desktop), when
90+
// using the pure friction simulation, acceleration naturally reduces to zero
91+
// and creates a stopping point.
92+
double _finalTime = double.infinity; // needs to be infinity for newtonsMethod call in constructor.
6193

6294
// Return the drag value for a FrictionSimulation whose x() and dx() values pass
6395
// through the specified start and end position/velocity values.
@@ -71,13 +103,28 @@ class FrictionSimulation extends Simulation {
71103
}
72104

73105
@override
74-
double x(double time) => _x + _v * math.pow(_drag, time) / _dragLog - _v / _dragLog;
106+
double x(double time) {
107+
if (time > _finalTime) {
108+
return finalX;
109+
}
110+
return _x + _v * math.pow(_drag, time) / _dragLog - _v / _dragLog - ((_constantDeceleration / 2) * time * time);
111+
}
75112

76113
@override
77-
double dx(double time) => _v * math.pow(_drag, time);
114+
double dx(double time) {
115+
if (time > _finalTime) {
116+
return 0;
117+
}
118+
return _v * math.pow(_drag, time) - _constantDeceleration * time;
119+
}
78120

79121
/// The value of [x] at `double.infinity`.
80-
double get finalX => _x - _v / _dragLog;
122+
double get finalX {
123+
if (_constantDeceleration == 0) {
124+
return _x - _v / _dragLog;
125+
}
126+
return x(_finalTime);
127+
}
81128

82129
/// The time at which the value of `x(time)` will equal [x].
83130
///
@@ -89,11 +136,19 @@ class FrictionSimulation extends Simulation {
89136
if (_v == 0.0 || (_v > 0 ? (x < _x || x > finalX) : (x > _x || x < finalX))) {
90137
return double.infinity;
91138
}
92-
return math.log(_dragLog * (x - _x) / _v + 1.0) / _dragLog;
139+
return _newtonsMethod(
140+
target: x,
141+
initialGuess: 0,
142+
f: this.x,
143+
df: dx,
144+
iterations: 10
145+
);
93146
}
94147

95148
@override
96-
bool isDone(double time) => dx(time).abs() < tolerance.velocity;
149+
bool isDone(double time) {
150+
return dx(time).abs() < tolerance.velocity;
151+
}
97152

98153
@override
99154
String toString() => '${objectRuntimeType(this, 'FrictionSimulation')}(cₓ: ${_drag.toStringAsFixed(1)}, x₀: ${_x.toStringAsFixed(1)}, dx₀: ${_v.toStringAsFixed(1)})';

packages/flutter/lib/src/widgets/scroll_configuration.dart

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,8 +219,9 @@ class ScrollBehavior {
219219
GestureVelocityTrackerBuilder velocityTrackerBuilder(BuildContext context) {
220220
switch (getPlatform(context)) {
221221
case TargetPlatform.iOS:
222-
case TargetPlatform.macOS:
223222
return (PointerEvent event) => IOSScrollViewFlingVelocityTracker(event.kind);
223+
case TargetPlatform.macOS:
224+
return (PointerEvent event) => MacOSScrollViewFlingVelocityTracker(event.kind);
224225
case TargetPlatform.android:
225226
case TargetPlatform.fuchsia:
226227
case TargetPlatform.linux:
@@ -230,6 +231,10 @@ class ScrollBehavior {
230231
}
231232

232233
static const ScrollPhysics _bouncingPhysics = BouncingScrollPhysics(parent: RangeMaintainingScrollPhysics());
234+
static const ScrollPhysics _bouncingDesktopPhysics = BouncingScrollPhysics(
235+
decelerationRate: ScrollDecelerationRate.fast,
236+
parent: RangeMaintainingScrollPhysics()
237+
);
233238
static const ScrollPhysics _clampingPhysics = ClampingScrollPhysics(parent: RangeMaintainingScrollPhysics());
234239

235240
/// The scroll physics to use for the platform given by [getPlatform].
@@ -240,8 +245,9 @@ class ScrollBehavior {
240245
ScrollPhysics getScrollPhysics(BuildContext context) {
241246
switch (getPlatform(context)) {
242247
case TargetPlatform.iOS:
243-
case TargetPlatform.macOS:
244248
return _bouncingPhysics;
249+
case TargetPlatform.macOS:
250+
return _bouncingDesktopPhysics;
245251
case TargetPlatform.android:
246252
case TargetPlatform.fuchsia:
247253
case TargetPlatform.linux:

packages/flutter/lib/src/widgets/scroll_physics.dart

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,17 @@ import 'scroll_simulation.dart';
1616

1717
export 'package:flutter/physics.dart' show ScrollSpringSimulation, Simulation, Tolerance;
1818

19+
/// The rate at which scroll momentum will be decelerated.
20+
enum ScrollDecelerationRate {
21+
/// Standard deceleration, aligned with mobile software expectations.
22+
normal,
23+
/// Increased deceleration, aligned with desktop software expectations.
24+
///
25+
/// Appropriate for use with input devices more precise than touch screens,
26+
/// such as trackpads or mouse wheels.
27+
fast
28+
}
29+
1930
// Examples can assume:
2031
// class FooScrollPhysics extends ScrollPhysics {
2132
// const FooScrollPhysics({ super.parent });
@@ -608,7 +619,13 @@ class RangeMaintainingScrollPhysics extends ScrollPhysics {
608619
/// of different types to get the desired scroll physics.
609620
class BouncingScrollPhysics extends ScrollPhysics {
610621
/// Creates scroll physics that bounce back from the edge.
611-
const BouncingScrollPhysics({ super.parent });
622+
const BouncingScrollPhysics({
623+
this.decelerationRate = ScrollDecelerationRate.normal,
624+
super.parent,
625+
});
626+
627+
/// Used to determine parameters for friction simulations.
628+
final ScrollDecelerationRate decelerationRate;
612629

613630
@override
614631
BouncingScrollPhysics applyTo(ScrollPhysics? ancestor) {
@@ -623,7 +640,14 @@ class BouncingScrollPhysics extends ScrollPhysics {
623640
/// This factor starts at 0.52 and progressively becomes harder to overscroll
624641
/// as more of the area past the edge is dragged in (represented by an increasing
625642
/// `overscrollFraction` which starts at 0 when there is no overscroll).
626-
double frictionFactor(double overscrollFraction) => 0.52 * math.pow(1 - overscrollFraction, 2);
643+
double frictionFactor(double overscrollFraction) {
644+
switch (decelerationRate) {
645+
case ScrollDecelerationRate.fast:
646+
return 0.07 * math.pow(1 - overscrollFraction, 2);
647+
case ScrollDecelerationRate.normal:
648+
return 0.52 * math.pow(1 - overscrollFraction, 2);
649+
}
650+
}
627651

628652
@override
629653
double applyPhysicsToUserOffset(ScrollMetrics position, double offset) {
@@ -670,13 +694,23 @@ class BouncingScrollPhysics extends ScrollPhysics {
670694
Simulation? createBallisticSimulation(ScrollMetrics position, double velocity) {
671695
final Tolerance tolerance = this.tolerance;
672696
if (velocity.abs() >= tolerance.velocity || position.outOfRange) {
697+
double constantDeceleration;
698+
switch (decelerationRate) {
699+
case ScrollDecelerationRate.fast:
700+
constantDeceleration = 1400;
701+
break;
702+
case ScrollDecelerationRate.normal:
703+
constantDeceleration = 0;
704+
break;
705+
}
673706
return BouncingScrollSimulation(
674707
spring: spring,
675708
position: position.pixels,
676709
velocity: velocity,
677710
leadingExtent: position.minScrollExtent,
678711
trailingExtent: position.maxScrollExtent,
679712
tolerance: tolerance,
713+
constantDeceleration: constantDeceleration
680714
);
681715
}
682716
return null;
@@ -711,6 +745,30 @@ class BouncingScrollPhysics extends ScrollPhysics {
711745
// from the natural motion of lifting the finger after a scroll.
712746
@override
713747
double get dragStartDistanceMotionThreshold => 3.5;
748+
749+
@override
750+
double get maxFlingVelocity {
751+
switch (decelerationRate) {
752+
case ScrollDecelerationRate.fast:
753+
return kMaxFlingVelocity * 8.0;
754+
case ScrollDecelerationRate.normal:
755+
return super.maxFlingVelocity;
756+
}
757+
}
758+
759+
@override
760+
SpringDescription get spring {
761+
switch (decelerationRate) {
762+
case ScrollDecelerationRate.fast:
763+
return SpringDescription.withDampingRatio(
764+
mass: 0.3,
765+
stiffness: 75.0,
766+
ratio: 1.3,
767+
);
768+
case ScrollDecelerationRate.normal:
769+
return super.spring;
770+
}
771+
}
714772
}
715773

716774
/// Scroll physics for environments that prevent the scroll offset from reaching

packages/flutter/lib/src/widgets/scroll_simulation.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ class BouncingScrollSimulation extends Simulation {
3434
required this.leadingExtent,
3535
required this.trailingExtent,
3636
required this.spring,
37+
double constantDeceleration = 0,
3738
super.tolerance,
3839
}) : assert(position != null),
3940
assert(velocity != null),
@@ -50,7 +51,7 @@ class BouncingScrollSimulation extends Simulation {
5051
} else {
5152
// Taken from UIScrollView.decelerationRate (.normal = 0.998)
5253
// 0.998^1000 = ~0.135
53-
_frictionSimulation = FrictionSimulation(0.135, position, velocity);
54+
_frictionSimulation = FrictionSimulation(0.135, position, velocity, constantDeceleration: constantDeceleration);
5455
final double finalX = _frictionSimulation.finalX;
5556
if (velocity > 0.0 && finalX > trailingExtent) {
5657
_springTime = _frictionSimulation.timeAtX(trailingExtent);

packages/flutter/test/cupertino/picker_test.dart

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// found in the LICENSE file.
44

55
import 'package:flutter/cupertino.dart';
6+
import 'package:flutter/foundation.dart';
67
import 'package:flutter/material.dart';
78
import 'package:flutter/rendering.dart';
89
import 'package:flutter/services.dart';
@@ -397,9 +398,11 @@ void main() {
397398
warnIfMissed: false, // has an IgnorePointer
398399
);
399400

400-
// Should have been flung far enough that even the first item goes off
401-
// screen and gets removed.
402-
expect(find.widgetWithText(SizedBox, '0').evaluate().isEmpty, true);
401+
if (debugDefaultTargetPlatformOverride == TargetPlatform.iOS) {
402+
// Should have been flung far enough that even the first item goes off
403+
// screen and gets removed.
404+
expect(find.widgetWithText(SizedBox, '0').evaluate().isEmpty, true);
405+
}
403406

404407
expect(
405408
selectedItems,
@@ -421,7 +424,7 @@ void main() {
421424
// Falling back to 0 shouldn't produce more callbacks.
422425
<int>[8, 6, 4, 2, 0],
423426
);
424-
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
427+
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
425428
});
426429

427430
testWidgets('Picker adapts to MaterialApp dark mode', (WidgetTester tester) async {

0 commit comments

Comments
 (0)