diff --git a/lib/core/widgets/app_pull_to_refresh.dart b/lib/core/widgets/app_pull_to_refresh.dart index 647c080..1961981 100644 --- a/lib/core/widgets/app_pull_to_refresh.dart +++ b/lib/core/widgets/app_pull_to_refresh.dart @@ -9,7 +9,6 @@ import '../theme/app_colors.dart'; const double _maxPullDistance = 96; const double _refreshThreshold = 56; -const double _holdDistance = 56; const double _indicatorHeight = 56; const double _indicatorMaxOffset = 10; const Duration _arrowTurnDuration = Duration(milliseconds: 160); @@ -42,6 +41,7 @@ class _AppPullToRefreshState extends State late final AnimationController _offsetController; _PullState _state = _PullState.idle; bool _refreshing = false; + bool _retracting = false; @override void initState() { @@ -60,7 +60,7 @@ class _AppPullToRefreshState extends State } bool _onScroll(ScrollNotification notification) { - if (notification.depth != 0 || _refreshing) return false; + if (notification.depth != 0 || _refreshing || _retracting) return false; if (notification is ScrollStartNotification) { if (notification.dragDetails != null) _setPullDistance(0); } else if (notification is ScrollUpdateNotification) { @@ -91,24 +91,31 @@ class _AppPullToRefreshState extends State } void _release() { - if (_refreshing) return; + if (_refreshing || _retracting) return; if (_state == _PullState.armed) { unawaited(_refresh()); } else if (_state == _PullState.pull) { - _offsetController.value = 0; - setState(() { - _state = _PullState.idle; - }); + unawaited(_retract()); } } + Future _retract() async { + if (_retracting) return; + _retracting = true; + await _animateOffsetTo(0); + if (!mounted) return; + setState(() { + _state = _PullState.idle; + _retracting = false; + }); + } + Future _refresh() async { if (_refreshing) return; _refreshing = true; setState(() => _state = _PullState.refreshing); final refreshFuture = Future.sync(widget.onRefresh); try { - await _animateOffsetTo(_holdDistance); await refreshFuture; } finally { if (mounted) { @@ -152,7 +159,10 @@ class _AppPullToRefreshState extends State Transform.translate( key: const Key('pull-refresh-content'), offset: Offset(0, offset), - child: IgnorePointer(ignoring: _refreshing, child: child), + child: IgnorePointer( + ignoring: _refreshing || _retracting, + child: child, + ), ), if (_state != _PullState.idle && offset > 0) Positioned( diff --git a/test/core/app_pull_to_refresh_test.dart b/test/core/app_pull_to_refresh_test.dart index d69d032..8cf5e49 100644 --- a/test/core/app_pull_to_refresh_test.dart +++ b/test/core/app_pull_to_refresh_test.dart @@ -9,9 +9,10 @@ import 'package:fruitshorts/core/widgets/app_pull_to_refresh.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; void main() { - testWidgets('should_hold_pull_offset_until_refresh_completes', ( + testWidgets('should_freeze_pull_offset_until_refresh_completes', ( tester, ) async { + expect(AppPullToRefresh.physics.parent, isA()); final refresh = Completer(); var calls = 0; await tester.pumpWidget( @@ -66,13 +67,12 @@ void main() { AppColors.blue, ); expect(calls, 1); - expect(_contentOffset(tester), greaterThan(56)); + final releaseOffset = _contentOffset(tester); + expect(releaseOffset, greaterThan(56)); expect(find.byType(CircularProgressIndicator), findsOneWidget); - await tester.pump(const Duration(milliseconds: 200)); - expect(_contentOffset(tester), closeTo(56, 0.1)); await tester.pump(const Duration(milliseconds: 300)); - expect(_contentOffset(tester), closeTo(56, 0.1)); + expect(_contentOffset(tester), closeTo(releaseOffset, 0.1)); final duplicate = await tester.startGesture( tester.getCenter(find.byKey(const Key('pull-refresh-list'))), @@ -81,7 +81,7 @@ void main() { await duplicate.up(); await tester.pump(); expect(calls, 1); - expect(_contentOffset(tester), closeTo(56, 0.1)); + expect(_contentOffset(tester), closeTo(releaseOffset, 0.1)); refresh.complete(); await tester.pump(); @@ -90,6 +90,54 @@ void main() { expect(find.text('Refreshing…'), findsNothing); expect(find.text('Pull to refresh'), findsNothing); }); + + testWidgets('should_animate_back_when_released_below_threshold', ( + tester, + ) async { + var calls = 0; + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: AppPullToRefresh( + onRefresh: () async { + calls++; + }, + child: ListView( + key: const Key('pull-refresh-list'), + physics: AppPullToRefresh.physics, + children: const [SizedBox(height: 1200)], + ), + ), + ), + ), + ); + + final list = find.byKey(const Key('pull-refresh-list')); + final gesture = await tester.startGesture(tester.getCenter(list)); + await gesture.moveBy(const Offset(0, 30)); + await tester.pump(); + final releaseOffset = _contentOffset(tester); + expect(releaseOffset, greaterThan(0)); + expect(releaseOffset, lessThan(56)); + expect(find.text('Pull to refresh'), findsOneWidget); + + await gesture.up(); + await tester.pump(); + expect(calls, 0); + expect(find.text('Refreshing…'), findsNothing); + expect(_contentOffset(tester), greaterThan(0)); + + await tester.pump(const Duration(milliseconds: 100)); + expect(_contentOffset(tester), greaterThan(0)); + expect(_contentOffset(tester), lessThan(releaseOffset)); + + await tester.pumpAndSettle(); + expect(_contentOffset(tester), closeTo(0, 0.1)); + expect(find.text('Pull to refresh'), findsNothing); + }); } double _contentOffset(WidgetTester tester) {