diff --git a/packages/flutter/lib/src/widgets/scroll_activity.dart b/packages/flutter/lib/src/widgets/scroll_activity.dart index c66f4e4c5f0..f5072d72ab0 100644 --- a/packages/flutter/lib/src/widgets/scroll_activity.dart +++ b/packages/flutter/lib/src/widgets/scroll_activity.dart @@ -748,11 +748,23 @@ class DrivenScrollActivity extends ScrollActivity { Future get done => _completer.future; void _tick() { - if (delegate.setPixels(_controller.value) != 0.0) { + if (!applyMoveTo(_controller.value)) { delegate.goIdle(); } } + /// Move the position to the given location. + /// + /// If the new position was fully applied, returns true. If there was any + /// overflow, returns false. + /// + /// The default implementation calls [ScrollActivityDelegate.setPixels] + /// and returns true if the overflow was zero. + @protected + bool applyMoveTo(double value) { + return delegate.setPixels(value).abs() < precisionErrorTolerance; + } + void _end() { // Check if the activity was disposed before going ballistic because _end might be called // if _controller is disposed just after completion. diff --git a/packages/flutter/test/widgets/scroll_activity_test.dart b/packages/flutter/test/widgets/scroll_activity_test.dart index d6325b86f5a..1404260bdeb 100644 --- a/packages/flutter/test/widgets/scroll_activity_test.dart +++ b/packages/flutter/test/widgets/scroll_activity_test.dart @@ -61,6 +61,61 @@ void main() { expect(controller.position.pixels, thirty + 100.0); // and ends up at the end }); + testWidgets('DrivenScrollActivity allows overriding applyMoveTo', (WidgetTester tester) async { + final ScrollController controller = ScrollController(); + addTearDown(controller.dispose); + final List notifications = []; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: NotificationListener( + onNotification: (ScrollNotification notif) { + if (notif is OverscrollNotification) { + notifications.add(notif); + } + return false; + }, + child: ListView(controller: controller, children: children(10)), + ), + ), + ); + final ScrollPositionWithSingleContext position = + controller.position as ScrollPositionWithSingleContext; + final double end = position.maxScrollExtent; + + position.beginActivity( + DrivenScrollActivity( + position, + from: 0, + to: end + 10, + duration: const Duration(milliseconds: 100), + curve: Curves.linear, + vsync: position.context.vsync, + ), + ); + await tester.pumpAndSettle(); + // The base DrivenScrollActivity caused overscroll. + expect(notifications, hasLength(1)); + + notifications.clear(); + controller.jumpTo(0); + await tester.pump(); + + position.beginActivity( + _NoOverscrollDrivenScrollActivity( + position, + from: 0, + to: end + 10, + duration: const Duration(milliseconds: 100), + curve: Curves.linear, + vsync: position.context.vsync, + ), + ); + await tester.pumpAndSettle(); + // The _NoOverscrollDrivenScrollActivity avoided overscroll. + expect(notifications, isEmpty); + }); + testWidgets('Ability to keep a PageView at the end manually (issue 62209)', ( WidgetTester tester, ) async { @@ -440,6 +495,35 @@ class _Carousel62209State extends State { } } +class _NoOverscrollDrivenScrollActivity extends DrivenScrollActivity { + _NoOverscrollDrivenScrollActivity( + ScrollPositionWithSingleContext super.delegate, { + required super.from, + required super.to, + required super.duration, + required super.curve, + required super.vsync, + }); + + ScrollPosition get _position => delegate as ScrollPosition; + + @override + bool applyMoveTo(double value) { + bool done = false; + if (velocity >= 0.0 && value > _position.maxScrollExtent) { + value = _position.maxScrollExtent; + done = true; + } else if (velocity <= 0.0 && value < _position.minScrollExtent) { + value = _position.minScrollExtent; + done = true; + } + if (!super.applyMoveTo(value)) { + return false; + } + return !done; + } +} + class _ScrollActivity extends ScrollActivity { _ScrollActivity(super.delegate);