Unverified Commit daef0825 authored by nt4f04uNd's avatar nt4f04uNd Committed by GitHub

PageView scroll physics to match Android (#95423)

parent 1feaa6c2
...@@ -21,6 +21,7 @@ import 'scroll_notification.dart'; ...@@ -21,6 +21,7 @@ import 'scroll_notification.dart';
import 'scroll_physics.dart'; import 'scroll_physics.dart';
import 'scroll_position.dart'; import 'scroll_position.dart';
import 'scroll_position_with_single_context.dart'; import 'scroll_position_with_single_context.dart';
import 'scroll_simulation.dart';
import 'scroll_view.dart'; import 'scroll_view.dart';
import 'scrollable.dart'; import 'scrollable.dart';
import 'sliver.dart'; import 'sliver.dart';
...@@ -524,10 +525,24 @@ class _ForceImplicitScrollPhysics extends ScrollPhysics { ...@@ -524,10 +525,24 @@ class _ForceImplicitScrollPhysics extends ScrollPhysics {
/// * [ScrollPhysics], the base class which defines the API for scrolling /// * [ScrollPhysics], the base class which defines the API for scrolling
/// physics. /// physics.
/// * [PageView.physics], which can override the physics used by a page view. /// * [PageView.physics], which can override the physics used by a page view.
/// * [PageScrollSimulation], which implements Android page view scroll physics, and
/// used by this class.
class PageScrollPhysics extends ScrollPhysics { class PageScrollPhysics extends ScrollPhysics {
/// Creates physics for a [PageView]. /// Creates physics for a [PageView].
const PageScrollPhysics({ ScrollPhysics? parent }) : super(parent: parent); const PageScrollPhysics({ ScrollPhysics? parent }) : super(parent: parent);
// See Android ViewPager constants
// https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:viewpager/viewpager/src/main/java/androidx/viewpager/widget/ViewPager.java;l=116;drc=1dcb8847e7aca80ee78c5d9864329b93dd276379
static const int _kMaxSettleDuration = 600;
static const double _kMinFlingDistance = 25.0;
static const double _kMinFlingVelocity = 400.0;
@override
double get minFlingDistance => _kMinFlingDistance;
@override
double get minFlingVelocity => _kMinFlingVelocity;
@override @override
PageScrollPhysics applyTo(ScrollPhysics? ancestor) { PageScrollPhysics applyTo(ScrollPhysics? ancestor) {
return PageScrollPhysics(parent: buildParent(ancestor)); return PageScrollPhysics(parent: buildParent(ancestor));
...@@ -545,13 +560,24 @@ class PageScrollPhysics extends ScrollPhysics { ...@@ -545,13 +560,24 @@ class PageScrollPhysics extends ScrollPhysics {
return page * position.viewportDimension; return page * position.viewportDimension;
} }
double _getTargetPixels(ScrollMetrics position, Tolerance tolerance, double velocity) { double _getTargetPage(double page, Tolerance tolerance, double velocity) {
double page = _getPage(position);
if (velocity < -tolerance.velocity) if (velocity < -tolerance.velocity)
page -= 0.5; page -= 0.5;
else if (velocity > tolerance.velocity) else if (velocity > tolerance.velocity)
page += 0.5; page += 0.5;
return _getPixels(position, page.roundToDouble()); return page.roundToDouble();
}
double _getPageDelta(ScrollMetrics position, Tolerance tolerance, double velocity) {
final double page = _getPage(position);
final double targetPage = _getTargetPage(page, tolerance, velocity);
return targetPage - page;
}
double _getTargetPixels(ScrollMetrics position, Tolerance tolerance, double velocity) {
final double page = _getPage(position);
final double targetPage = _getTargetPage(page, tolerance, velocity);
return _getPixels(position, targetPage);
} }
@override @override
...@@ -563,11 +589,54 @@ class PageScrollPhysics extends ScrollPhysics { ...@@ -563,11 +589,54 @@ class PageScrollPhysics extends ScrollPhysics {
return super.createBallisticSimulation(position, velocity); return super.createBallisticSimulation(position, velocity);
final Tolerance tolerance = this.tolerance; final Tolerance tolerance = this.tolerance;
final double target = _getTargetPixels(position, tolerance, velocity); final double target = _getTargetPixels(position, tolerance, velocity);
if (target != position.pixels) if (target != position.pixels) {
return ScrollSpringSimulation(spring, position.pixels, target, velocity, tolerance: tolerance); // See Android ViewPager smoothScrollTo logic
// https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:viewpager/viewpager/src/main/java/androidx/viewpager/widget/ViewPager.java;l=952;drc=1dcb8847e7aca80ee78c5d9864329b93dd276379
final double delta = target - position.pixels;
final double width = position.viewportDimension;
final double halfWidth = width / 2;
final double distanceRatio = math.min(1.0, 1.0 * delta.abs() / width);
final double distance = halfWidth + halfWidth * _distanceInfluenceForSnapDuration(distanceRatio);
int duration;
if (velocity.abs() > 0) {
duration = 4 * (1000 * (distance / velocity).abs()).round();
} else {
final double pageDelta = _getPageDelta(position, tolerance, velocity).abs();
// A slightly different algorithm than on Android, because
// Flutter has different velocity estimate, which is more likely to
// return zero pointer velocity when user holds his finger after a fling,
// compared to Android's VelocityTracker.
//
// It was not clear why exactly this happens, since the estimate logic is
// the same as on Android, so it was decided to adjust this formula
// to produce similar results.
//
// On Android it looks like this:
// duration = ((pageDelta + 1) * 100).toInt();
duration = ((pageDelta * 100 + 1) * 100).toInt();
}
duration = math.min(duration, _kMaxSettleDuration);
return PageScrollSimulation(
position: position.pixels,
target: target,
duration: duration / 1000,
);
}
return null; return null;
} }
// See Android ViewPager distanceInfluenceForSnapDuration.
//
// We want the duration of the page snap animation to be influenced by the distance that
// the screen has to travel, however, we don't want this duration to be effected in a
// purely linear fashion. Instead, we use this method to moderate the effect that the distance
// of travel has on the overall snap duration.
double _distanceInfluenceForSnapDuration(double value) {
value -= 0.5; // center the values about 0.
value *= 0.3 * math.pi / 2.0;
return math.sin(value);
}
@override @override
bool get allowImplicitScrolling => false; bool get allowImplicitScrolling => false;
} }
......
...@@ -349,7 +349,9 @@ class ScrollPhysics { ...@@ -349,7 +349,9 @@ class ScrollPhysics {
ratio: 1.1, ratio: 1.1,
); );
/// The spring to use for ballistic simulations. /// The spring which [createBallisticSimulation] should use to create
/// a [SpringSimulation] to correct the scroll position when it goes
/// out of range.
SpringDescription get spring => parent?.spring ?? _kDefaultSpring; SpringDescription get spring => parent?.spring ?? _kDefaultSpring;
/// The default accuracy to which scrolling is computed. /// The default accuracy to which scrolling is computed.
......
...@@ -12,6 +12,7 @@ import 'package:flutter/physics.dart'; ...@@ -12,6 +12,7 @@ import 'package:flutter/physics.dart';
/// See also: /// See also:
/// ///
/// * [ClampingScrollSimulation], which implements Android scroll physics. /// * [ClampingScrollSimulation], which implements Android scroll physics.
/// * [PageScrollSimulation], which implements Android page view scroll physics.
class BouncingScrollSimulation extends Simulation { class BouncingScrollSimulation extends Simulation {
/// Creates a simulation group for scrolling on iOS, with the given /// Creates a simulation group for scrolling on iOS, with the given
/// parameters. /// parameters.
...@@ -134,6 +135,7 @@ class BouncingScrollSimulation extends Simulation { ...@@ -134,6 +135,7 @@ class BouncingScrollSimulation extends Simulation {
/// See also: /// See also:
/// ///
/// * [BouncingScrollSimulation], which implements iOS scroll physics. /// * [BouncingScrollSimulation], which implements iOS scroll physics.
/// * [PageScrollSimulation], which implements Android page view scroll physics.
// //
// This class is based on Scroller.java from Android: // This class is based on Scroller.java from Android:
// https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/widget // https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/widget
...@@ -229,3 +231,70 @@ class ClampingScrollSimulation extends Simulation { ...@@ -229,3 +231,70 @@ class ClampingScrollSimulation extends Simulation {
return time >= _duration; return time >= _duration;
} }
} }
/// An implementation of scroll physics that matches Android page view.
///
/// See also:
///
/// * [BouncingScrollSimulation], which implements iOS scroll physics.
/// * [ClampingScrollSimulation], which implements Android scroll physics.
//
// This class ports Android logic from Scroller.java `startScroll`, applying
// the interpolator set in ViewPager.java.
//
// See Android Scroller.java `startScroll` source code
// https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/widget/Scroller.java;l=393;drc=ae5bcf23b5f0875e455790d6af387184dbd009c1
class PageScrollSimulation extends Simulation {
/// Creates a scroll physics simulation that matches Android page view.
PageScrollSimulation({
required this.position,
required this.target,
required this.duration,
}) : assert(position != target),
_delta = target - position,
_durationReciprocal = 1.0 / duration;
/// The position at the beginning of the simulation.
final double position;
/// The target, which will be reached at the end of the simulation.
final double target;
/// The duration of the simulation in seconds.
final double duration;
final double _delta;
final double _durationReciprocal;
/// See Android ViewPager interpolator
/// https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:viewpager/viewpager/src/main/java/androidx/viewpager/widget/ViewPager.java;l=152;drc=1dcb8847e7aca80ee78c5d9864329b93dd276379
double _interpolate(double t) {
// y = (t - 1)^5 + 1.0
t -= 1.0;
return t * t * t * t * t + 1.0;
}
/// A derivative from [_interpolate] function.
double _interpolateDx(double t) {
// y = 5 * (t - 1)^4
t -= 1.0;
return 5.0 * t * t * t * t;
}
@override
double x(double time) {
time = time.clamp(0.0, duration);
return position + _delta * _interpolate(time * _durationReciprocal);
}
@override
double dx(double time) {
time = time.clamp(0.0, duration);
return _delta * _interpolateDx(time * _durationReciprocal);
}
@override
bool isDone(double time) {
return time >= duration;
}
}
...@@ -1123,4 +1123,210 @@ void main() { ...@@ -1123,4 +1123,210 @@ void main() {
await tester.pump(); await tester.pump();
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
group('PageView uses PageScrollPhysics -', () {
const double expectedMinFlingVelocity = 400.0;
const int maxExpectedFrames = 41; // expected frames with _kMaxSettleDuration
/// Creates a page view and drags/flings it with specified parameters.
///
/// When `flingVelocity` is not null, uses [WidgetTester.fling]
/// otherwise uses [WidgetTester.drag].
///
/// Returns the length of animation in frames.
///
/// At the end, runs expects:
///
/// * `expectedEndValue` will be used to verify the end scroll position
/// * optional `expectedFrames` will be used to verify the length of animation
///
Future<int> testPageViewPhysics(
WidgetTester tester, {
double? flingVelocity,
required double offset,
required double expectedEndValue,
Object? expectedFrames,
}) async {
await tester.pumpWidget(
Directionality(
key: UniqueKey(),
textDirection: TextDirection.ltr,
child: PageView.builder(
itemCount: kStates.length,
itemBuilder: (BuildContext context, int index) {
return Container(
height: 200.0,
color: index.isEven
? const Color(0xFF0000FF)
: const Color(0xFF00FF00),
child: Text(kStates[index]),
);
},
),
)
);
if (flingVelocity != null)
await tester.fling(find.byType(PageView), Offset(-offset, 0.0), flingVelocity);
else
await tester.drag(find.byType(PageView), Offset(-offset, 0.0));
final ScrollPosition position = tester.state<ScrollableState>(find.byType(Scrollable)).position;
final List<double> values = <double> [];
for (int i = 0; i < 100; i++) {
values.add(position.pixels);
if (values[i] == expectedEndValue) {
await tester.pump(const Duration(milliseconds: 16));
values.add(position.pixels);
break;
}
await tester.pump(const Duration(milliseconds: 16));
}
// verify the simulation ended
expect(values[values.length - 2], values.last);
if (expectedFrames != null)
expect(values, hasLength(expectedFrames));
expect(position.pixels, expectedEndValue);
return values.length;
}
testWidgets('drag and release', (WidgetTester tester) async {
await testPageViewPhysics(
tester,
offset: 1.0,
expectedFrames: 10,
expectedEndValue: 0.0,
);
await testPageViewPhysics(
tester,
offset: 20.0,
expectedFrames: 25,
expectedEndValue: 0.0,
);
await testPageViewPhysics(
tester,
offset: 40.0,
expectedFrames: maxExpectedFrames,
expectedEndValue: 0.0,
);
await testPageViewPhysics(
tester,
offset: 399.0,
expectedFrames: maxExpectedFrames,
expectedEndValue: 0.0,
);
await testPageViewPhysics(
tester,
offset: 400.0,
expectedFrames: maxExpectedFrames,
expectedEndValue: 800.0,
);
await testPageViewPhysics(
tester,
offset: 799.0,
expectedFrames: 10,
expectedEndValue: 800.0,
);
});
testWidgets('min fling velocity', (WidgetTester tester) async {
// fling with velocity less than min fling velocity - goes back to 0
await testPageViewPhysics(
tester,
flingVelocity: 1.0,
offset: 200.0,
expectedEndValue: 0.0,
);
await testPageViewPhysics(
tester,
flingVelocity: expectedMinFlingVelocity,
offset: 200.0,
expectedEndValue: 0.0,
);
// fling with velocity greater than min fling velocity - goes to the next page
await testPageViewPhysics(
tester,
flingVelocity: expectedMinFlingVelocity + 1,
offset: 200.0,
expectedEndValue: 800.0,
);
});
testWidgets('fling has max settle duration, which decreases when user drags faster', (WidgetTester tester) async {
// The duration depends on the fling `offset` and `velocity`
// For offset 200.0
// Fling very slowly
int frames = await testPageViewPhysics(
tester,
flingVelocity: 500.0,
offset: 200.0,
expectedFrames: maxExpectedFrames,
expectedEndValue: 800.0,
);
// Fling about as fast to start going faster
frames = await testPageViewPhysics(
tester,
flingVelocity: 3000.0,
offset: 200.0,
expectedFrames: frames,
expectedEndValue: 800.0,
);
// Go faster
frames = await testPageViewPhysics(
tester,
flingVelocity: 3050.0,
offset: 200.0,
expectedFrames: lessThan(frames),
expectedEndValue: 800.0,
);
// Go even faster
frames = await testPageViewPhysics(
tester,
flingVelocity: 10000.0,
offset: 200.0,
expectedFrames: lessThan(frames),
expectedEndValue: 800.0,
);
// For offset 400.0
// Fling very slowly
frames = await testPageViewPhysics(
tester,
flingVelocity: 500.0,
offset: 400.0,
expectedFrames: maxExpectedFrames,
expectedEndValue: 800.0,
);
// Fling about as fast to start going faster
frames = await testPageViewPhysics(
tester,
flingVelocity: 2650.0,
offset: 400.0,
expectedFrames: frames,
expectedEndValue: 800.0,
);
// Go faster
frames = await testPageViewPhysics(
tester,
flingVelocity: 2700.0,
offset: 400.0,
expectedFrames: lessThan(frames),
expectedEndValue: 800.0,
);
// Go even faster
frames = await testPageViewPhysics(
tester,
flingVelocity: 10000.0,
offset: 400.0,
expectedFrames: lessThan(frames),
expectedEndValue: 800.0,
);
});
});
} }
...@@ -93,7 +93,7 @@ void main() { ...@@ -93,7 +93,7 @@ void main() {
); );
}); });
test("ScrollPhysics scrolling subclasses - Creating the simulation doesn't alter the velocity for time 0", () { test('ScrollPhysics scrolling subclasses - initial velocity when creating the simulation', () {
final ScrollMetrics position = FixedScrollMetrics( final ScrollMetrics position = FixedScrollMetrics(
minScrollExtent: 0.0, minScrollExtent: 0.0,
maxScrollExtent: 100.0, maxScrollExtent: 100.0,
...@@ -104,13 +104,25 @@ void main() { ...@@ -104,13 +104,25 @@ void main() {
const BouncingScrollPhysics bounce = BouncingScrollPhysics(); const BouncingScrollPhysics bounce = BouncingScrollPhysics();
const ClampingScrollPhysics clamp = ClampingScrollPhysics(); const ClampingScrollPhysics clamp = ClampingScrollPhysics();
const PageScrollPhysics page = PageScrollPhysics();
// Verify creating these simulations doesn't alter the velocity for time 0.
//
// Calls to createBallisticSimulation may happen on every frame (i.e. when the maxScrollExtent changes) // Calls to createBallisticSimulation may happen on every frame (i.e. when the maxScrollExtent changes)
// Changing velocity for time 0 may cause a sudden, unwanted damping/speedup effect // Changing velocity for time 0 may cause a sudden, unwanted damping/speedup effect.
expect(bounce.createBallisticSimulation(position, 1000)!.dx(0), moreOrLessEquals(1000)); expect(bounce.createBallisticSimulation(position, 1000)!.dx(0), 1000);
expect(clamp.createBallisticSimulation(position, 1000)!.dx(0), moreOrLessEquals(1000)); expect(clamp.createBallisticSimulation(position, 1000)!.dx(0), 1000);
expect(page.createBallisticSimulation(position, 1000)!.dx(0), moreOrLessEquals(1000));
// Contrary to the other two scroll physics, PageScrollPhysics do intentionally produce
// a bigger velocity than supplied to them, but they won't suffer from this, because the simulation
// velocity depends on a ratio of velocity / pageDelta, and because they are not affected by
// https://github.com/flutter/flutter/issues/11599
// TODO(nt4f04uNd): remove this test, because it is artificial, in favor of a real one.
// Scroll simulations surely should be allowed to modify the speed they receive in whatever way they want.
// The proper fix for the original bug https://github.com/flutter/flutter/issues/24715
// would be to ensure this is not called too often https://github.com/flutter/flutter/issues/11599
// Proper test(s) should ensure the animations of the physics have the same duration
// for same fling conditions within different layouts.
}); });
group('BouncingScrollPhysics test', () { group('BouncingScrollPhysics test', () {
......
...@@ -23,4 +23,45 @@ void main() { ...@@ -23,4 +23,45 @@ void main() {
checkInitialConditions(75.0, 614.2093); checkInitialConditions(75.0, 614.2093);
checkInitialConditions(5469.0, 182.114534); checkInitialConditions(5469.0, 182.114534);
}); });
test('PageScrollSimulation', () {
void checkSimulation(double position, double target, double duration) {
final double delta = target - position;
final PageScrollSimulation simulation = PageScrollSimulation(position: position, target: target, duration: duration);
late double lastX;
late double lastDx;
void expectX(double t, dynamic expectedValue) {
final double x = simulation.x(t);
expect(x, expectedValue);
lastX = x;
}
void expectDx(double t, dynamic expectedValue) {
final double dx = simulation.dx(t);
expect(dx, expectedValue);
lastDx = dx;
}
// verify start values
expectX(0.0, position);
expectDx(0.0, moreOrLessEquals(5 * delta));
expect(simulation.isDone(0.0), false);
// verify intermediate values change monotonically
for (double t = 0.01; t < duration; t += 1) {
expectX(t, target > position ? greaterThan(lastX) : lessThan(lastX));
expectDx(t, target > position ? lessThan(lastDx) : greaterThan(lastDx));
expect(simulation.isDone(t), false);
}
// verify end values
expectX(duration, target);
expectDx(duration, 0.0);
expect(simulation.isDone(duration), true);
}
checkSimulation(0, 500, 1000);
checkSimulation(0, -500, 1000);
checkSimulation(1000, 5000, 100);
checkSimulation(100, -5000, 100);
});
} }
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