diff --git a/packages/flutter/lib/src/material/refresh_indicator.dart b/packages/flutter/lib/src/material/refresh_indicator.dart index 39ce3646b6c..9755c3e3abf 100644 --- a/packages/flutter/lib/src/material/refresh_indicator.dart +++ b/packages/flutter/lib/src/material/refresh_indicator.dart @@ -47,6 +47,17 @@ enum _RefreshIndicatorMode { canceled, // Animating the indicator's fade-out after not arming. } +/// Used to configure how [RefreshIndicator] can be triggered. +enum RefreshIndicatorTriggerMode { + /// The indicator can be triggered regardless of the scroll position + /// of the [Scrollable] when the drag starts. + anywhere, + + /// The indicator can only be triggered if the [Scrollable] is at the edge + /// when the drag starts. + onEdge, +} + /// A widget that supports the Material "swipe to refresh" idiom. /// /// When the child's [Scrollable] descendant overscrolls, an animated circular @@ -56,6 +67,8 @@ enum _RefreshIndicatorMode { /// scrollable's contents and then complete the [Future] it returns. The refresh /// indicator disappears after the callback's [Future] has completed. /// +/// The trigger mode is configured by [RefreshIndicator.triggerMode]. +/// /// ## Troubleshooting /// /// ### Refresh indicator does not show up @@ -106,11 +119,13 @@ class RefreshIndicator extends StatefulWidget { this.notificationPredicate = defaultScrollNotificationPredicate, this.semanticsLabel, this.semanticsValue, - this.strokeWidth = 2.0 + this.strokeWidth = 2.0, + this.triggerMode = RefreshIndicatorTriggerMode.onEdge, }) : assert(child != null), assert(onRefresh != null), assert(notificationPredicate != null), assert(strokeWidth != null), + assert(triggerMode != null), super(key: key); /// The widget below this widget in the tree. @@ -160,6 +175,21 @@ class RefreshIndicator extends StatefulWidget { /// By default, the value of `strokeWidth` is 2.0 pixels. final double strokeWidth; + /// Defines how this [RefreshIndicator] can be triggered when users overscroll. + /// + /// The [RefreshIndicator] can be pulled out in two cases, + /// 1, Keep dragging if the scrollable widget at the edge with zero scroll position + /// when the drag starts. + /// 2, Keep dragging after overscroll occurs if the scrollable widget has + /// a non-zero scroll position when the drag starts. + /// + /// If this is [RefreshIndicatorTriggerMode.anywhere], both of the cases above can be triggered. + /// + /// If this is [RefreshIndicatorTriggerMode.onEdge], only case 1 can be triggered. + /// + /// Defaults to [RefreshIndicatorTriggerMode.onEdge]. + final RefreshIndicatorTriggerMode triggerMode; + @override RefreshIndicatorState createState() => RefreshIndicatorState(); } @@ -215,12 +245,17 @@ class RefreshIndicatorState extends State with TickerProviderS super.dispose(); } + bool _shouldStart(ScrollNotification notification) { + return (notification is ScrollStartNotification || (notification is ScrollUpdateNotification && notification.dragDetails != null && widget.triggerMode == RefreshIndicatorTriggerMode.anywhere)) + && notification.metrics.extentBefore == 0.0 + && _mode == null + && _start(notification.metrics.axisDirection); + } + bool _handleScrollNotification(ScrollNotification notification) { if (!widget.notificationPredicate(notification)) return false; - if ((notification is ScrollStartNotification || notification is ScrollUpdateNotification) && - notification.metrics.extentBefore == 0.0 && - _mode == null && _start(notification.metrics.axisDirection)) { + if (_shouldStart(notification)) { setState(() { _mode = _RefreshIndicatorMode.drag; }); diff --git a/packages/flutter/test/material/refresh_indicator_test.dart b/packages/flutter/test/material/refresh_indicator_test.dart index aa4e33f09b3..781e10350df 100644 --- a/packages/flutter/test/material/refresh_indicator_test.dart +++ b/packages/flutter/test/material/refresh_indicator_test.dart @@ -501,7 +501,115 @@ void main() { ); }); - testWidgets('Top RefreshIndicator showed when dragging from non-zero scroll position', (WidgetTester tester) async { + testWidgets('Top RefreshIndicator(anywhere mode) should be shown when dragging from non-zero scroll position', (WidgetTester tester) async { + refreshCalled = false; + final ScrollController scrollController = ScrollController(); + await tester.pumpWidget( + MaterialApp( + home: RefreshIndicator( + triggerMode: RefreshIndicatorTriggerMode.anywhere, + onRefresh: holdRefresh, + child: ListView( + controller: scrollController, + physics: const AlwaysScrollableScrollPhysics(), + children: const [ + SizedBox( + height: 200.0, + child: Text('X'), + ), + SizedBox( + height: 800.0, + child: Text('Y'), + ), + ], + ), + ), + ), + ); + + scrollController.jumpTo(50.0); + + await tester.fling(find.text('X'), const Offset(0.0, 300.0), 1000.0); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // finish the scroll animation + await tester.pump(const Duration(seconds: 1)); // finish the indicator settle animation + expect(tester.getCenter(find.byType(RefreshProgressIndicator)).dy, lessThan(300.0)); + }); + + testWidgets('Bottom RefreshIndicator(anywhere mode) should be shown when dragging from non-zero scroll position', (WidgetTester tester) async { + refreshCalled = false; + final ScrollController scrollController = ScrollController(); + await tester.pumpWidget( + MaterialApp( + home: RefreshIndicator( + triggerMode: RefreshIndicatorTriggerMode.anywhere, + onRefresh: holdRefresh, + child: ListView( + reverse: true, + controller: scrollController, + physics: const AlwaysScrollableScrollPhysics(), + children: const [ + SizedBox( + height: 200.0, + child: Text('X'), + ), + SizedBox( + height: 800.0, + child: Text('Y'), + ), + ], + ), + ), + ), + ); + + scrollController.jumpTo(50.0); + + await tester.fling(find.text('X'), const Offset(0.0, -300.0), 1000.0); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // finish the scroll animation + await tester.pump(const Duration(seconds: 1)); // finish the indicator settle animation + expect(tester.getCenter(find.byType(RefreshProgressIndicator)).dy, greaterThan(300.0)); + }); + + // Regression test for https://github.com/flutter/flutter/issues/71936 + testWidgets('RefreshIndicator(anywhere mode) should not be shown when overscroll occurs due to inertia', (WidgetTester tester) async { + refreshCalled = false; + final ScrollController scrollController = ScrollController(); + await tester.pumpWidget( + MaterialApp( + home: RefreshIndicator( + triggerMode: RefreshIndicatorTriggerMode.anywhere, + onRefresh: holdRefresh, + child: ListView( + controller: scrollController, + physics: const AlwaysScrollableScrollPhysics(), + children: const [ + SizedBox( + height: 200.0, + child: Text('X'), + ), + SizedBox( + height: 2000.0, + child: Text('Y'), + ), + ], + ), + ), + ), + ); + + scrollController.jumpTo(100.0); + + // Release finger before reach the edge. + await tester.fling(find.text('X'), const Offset(0.0, 99.0), 1000.0); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // finish the scroll animation + await tester.pump(const Duration(seconds: 1)); // finish the indicator settle animation + expect(find.byType(RefreshProgressIndicator), findsNothing); + }); + + testWidgets('Top RefreshIndicator(onEdge mode) should not be shown when dragging from non-zero scroll position', (WidgetTester tester) async { refreshCalled = false; final ScrollController scrollController = ScrollController(); await tester.pumpWidget( @@ -532,10 +640,10 @@ void main() { await tester.pump(); await tester.pump(const Duration(seconds: 1)); // finish the scroll animation await tester.pump(const Duration(seconds: 1)); // finish the indicator settle animation - expect(tester.getCenter(find.byType(RefreshProgressIndicator)).dy, lessThan(300.0)); + expect(find.byType(RefreshProgressIndicator), findsNothing); }); - testWidgets('Bottom RefreshIndicator showed when dragging from non-zero scroll position', (WidgetTester tester) async { + testWidgets('Bottom RefreshIndicator(onEdge mode) should be shown when dragging from non-zero scroll position', (WidgetTester tester) async { refreshCalled = false; final ScrollController scrollController = ScrollController(); await tester.pumpWidget( @@ -567,6 +675,6 @@ void main() { await tester.pump(); await tester.pump(const Duration(seconds: 1)); // finish the scroll animation await tester.pump(const Duration(seconds: 1)); // finish the indicator settle animation - expect(tester.getCenter(find.byType(RefreshProgressIndicator)).dy, greaterThan(300.0)); + expect(find.byType(RefreshProgressIndicator), findsNothing); }); }