diff --git a/AUTHORS b/AUTHORS index 15682b932c..2ba77aacf9 100644 --- a/AUTHORS +++ b/AUTHORS @@ -101,4 +101,5 @@ Junhua Lin <1075209054@qq.com> Tomasz Gucio Jason C.H Hubert Jóźwiak -Eli Albert \ No newline at end of file +Eli Albert +Jan Kuß diff --git a/packages/flutter/lib/src/widgets/overscroll_indicator.dart b/packages/flutter/lib/src/widgets/overscroll_indicator.dart index 1aa71f19f7..2e31b45e73 100644 --- a/packages/flutter/lib/src/widgets/overscroll_indicator.dart +++ b/packages/flutter/lib/src/widgets/overscroll_indicator.dart @@ -601,6 +601,15 @@ class _GlowingOverscrollIndicatorPainter extends CustomPainter { } } +enum _StretchDirection { + /// The [trailing] direction indicates that the content will be stretched toward + /// the trailing edge. + trailing, + /// The [leading] direction indicates that the content will be stretched toward + /// the leading edge. + leading, +} + /// A Material Design visual indication that a scroll view has overscrolled. /// /// A [StretchingOverscrollIndicator] listens for [ScrollNotification]s in order @@ -689,6 +698,9 @@ class _StretchingOverscrollIndicatorState extends State 0 + return stretchDirection == _StretchDirection.trailing ? AlignmentDirectional.topCenter : AlignmentDirectional.bottomCenter; case AxisDirection.right: - return overscroll > 0 + return stretchDirection == _StretchDirection.trailing ? Alignment.centerRight : Alignment.centerLeft; case AxisDirection.down: - return overscroll > 0 + return stretchDirection == _StretchDirection.trailing ? AlignmentDirectional.bottomCenter : AlignmentDirectional.topCenter; case AxisDirection.left: - return overscroll > 0 + return stretchDirection == _StretchDirection.trailing ? Alignment.centerLeft : Alignment.centerRight; } @@ -784,7 +800,7 @@ class _StretchingOverscrollIndicatorState extends State _pullDistance; double _pullDistance = 0.0; + _StretchDirection get stretchDirection => _stretchDirection; + _StretchDirection _stretchDirection = _StretchDirection.trailing; + // Constants from Android. static const double _exponentialScalar = math.e / 0.33; static const double _stretchIntensity = 0.016; @@ -847,7 +866,7 @@ class _StretchController extends ChangeNotifier { /// Handle a fling to the edge of the viewport at a particular velocity. /// /// The velocity must be positive. - void absorbImpact(double velocity) { + void absorbImpact(double velocity, double totalOverscroll) { assert(velocity >= 0.0); velocity = clampDouble(velocity, 1, 10000); _stretchSizeTween.begin = _stretchSize.value; @@ -855,6 +874,7 @@ class _StretchController extends ChangeNotifier { _stretchController.duration = Duration(milliseconds: (velocity * 0.02).round()); _stretchController.forward(from: 0.0); _state = _StretchState.absorb; + _stretchDirection = totalOverscroll > 0 ? _StretchDirection.trailing : _StretchDirection.leading; } /// Handle a user-driven overscroll. @@ -862,8 +882,19 @@ class _StretchController extends ChangeNotifier { /// The `normalizedOverscroll` argument should be the absolute value of the /// scroll distance in logical pixels, divided by the extent of the viewport /// in the main axis. - void pull(double normalizedOverscroll) { + void pull(double normalizedOverscroll, double totalOverscroll) { assert(normalizedOverscroll >= 0.0); + + final _StretchDirection newStretchDirection = totalOverscroll > 0 ? _StretchDirection.trailing : _StretchDirection.leading; + if (_stretchDirection != newStretchDirection && _state == _StretchState.recede) { + // When the stretch direction changes while we are in the recede state, we need to ignore the change. + // If we don't, the stretch will instantly jump to the new direction with the recede animation still playing, which causes + // a unwanted visual abnormality (https://github.com/flutter/flutter/pull/116548#issuecomment-1414872567). + // By ignoring the directional change until the recede state is finished, we can avoid this. + return; + } + + _stretchDirection = newStretchDirection; _pullDistance = normalizedOverscroll; _stretchSizeTween.begin = _stretchSize.value; final double linearIntensity =_stretchIntensity * _pullDistance; diff --git a/packages/flutter/test/widgets/overscroll_stretch_indicator_test.dart b/packages/flutter/test/widgets/overscroll_stretch_indicator_test.dart index e664202540..6d851096a9 100644 --- a/packages/flutter/test/widgets/overscroll_stretch_indicator_test.dart +++ b/packages/flutter/test/widgets/overscroll_stretch_indicator_test.dart @@ -20,6 +20,9 @@ void main() { Axis axis = Axis.vertical, bool reverse = false, TextDirection textDirection = TextDirection.ltr, + double boxHeight = 250.0, + double boxWidth = 300.0, + ScrollPhysics? physics, }) { final AxisDirection axisDirection; switch (axis) { @@ -44,6 +47,7 @@ void main() { child: StretchingOverscrollIndicator( axisDirection: axisDirection, child: CustomScrollView( + physics: physics, reverse: reverse, scrollDirection: axis, controller: controller, @@ -51,20 +55,20 @@ void main() { SliverToBoxAdapter(child: Container( color: const Color(0xD0FF0000), key: box1Key, - height: 250.0, - width: 300.0, + height: boxHeight, + width: boxWidth, )), SliverToBoxAdapter(child: Container( color: const Color(0xFFFFFF00), key: box2Key, - height: 250.0, - width: 300.0, + height: boxHeight, + width: boxWidth, )), SliverToBoxAdapter(child: Container( color: const Color(0xFF6200EA), key: box3Key, - height: 250.0, - width: 300.0, + height: boxHeight, + width: boxWidth, )), ], ), @@ -387,7 +391,7 @@ void main() { expect(box3.localToGlobal(Offset.zero).dx, 500.0); }); - testWidgets('Stretch overscroll horizontally RTl', (WidgetTester tester) async { + testWidgets('Stretch overscroll horizontally RTL', (WidgetTester tester) async { final GlobalKey box1Key = GlobalKey(); final GlobalKey box2Key = GlobalKey(); final GlobalKey box3Key = GlobalKey(); @@ -701,7 +705,7 @@ void main() { await tester.pumpAndSettle(); }); - testWidgets('Multiple pointers wll not exceed stretch limit', (WidgetTester tester) async { + testWidgets('Multiple pointers will not exceed stretch limit', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/99264 await tester.pumpWidget( Directionality( @@ -770,4 +774,297 @@ void main() { await pointer4.up(); await tester.pumpAndSettle(); }); + + testWidgets('Stretch overscroll vertically, change direction mid scroll', (WidgetTester tester) async { + final GlobalKey box1Key = GlobalKey(); + final GlobalKey box2Key = GlobalKey(); + final GlobalKey box3Key = GlobalKey(); + final ScrollController controller = ScrollController(); + await tester.pumpWidget( + buildTest( + box1Key, + box2Key, + box3Key, + controller, + // Setting the `boxHeight` to 100.0 will make the boxes fit in the + // scrollable viewport. + boxHeight: 100, + // To make the scroll view in the test still scrollable, we need to add + // the `AlwaysScrollableScrollPhysics`. + physics: const AlwaysScrollableScrollPhysics(), + ), + ); + + expect(find.byType(StretchingOverscrollIndicator), findsOneWidget); + expect(find.byType(GlowingOverscrollIndicator), findsNothing); + final RenderBox box1 = tester.renderObject(find.byKey(box1Key)); + final RenderBox box2 = tester.renderObject(find.byKey(box2Key)); + final RenderBox box3 = tester.renderObject(find.byKey(box3Key)); + + expect(controller.offset, 0.0); + expect(box1.localToGlobal(Offset.zero), Offset.zero); + expect(box2.localToGlobal(Offset.zero), const Offset(0.0, 100.0)); + expect(box3.localToGlobal(Offset.zero), const Offset(0.0, 200.0)); + + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(CustomScrollView))); + // Overscroll the start + await gesture.moveBy(const Offset(0.0, 600.0)); + await tester.pumpAndSettle(); + + // The boxes should now be at different locations because of the scaling. + expect(box1.localToGlobal(Offset.zero), Offset.zero); + expect(box2.localToGlobal(Offset.zero).dy, greaterThan(103.0)); + expect(box3.localToGlobal(Offset.zero).dy, greaterThan(206.0)); + + // Move the pointer up a miniscule amount to trigger a directional change. + await gesture.moveBy(const Offset(0.0, -20.0)); + await tester.pumpAndSettle(); + + // The boxes should remain roughly at the same locations, since the pointer + // didn't move far. + expect(box1.localToGlobal(Offset.zero), Offset.zero); + expect(box2.localToGlobal(Offset.zero).dy, greaterThan(103.0)); + expect(box3.localToGlobal(Offset.zero).dy, greaterThan(206.0)); + + // Now make the pointer overscroll to the end + await gesture.moveBy(const Offset(0.0, -1200.0)); + await tester.pumpAndSettle(); + + expect(box1.localToGlobal(Offset.zero).dy, lessThan(-19.0)); + expect(box2.localToGlobal(Offset.zero).dy, lessThan(85.0)); + expect(box3.localToGlobal(Offset.zero).dy, lessThan(188.0)); + + // Release the pointer + await gesture.up(); + await tester.pumpAndSettle(); + + // Now the boxes should be back to their original locations. + expect(box1.localToGlobal(Offset.zero), Offset.zero); + expect(box2.localToGlobal(Offset.zero), const Offset(0.0, 100.0)); + expect(box3.localToGlobal(Offset.zero), const Offset(0.0, 200.0)); + }); + + testWidgets('Stretch overscroll horizontally, change direction mid scroll', (WidgetTester tester) async { + final GlobalKey box1Key = GlobalKey(); + final GlobalKey box2Key = GlobalKey(); + final GlobalKey box3Key = GlobalKey(); + final ScrollController controller = ScrollController(); + await tester.pumpWidget( + buildTest( + box1Key, + box2Key, + box3Key, + controller, + // Setting the `boxWidth` to 100.0 will make the boxes fit in the + // scrollable viewport. + boxWidth: 100, + // To make the scroll view in the test still scrollable, we need to add + // the `AlwaysScrollableScrollPhysics`. + physics: const AlwaysScrollableScrollPhysics(), + axis: Axis.horizontal, + ), + ); + + expect(find.byType(StretchingOverscrollIndicator), findsOneWidget); + expect(find.byType(GlowingOverscrollIndicator), findsNothing); + final RenderBox box1 = tester.renderObject(find.byKey(box1Key)); + final RenderBox box2 = tester.renderObject(find.byKey(box2Key)); + final RenderBox box3 = tester.renderObject(find.byKey(box3Key)); + + expect(controller.offset, 0.0); + expect(box1.localToGlobal(Offset.zero), Offset.zero); + expect(box2.localToGlobal(Offset.zero), const Offset(100.0, 0.0)); + expect(box3.localToGlobal(Offset.zero), const Offset(200.0, 0.0)); + + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(CustomScrollView))); + // Overscroll the start + await gesture.moveBy(const Offset(600.0, 0.0)); + await tester.pumpAndSettle(); + + // The boxes should now be at different locations because of the scaling. + expect(box1.localToGlobal(Offset.zero), Offset.zero); + expect(box2.localToGlobal(Offset.zero).dx, greaterThan(102.0)); + expect(box3.localToGlobal(Offset.zero).dx, greaterThan(205.0)); + + // Move the pointer up a miniscule amount to trigger a directional change. + await gesture.moveBy(const Offset(-20.0, 0.0)); + await tester.pumpAndSettle(); + + // The boxes should remain roughly at the same locations, since the pointer + // didn't move far. + expect(box1.localToGlobal(Offset.zero), Offset.zero); + expect(box2.localToGlobal(Offset.zero).dx, greaterThan(102.0)); + expect(box3.localToGlobal(Offset.zero).dx, greaterThan(205.0)); + + // Now make the pointer overscroll to the end + await gesture.moveBy(const Offset(-1200.0, 0.0)); + await tester.pumpAndSettle(); + + expect(box1.localToGlobal(Offset.zero).dx, lessThan(-19.0)); + expect(box2.localToGlobal(Offset.zero).dx, lessThan(85.0)); + expect(box3.localToGlobal(Offset.zero).dx, lessThan(188.0)); + + // Release the pointer + await gesture.up(); + await tester.pumpAndSettle(); + + // Now the boxes should be back to their original locations. + expect(box1.localToGlobal(Offset.zero), Offset.zero); + expect(box2.localToGlobal(Offset.zero), const Offset(100.0, 0.0)); + expect(box3.localToGlobal(Offset.zero), const Offset(200.0, 0.0)); + }); + + testWidgets('Fling toward the trailing edge causes stretch toward the leading edge', (WidgetTester tester) async { + final GlobalKey box1Key = GlobalKey(); + final GlobalKey box2Key = GlobalKey(); + final GlobalKey box3Key = GlobalKey(); + final ScrollController controller = ScrollController(); + await tester.pumpWidget( + buildTest(box1Key, box2Key, box3Key, controller), + ); + + expect(find.byType(StretchingOverscrollIndicator), findsOneWidget); + expect(find.byType(GlowingOverscrollIndicator), findsNothing); + final RenderBox box1 = tester.renderObject(find.byKey(box1Key)); + final RenderBox box2 = tester.renderObject(find.byKey(box2Key)); + final RenderBox box3 = tester.renderObject(find.byKey(box3Key)); + + expect(controller.offset, 0.0); + expect(box1.localToGlobal(Offset.zero), Offset.zero); + expect(box2.localToGlobal(Offset.zero), const Offset(0.0, 250.0)); + expect(box3.localToGlobal(Offset.zero), const Offset(0.0, 500.0)); + + await tester.fling(find.byType(CustomScrollView), const Offset(0.0, -50.0), 10000.0); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + + // The boxes should now be at different locations because of the scaling. + expect(controller.offset, 150.0); + expect(box1.localToGlobal(Offset.zero).dy, lessThan(-160.0)); + expect(box2.localToGlobal(Offset.zero).dy, lessThan(93.0)); + expect(box3.localToGlobal(Offset.zero).dy, lessThan(347.0)); + + await tester.pumpAndSettle(); + + // The boxes should now be at their final position. + expect(controller.offset, 150.0); + expect(box1.localToGlobal(Offset.zero).dy, -150.0); + expect(box2.localToGlobal(Offset.zero).dy, 100.0); + expect(box3.localToGlobal(Offset.zero).dy, 350.0); + }); + + testWidgets('Fling toward the leading edge causes stretch toward the trailing edge', (WidgetTester tester) async { + final GlobalKey box1Key = GlobalKey(); + final GlobalKey box2Key = GlobalKey(); + final GlobalKey box3Key = GlobalKey(); + final ScrollController controller = ScrollController(); + await tester.pumpWidget( + buildTest(box1Key, box2Key, box3Key, controller), + ); + + expect(find.byType(StretchingOverscrollIndicator), findsOneWidget); + expect(find.byType(GlowingOverscrollIndicator), findsNothing); + final RenderBox box1 = tester.renderObject(find.byKey(box1Key)); + final RenderBox box2 = tester.renderObject(find.byKey(box2Key)); + final RenderBox box3 = tester.renderObject(find.byKey(box3Key)); + + expect(controller.offset, 0.0); + expect(box1.localToGlobal(Offset.zero), Offset.zero); + expect(box2.localToGlobal(Offset.zero), const Offset(0.0, 250.0)); + expect(box3.localToGlobal(Offset.zero), const Offset(0.0, 500.0)); + + // We fling to the trailing edge and let it settle. + await tester.fling(find.byType(CustomScrollView), const Offset(0.0, -50.0), 10000.0); + await tester.pumpAndSettle(); + + // We are now at the trailing edge + expect(controller.offset, 150.0); + expect(box1.localToGlobal(Offset.zero).dy, -150.0); + expect(box2.localToGlobal(Offset.zero).dy, 100.0); + expect(box3.localToGlobal(Offset.zero).dy, 350.0); + + // Now fling to the leading edge + await tester.fling(find.byType(CustomScrollView), const Offset(0.0, 50.0), 10000.0); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + + // The boxes should now be at different locations because of the scaling. + expect(controller.offset, 0.0); + expect(box1.localToGlobal(Offset.zero).dy, 0.0); + expect(box2.localToGlobal(Offset.zero).dy, greaterThan(254.0)); + expect(box3.localToGlobal(Offset.zero).dy, greaterThan(508.0)); + + await tester.pumpAndSettle(); + + // The boxes should now be at their final position. + expect(controller.offset, 0.0); + expect(box1.localToGlobal(Offset.zero).dy, 0.0); + expect(box2.localToGlobal(Offset.zero).dy, 250.0); + expect(box3.localToGlobal(Offset.zero).dy, 500.0); + }); + + testWidgets('changing scroll direction during recede animation will not change the stretch direction', (WidgetTester tester) async { + final GlobalKey box1Key = GlobalKey(); + final GlobalKey box2Key = GlobalKey(); + final GlobalKey box3Key = GlobalKey(); + final ScrollController controller = ScrollController(); + await tester.pumpWidget( + buildTest(box1Key, box2Key, box3Key, controller, boxHeight: 205.0), + ); + + expect(find.byType(StretchingOverscrollIndicator), findsOneWidget); + expect(find.byType(GlowingOverscrollIndicator), findsNothing); + final RenderBox box1 = tester.renderObject(find.byKey(box1Key)); + final RenderBox box2 = tester.renderObject(find.byKey(box2Key)); + final RenderBox box3 = tester.renderObject(find.byKey(box3Key)); + + // Fling to the trailing edge + await tester.fling(find.byType(CustomScrollView), const Offset(0.0, -50.0), 10000.0); + await tester.pumpAndSettle(); + + expect(box1.localToGlobal(Offset.zero).dy, -15.0); + expect(box2.localToGlobal(Offset.zero).dy, 190.0); + expect(box3.localToGlobal(Offset.zero).dy, 395.0); + + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(CustomScrollView))); + // Overscroll to the trailing edge + await gesture.moveBy(const Offset(0.0, -200.0)); + await tester.pumpAndSettle(); + + expect(box1.localToGlobal(Offset.zero).dy, lessThan(-25.0)); + expect(box2.localToGlobal(Offset.zero).dy, lessThan(185.0)); + expect(box3.localToGlobal(Offset.zero).dy, lessThan(392.0)); + + // This will trigger the recede animation + // The y offset of the boxes should be increasing, since the boxes were stretched + // toward the leading edge. + await gesture.moveBy(const Offset(0.0, 150.0)); + await tester.pump(const Duration(milliseconds: 100)); + + // Explicitly check that the box1 offset is not 0.0, since this would probably mean that + // the stretch direction is wrong. + expect(box1.localToGlobal(Offset.zero).dy, isNot(0.0)); + + expect(box1.localToGlobal(Offset.zero).dy, lessThan(-12.0)); + expect(box2.localToGlobal(Offset.zero).dy, lessThan(197.0)); + expect(box3.localToGlobal(Offset.zero).dy, lessThan(407.0)); + + await tester.pump(const Duration(milliseconds: 100)); + + expect(box1.localToGlobal(Offset.zero).dy, lessThan(-6.0)); + expect(box2.localToGlobal(Offset.zero).dy, lessThan(201.0)); + expect(box3.localToGlobal(Offset.zero).dy, lessThan(408.0)); + + await tester.pumpAndSettle(); + + // The recede animation is done now, we should now be at the leading edge. + expect(box1.localToGlobal(Offset.zero).dy, 0.0); + expect(box2.localToGlobal(Offset.zero).dy, 205.0); + expect(box3.localToGlobal(Offset.zero).dy, 410.0); + + await gesture.up(); + }); }