From a4b0d973fb196ba86da5c6ae4d51db40a78a0926 Mon Sep 17 00:00:00 2001 From: Pavlo Kochylo Date: Fri, 23 Aug 2024 22:37:24 +0200 Subject: [PATCH] Added new constructor RefreshIndicator.noSpinner() (#152075) This PR adds a new constructor to the RefreshIndicator's class, which is `noSpinner`. The purpose of this new constructor is to create a RefreshIndicator that doesn't show a spinner when the user arms it by pulling. The work is based on a partial that is here: https://github.com/flutter/flutter/pull/133507 I addressed the following issues reported in the PR above: - in the example for `noSpinner`, arming the RefreshIndicator now shows a CircularProgressIndicator, instead of just printing text to the console; - added a test for the new example; - added a doc comment on the new constructor; Fixes https://github.com/flutter/flutter/issues/132775 --- .../refresh_indicator.2.dart | 95 ++++++ .../refresh_indicator.2_test.dart | 26 ++ .../lib/src/material/refresh_indicator.dart | 272 ++++++++++++------ .../test/material/refresh_indicator_test.dart | 58 ++++ 4 files changed, 360 insertions(+), 91 deletions(-) create mode 100644 examples/api/lib/material/refresh_indicator/refresh_indicator.2.dart create mode 100644 examples/api/test/material/refresh_indicator/refresh_indicator.2_test.dart diff --git a/examples/api/lib/material/refresh_indicator/refresh_indicator.2.dart b/examples/api/lib/material/refresh_indicator/refresh_indicator.2.dart new file mode 100644 index 0000000000..6b33078b13 --- /dev/null +++ b/examples/api/lib/material/refresh_indicator/refresh_indicator.2.dart @@ -0,0 +1,95 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; + +/// Flutter code sample for [RefreshIndicator.noSpinner]. + +void main() => runApp(const RefreshIndicatorExampleApp()); + +class RefreshIndicatorExampleApp extends StatelessWidget { + const RefreshIndicatorExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + home: RefreshIndicatorExample(), + ); + } +} + +class RefreshIndicatorExample extends StatefulWidget { + const RefreshIndicatorExample({super.key}); + + @override + State createState() => _RefreshIndicatorExampleState(); +} + +class _RefreshIndicatorExampleState extends State { + bool _isRefreshing = false; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('RefreshIndicator.noSpinner Sample'), + ), + body: Stack( + children: [ + RefreshIndicator.noSpinner( + // Callback function used by the app to listen to the + // status of the RefreshIndicator pull-down action. + onStatusChange: (RefreshIndicatorStatus? status) { + if (status == RefreshIndicatorStatus.done) { + setState(() { + _isRefreshing = false; + }); + } + }, + + // Callback that gets called whenever the user pulls down to refresh. + onRefresh: () async { + // This can be also done in onStatusChange when the status is RefreshIndicatorStatus.refresh. + setState(() { + _isRefreshing = true; + }); + + // Replace this delay with the code to be executed during refresh + // and return asynchronous code. + return Future.delayed(const Duration(seconds: 3)); + }, + + child: CustomScrollView( + slivers: [ + SliverList.builder( + itemCount: 20, + itemBuilder: (BuildContext context, int index) { + return ListTile( + tileColor: Colors.green[100], + title: const Text('Pull down here'), + subtitle: const Text('A custom refresh indicator will be shown'), + ); + } + ) + ], + ), + ), + + // Shows an overlay with a CircularProgressIndicator when refreshing. + if (_isRefreshing) + ColoredBox( + color: Colors.black45, + child: Align( + child: CircularProgressIndicator( + color: Colors.purple[500], + strokeWidth: 10, + semanticsLabel: 'Circular progress indicator', + ), + ), + ), + ] + ), + ); + } +} diff --git a/examples/api/test/material/refresh_indicator/refresh_indicator.2_test.dart b/examples/api/test/material/refresh_indicator/refresh_indicator.2_test.dart new file mode 100644 index 0000000000..c78e225fbe --- /dev/null +++ b/examples/api/test/material/refresh_indicator/refresh_indicator.2_test.dart @@ -0,0 +1,26 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_api_samples/material/refresh_indicator/refresh_indicator.2.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Pulling from scroll view triggers a refresh indicator which shows a CircularProgressIndicator', (WidgetTester tester) async { + await tester.pumpWidget( + const example.RefreshIndicatorExampleApp(), + ); + + // Pull the first item. + await tester.fling(find.text('Pull down here').first, const Offset(0.0, 300.0), 1000.0); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(const Duration(seconds: 1)); + + expect(find.byType(RefreshProgressIndicator), findsNothing); + expect(find.bySemanticsLabel('Circular progress indicator'), findsOneWidget); + + await tester.pumpAndSettle(); // Advance pending time. + }); +} diff --git a/packages/flutter/lib/src/material/refresh_indicator.dart b/packages/flutter/lib/src/material/refresh_indicator.dart index 459715a740..4b5d42222c 100644 --- a/packages/flutter/lib/src/material/refresh_indicator.dart +++ b/packages/flutter/lib/src/material/refresh_indicator.dart @@ -40,15 +40,25 @@ const Duration _kIndicatorScaleDuration = Duration(milliseconds: 200); /// Used by [RefreshIndicator.onRefresh]. typedef RefreshCallback = Future Function(); -// The state machine moves through these modes only when the scrollable -// identified by scrollableKey has been scrolled to its min or max limit. -enum _RefreshIndicatorMode { - drag, // Pointer is down. - armed, // Dragged far enough that an up event will run the onRefresh callback. - snap, // Animating to the indicator's final "displacement". - refresh, // Running the refresh callback. - done, // Animating the indicator's fade-out after refreshing. - canceled, // Animating the indicator's fade-out after not arming. +/// Indicates current status of Material `RefreshIndicator`. +enum RefreshIndicatorStatus { + /// Pointer is down. + drag, + + /// Dragged far enough that an up event will run the onRefresh callback. + armed, + + /// Animating to the indicator's final "displacement". + snap, + + /// Running the refresh callback. + refresh, + + /// Animating the indicator's fade-out after refreshing. + done, + + /// Animating the indicator's fade-out after not arming. + canceled, } /// Used to configure how [RefreshIndicator] can be triggered. @@ -62,7 +72,7 @@ enum RefreshIndicatorTriggerMode { onEdge, } -enum _IndicatorType { material, adaptive } +enum _IndicatorType { material, adaptive, noSpinner } /// A widget that supports the Material "swipe to refresh" idiom. /// @@ -90,6 +100,12 @@ enum _IndicatorType { material, adaptive } /// ** See code in examples/api/lib/material/refresh_indicator/refresh_indicator.1.dart ** /// {@end-tool} /// +/// {@tool dartpad} +/// This example shows how to use [RefreshIndicator] without the spinner. +/// +/// ** See code in examples/api/lib/material/refresh_indicator/refresh_indicator.2.dart ** +/// {@end-tool} +/// /// ## Troubleshooting /// /// ### Refresh indicator does not show up @@ -143,7 +159,8 @@ class RefreshIndicator extends StatefulWidget { this.semanticsValue, this.strokeWidth = RefreshProgressIndicator.defaultStrokeWidth, this.triggerMode = RefreshIndicatorTriggerMode.onEdge, - }) : _indicatorType = _IndicatorType.material; + }) : _indicatorType = _IndicatorType.material, + onStatusChange = null; /// Creates an adaptive [RefreshIndicator] based on whether the target /// platform is iOS or macOS, following Material design's @@ -174,7 +191,31 @@ class RefreshIndicator extends StatefulWidget { this.semanticsValue, this.strokeWidth = RefreshProgressIndicator.defaultStrokeWidth, this.triggerMode = RefreshIndicatorTriggerMode.onEdge, - }) : _indicatorType = _IndicatorType.adaptive; + }) : _indicatorType = _IndicatorType.adaptive, + onStatusChange = null; + + /// Creates a [RefreshIndicator] with no spinner and calls `onRefresh` when + /// successfully armed by a drag event. + /// + /// Events can be optionally listened by using the `onStatusChange` callback. + const RefreshIndicator.noSpinner({ + super.key, + required this.child, + required this.onRefresh, + this.onStatusChange, + this.notificationPredicate = defaultScrollNotificationPredicate, + this.semanticsLabel, + this.semanticsValue, + this.triggerMode = RefreshIndicatorTriggerMode.onEdge, + }) : _indicatorType = _IndicatorType.noSpinner, + // The following parameters aren't used because [_IndicatorType.noSpinner] is being used, + // which involves showing no spinner, hence the following parameters are useless since + // their only use is to change the spinner's appearance. + displacement = 0.0, + edgeOffset = 0.0, + color = null, + backgroundColor = null, + strokeWidth = 0.0; /// The widget below this widget in the tree. /// @@ -214,6 +255,10 @@ class RefreshIndicator extends StatefulWidget { /// [Future] must complete when the refresh operation is finished. final RefreshCallback onRefresh; + /// Called to get the current status of the [RefreshIndicator] to update the UI as needed. + /// This is an optional parameter, used to fine tune app cases. + final ValueChanged? onStatusChange; + /// The progress indicator's foreground color. The current theme's /// [ColorScheme.primary] by default. final Color? color; @@ -266,7 +311,8 @@ class RefreshIndicator extends StatefulWidget { /// Contains the state for a [RefreshIndicator]. This class can be used to /// programmatically show the refresh indicator, see the [show] method. -class RefreshIndicatorState extends State with TickerProviderStateMixin { +class RefreshIndicatorState extends State + with TickerProviderStateMixin { late AnimationController _positionController; late AnimationController _scaleController; late Animation _positionFactor; @@ -274,22 +320,35 @@ class RefreshIndicatorState extends State with TickerProviderS late Animation _value; late Animation _valueColor; - _RefreshIndicatorMode? _mode; + RefreshIndicatorStatus? _status; late Future _pendingRefreshFuture; bool? _isIndicatorAtTop; double? _dragOffset; late Color _effectiveValueColor = widget.color ?? Theme.of(context).colorScheme.primary; - static final Animatable _threeQuarterTween = Tween(begin: 0.0, end: 0.75); - static final Animatable _kDragSizeFactorLimitTween = Tween(begin: 0.0, end: _kDragSizeFactorLimit); - static final Animatable _oneToZeroTween = Tween(begin: 1.0, end: 0.0); + static final Animatable _threeQuarterTween = Tween( + begin: 0.0, + end: 0.75, + ); + + static final Animatable _kDragSizeFactorLimitTween = Tween( + begin: 0.0, + end: _kDragSizeFactorLimit, + ); + + static final Animatable _oneToZeroTween = Tween( + begin: 1.0, + end: 0.0, + ); @override void initState() { super.initState(); _positionController = AnimationController(vsync: this); _positionFactor = _positionController.drive(_kDragSizeFactorLimitTween); - _value = _positionController.drive(_threeQuarterTween); // The "value" of the circular progress indicator during a drag. + + // The "value" of the circular progress indicator during a drag. + _value = _positionController.drive(_threeQuarterTween); _scaleController = AnimationController(vsync: this); _scaleFactor = _scaleController.drive(_oneToZeroTween); @@ -342,12 +401,26 @@ class RefreshIndicatorState extends State with TickerProviderS // If the notification.dragDetails is null, this scroll is not triggered by // user dragging. It may be a result of ScrollController.jumpTo or ballistic scroll. // In this case, we don't want to trigger the refresh indicator. - return ((notification is ScrollStartNotification && notification.dragDetails != null) - || (notification is ScrollUpdateNotification && notification.dragDetails != null && widget.triggerMode == RefreshIndicatorTriggerMode.anywhere)) - && (( notification.metrics.axisDirection == AxisDirection.up && notification.metrics.extentAfter == 0.0) - || (notification.metrics.axisDirection == AxisDirection.down && notification.metrics.extentBefore == 0.0)) - && _mode == null - && _start(notification.metrics.axisDirection); + return ( + ( + notification is ScrollStartNotification + && notification.dragDetails != null + ) || ( + notification is ScrollUpdateNotification + && notification.dragDetails != null + && widget.triggerMode == RefreshIndicatorTriggerMode.anywhere + ) + ) + && ( + ( + notification.metrics.axisDirection == AxisDirection.up + && notification.metrics.extentAfter == 0.0 + ) || ( + notification.metrics.axisDirection == AxisDirection.down + && notification.metrics.extentBefore == 0.0 + ) + ) + && _status == null && _start(notification.metrics.axisDirection); } bool _handleScrollNotification(ScrollNotification notification) { @@ -356,7 +429,8 @@ class RefreshIndicatorState extends State with TickerProviderS } if (_shouldStart(notification)) { setState(() { - _mode = _RefreshIndicatorMode.drag; + _status = RefreshIndicatorStatus.drag; + widget.onStatusChange?.call(_status); }); return false; } @@ -365,11 +439,13 @@ class RefreshIndicatorState extends State with TickerProviderS AxisDirection.left || AxisDirection.right => null, }; if (indicatorAtTopNow != _isIndicatorAtTop) { - if (_mode == _RefreshIndicatorMode.drag || _mode == _RefreshIndicatorMode.armed) { - _dismiss(_RefreshIndicatorMode.canceled); + if (_status == RefreshIndicatorStatus.drag || + _status == RefreshIndicatorStatus.armed) { + _dismiss(RefreshIndicatorStatus.canceled); } } else if (notification is ScrollUpdateNotification) { - if (_mode == _RefreshIndicatorMode.drag || _mode == _RefreshIndicatorMode.armed) { + if (_status == RefreshIndicatorStatus.drag || + _status == RefreshIndicatorStatus.armed) { if (notification.metrics.axisDirection == AxisDirection.down) { _dragOffset = _dragOffset! - notification.scrollDelta!; } else if (notification.metrics.axisDirection == AxisDirection.up) { @@ -377,14 +453,16 @@ class RefreshIndicatorState extends State with TickerProviderS } _checkDragOffset(notification.metrics.viewportDimension); } - if (_mode == _RefreshIndicatorMode.armed && notification.dragDetails == null) { + if (_status == RefreshIndicatorStatus.armed && + notification.dragDetails == null) { // On iOS start the refresh when the Scrollable bounces back from the // overscroll (ScrollNotification indicating this don't have dragDetails // because the scroll activity is not directly triggered by a drag). _show(); } } else if (notification is OverscrollNotification) { - if (_mode == _RefreshIndicatorMode.drag || _mode == _RefreshIndicatorMode.armed) { + if (_status == RefreshIndicatorStatus.drag || + _status == RefreshIndicatorStatus.armed) { if (notification.metrics.axisDirection == AxisDirection.down) { _dragOffset = _dragOffset! - notification.overscroll; } else if (notification.metrics.axisDirection == AxisDirection.up) { @@ -393,19 +471,19 @@ class RefreshIndicatorState extends State with TickerProviderS _checkDragOffset(notification.metrics.viewportDimension); } } else if (notification is ScrollEndNotification) { - switch (_mode) { - case _RefreshIndicatorMode.armed: + switch (_status) { + case RefreshIndicatorStatus.armed: if (_positionController.value < 1.0) { - _dismiss(_RefreshIndicatorMode.canceled); + _dismiss(RefreshIndicatorStatus.canceled); } else { _show(); } - case _RefreshIndicatorMode.drag: - _dismiss(_RefreshIndicatorMode.canceled); - case _RefreshIndicatorMode.canceled: - case _RefreshIndicatorMode.done: - case _RefreshIndicatorMode.refresh: - case _RefreshIndicatorMode.snap: + case RefreshIndicatorStatus.drag: + _dismiss(RefreshIndicatorStatus.canceled); + case RefreshIndicatorStatus.canceled: + case RefreshIndicatorStatus.done: + case RefreshIndicatorStatus.refresh: + case RefreshIndicatorStatus.snap: case null: // do nothing break; @@ -418,7 +496,7 @@ class RefreshIndicatorState extends State with TickerProviderS if (notification.depth != 0 || !notification.leading) { return false; } - if (_mode == _RefreshIndicatorMode.drag) { + if (_status == RefreshIndicatorStatus.drag) { notification.disallowIndicator(); return true; } @@ -426,7 +504,7 @@ class RefreshIndicatorState extends State with TickerProviderS } bool _start(AxisDirection direction) { - assert(_mode == null); + assert(_status == null); assert(_isIndicatorAtTop == null); assert(_dragOffset == null); switch (direction) { @@ -446,67 +524,74 @@ class RefreshIndicatorState extends State with TickerProviderS } void _checkDragOffset(double containerExtent) { - assert(_mode == _RefreshIndicatorMode.drag || _mode == _RefreshIndicatorMode.armed); + assert( + _status == RefreshIndicatorStatus.drag + || _status == RefreshIndicatorStatus.armed + ); double newValue = _dragOffset! / (containerExtent * _kDragContainerExtentPercentage); - if (_mode == _RefreshIndicatorMode.armed) { + if (_status == RefreshIndicatorStatus.armed) { newValue = math.max(newValue, 1.0 / _kDragSizeFactorLimit); } - _positionController.value = clampDouble(newValue, 0.0, 1.0); // this triggers various rebuilds - if (_mode == _RefreshIndicatorMode.drag && _valueColor.value!.alpha == _effectiveValueColor.alpha) { - _mode = _RefreshIndicatorMode.armed; + _positionController.value = clampDouble(newValue, 0.0, 1.0); // This triggers various rebuilds. + if (_status == RefreshIndicatorStatus.drag && _valueColor.value!.alpha == _effectiveValueColor.alpha) { + _status = RefreshIndicatorStatus.armed; + widget.onStatusChange?.call(_status); } } // Stop showing the refresh indicator. - Future _dismiss(_RefreshIndicatorMode newMode) async { + Future _dismiss(RefreshIndicatorStatus newMode) async { await Future.value(); // This can only be called from _show() when refreshing and // _handleScrollNotification in response to a ScrollEndNotification or // direction change. - assert(newMode == _RefreshIndicatorMode.canceled || newMode == _RefreshIndicatorMode.done); + assert(newMode == RefreshIndicatorStatus.canceled || + newMode == RefreshIndicatorStatus.done); setState(() { - _mode = newMode; + _status = newMode; + widget.onStatusChange?.call(_status); }); - switch (_mode!) { - case _RefreshIndicatorMode.done: + switch (_status!) { + case RefreshIndicatorStatus.done: await _scaleController.animateTo(1.0, duration: _kIndicatorScaleDuration); - case _RefreshIndicatorMode.canceled: + case RefreshIndicatorStatus.canceled: await _positionController.animateTo(0.0, duration: _kIndicatorScaleDuration); - case _RefreshIndicatorMode.armed: - case _RefreshIndicatorMode.drag: - case _RefreshIndicatorMode.refresh: - case _RefreshIndicatorMode.snap: + case RefreshIndicatorStatus.armed: + case RefreshIndicatorStatus.drag: + case RefreshIndicatorStatus.refresh: + case RefreshIndicatorStatus.snap: assert(false); } - if (mounted && _mode == newMode) { + if (mounted && _status == newMode) { _dragOffset = null; _isIndicatorAtTop = null; setState(() { - _mode = null; + _status = null; }); } } void _show() { - assert(_mode != _RefreshIndicatorMode.refresh); - assert(_mode != _RefreshIndicatorMode.snap); + assert(_status != RefreshIndicatorStatus.refresh); + assert(_status != RefreshIndicatorStatus.snap); final Completer completer = Completer(); _pendingRefreshFuture = completer.future; - _mode = _RefreshIndicatorMode.snap; + _status = RefreshIndicatorStatus.snap; + widget.onStatusChange?.call(_status); _positionController .animateTo(1.0 / _kDragSizeFactorLimit, duration: _kIndicatorSnapDuration) .then((void value) { - if (mounted && _mode == _RefreshIndicatorMode.snap) { + if (mounted && _status == RefreshIndicatorStatus.snap) { setState(() { // Show the indeterminate progress indicator. - _mode = _RefreshIndicatorMode.refresh; + _status = RefreshIndicatorStatus.refresh; }); final Future refreshResult = widget.onRefresh(); refreshResult.whenComplete(() { - if (mounted && _mode == _RefreshIndicatorMode.refresh) { + if (mounted && _status == RefreshIndicatorStatus.refresh) { completer.complete(); - _dismiss(_RefreshIndicatorMode.done); + _dismiss(RefreshIndicatorStatus.done); } }); } @@ -529,10 +614,10 @@ class RefreshIndicatorState extends State with TickerProviderS /// When initiated in this manner, the refresh indicator is independent of any /// actual scroll view. It defaults to showing the indicator at the top. To /// show it at the bottom, set `atTop` to false. - Future show({ bool atTop = true }) { - if (_mode != _RefreshIndicatorMode.refresh && - _mode != _RefreshIndicatorMode.snap) { - if (_mode == null) { + Future show({bool atTop = true}) { + if (_status != RefreshIndicatorStatus.refresh && + _status != RefreshIndicatorStatus.snap) { + if (_status == null) { _start(atTop ? AxisDirection.down : AxisDirection.up); } _show(); @@ -551,7 +636,7 @@ class RefreshIndicatorState extends State with TickerProviderS ), ); assert(() { - if (_mode == null) { + if (_status == null) { assert(_dragOffset == null); assert(_isIndicatorAtTop == null); } else { @@ -561,34 +646,36 @@ class RefreshIndicatorState extends State with TickerProviderS return true; }()); - final bool showIndeterminateIndicator = - _mode == _RefreshIndicatorMode.refresh || _mode == _RefreshIndicatorMode.done; + final bool showIndeterminateIndicator = _status == RefreshIndicatorStatus.refresh + || _status == RefreshIndicatorStatus.done; return Stack( children: [ child, - if (_mode != null) Positioned( + if (_status != null) Positioned( top: _isIndicatorAtTop! ? widget.edgeOffset : null, bottom: !_isIndicatorAtTop! ? widget.edgeOffset : null, left: 0.0, right: 0.0, child: SizeTransition( axisAlignment: _isIndicatorAtTop! ? 1.0 : -1.0, - sizeFactor: _positionFactor, // this is what brings it down + sizeFactor: _positionFactor, // This is what brings it down. child: Container( padding: _isIndicatorAtTop! - ? EdgeInsets.only(top: widget.displacement) - : EdgeInsets.only(bottom: widget.displacement), + ? EdgeInsets.only(top: widget.displacement) + : EdgeInsets.only(bottom: widget.displacement), alignment: _isIndicatorAtTop! - ? Alignment.topCenter - : Alignment.bottomCenter, + ? Alignment.topCenter + : Alignment.bottomCenter, child: ScaleTransition( scale: _scaleFactor, child: AnimatedBuilder( animation: _positionController, builder: (BuildContext context, Widget? child) { final Widget materialIndicator = RefreshProgressIndicator( - semanticsLabel: widget.semanticsLabel ?? MaterialLocalizations.of(context).refreshIndicatorSemanticLabel, + semanticsLabel: widget.semanticsLabel ?? + MaterialLocalizations.of(context) + .refreshIndicatorSemanticLabel, semanticsValue: widget.semanticsValue, value: showIndeterminateIndicator ? null : _value.value, valueColor: _valueColor, @@ -604,19 +691,22 @@ class RefreshIndicatorState extends State with TickerProviderS case _IndicatorType.material: return materialIndicator; - case _IndicatorType.adaptive: { - final ThemeData theme = Theme.of(context); - switch (theme.platform) { - case TargetPlatform.android: - case TargetPlatform.fuchsia: - case TargetPlatform.linux: - case TargetPlatform.windows: - return materialIndicator; - case TargetPlatform.iOS: - case TargetPlatform.macOS: - return cupertinoIndicator; + case _IndicatorType.adaptive: + { + final ThemeData theme = Theme.of(context); + switch (theme.platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + return materialIndicator; + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return cupertinoIndicator; + } } - } + case _IndicatorType.noSpinner: + return Container(); } }, ), diff --git a/packages/flutter/test/material/refresh_indicator_test.dart b/packages/flutter/test/material/refresh_indicator_test.dart index 1e4f3dbe11..c0e24ccdfe 100644 --- a/packages/flutter/test/material/refresh_indicator_test.dart +++ b/packages/flutter/test/material/refresh_indicator_test.dart @@ -1140,6 +1140,64 @@ void main() { expect(stretchAccepted, false); }); + group('RefreshIndicator.noSpinner', () { + testWidgets('onStatusChange and onRefresh Trigger', (WidgetTester tester) async { + refreshCalled = false; + bool modeSnap = false; + bool modeDrag = false; + bool modeArmed = false; + bool modeDone = false; + + await tester.pumpWidget(MaterialApp( + home: RefreshIndicator.noSpinner( + onStatusChange: (RefreshIndicatorStatus? mode) { + if (mode == RefreshIndicatorStatus.armed) { + modeArmed = true; + } + if (mode == RefreshIndicatorStatus.drag) { + modeDrag = true; + } + if (mode == RefreshIndicatorStatus.snap) { + modeSnap = true; + } + if (mode == RefreshIndicatorStatus.done) { + modeDone = true; + } + }, + onRefresh: refresh, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: + ['A', 'B', 'C', 'D', 'E', 'F'].map((String item) { + return SizedBox( + height: 200.0, + child: Text(item), + ); + }).toList(), + ), + ), + )); + + await tester.fling(find.text('A'), const Offset(0.0, 300.0), 1000.0); + await tester.pump(); + + // Finish the scroll animation. + await tester.pump(const Duration(seconds: 1)); + + // Finish the indicator settle animation. + await tester.pump(const Duration(seconds: 1)); + + // Finish the indicator hide animation. + await tester.pump(const Duration(seconds: 1)); + + expect(refreshCalled, true); + expect(modeSnap, true); + expect(modeDrag, true); + expect(modeArmed, true); + expect(modeDone, true); + }); + }); + testWidgets('RefreshIndicator manipulates value color opacity correctly', (WidgetTester tester) async { final List colors = [ Colors.black,