diff --git a/lib/core/widgets/app_pull_to_refresh.dart b/lib/core/widgets/app_pull_to_refresh.dart index b71c335..647c080 100644 --- a/lib/core/widgets/app_pull_to_refresh.dart +++ b/lib/core/widgets/app_pull_to_refresh.dart @@ -9,10 +9,11 @@ import '../theme/app_colors.dart'; const double _maxPullDistance = 96; const double _refreshThreshold = 56; -const double _indicatorHeight = 64; +const double _holdDistance = 56; +const double _indicatorHeight = 56; const double _indicatorMaxOffset = 10; const Duration _arrowTurnDuration = Duration(milliseconds: 160); -const Duration _retractDuration = Duration(milliseconds: 200); +const Duration _settleDuration = Duration(milliseconds: 200); enum _PullState { idle, pull, armed, refreshing } @@ -26,7 +27,7 @@ class AppPullToRefresh extends StatefulWidget { }); static const AlwaysScrollableScrollPhysics physics = - AlwaysScrollableScrollPhysics(parent: BouncingScrollPhysics()); + AlwaysScrollableScrollPhysics(parent: ClampingScrollPhysics()); final Widget child; final Future Function() onRefresh; @@ -36,11 +37,28 @@ class AppPullToRefresh extends StatefulWidget { State createState() => _AppPullToRefreshState(); } -class _AppPullToRefreshState extends State { +class _AppPullToRefreshState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _offsetController; _PullState _state = _PullState.idle; - double _pullDistance = 0; bool _refreshing = false; + @override + void initState() { + super.initState(); + _offsetController = AnimationController( + vsync: this, + duration: _settleDuration, + upperBound: _maxPullDistance, + ); + } + + @override + void dispose() { + _offsetController.dispose(); + super.dispose(); + } + bool _onScroll(ScrollNotification notification) { if (notification.depth != 0 || _refreshing) return false; if (notification is ScrollStartNotification) { @@ -53,7 +71,7 @@ class _AppPullToRefreshState extends State { } } else if (notification is OverscrollNotification) { if (notification.dragDetails != null) { - _setPullDistance(_pullDistance - notification.overscroll); + _setPullDistance(_offsetController.value - notification.overscroll); } } else if (notification is ScrollEndNotification) { _release(); @@ -68,19 +86,17 @@ class _AppPullToRefreshState extends State { : distance > 0 ? _PullState.pull : _PullState.idle; - if (distance == _pullDistance && state == _state) return; - setState(() { - _pullDistance = distance; - _state = state; - }); + _offsetController.value = distance; + if (state != _state) setState(() => _state = state); } void _release() { + if (_refreshing) return; if (_state == _PullState.armed) { unawaited(_refresh()); } else if (_state == _PullState.pull) { + _offsetController.value = 0; setState(() { - _pullDistance = 0; _state = _PullState.idle; }); } @@ -90,53 +106,74 @@ class _AppPullToRefreshState extends State { if (_refreshing) return; _refreshing = true; setState(() => _state = _PullState.refreshing); + final refreshFuture = Future.sync(widget.onRefresh); try { - await widget.onRefresh(); + await _animateOffsetTo(_holdDistance); + await refreshFuture; } finally { - await Future.delayed(_retractDuration); if (mounted) { - setState(() { - _pullDistance = 0; - _state = _PullState.idle; - _refreshing = false; - }); + await _animateOffsetTo(0); + if (mounted) { + setState(() { + _state = _PullState.idle; + _refreshing = false; + }); + } } } } + Future _animateOffsetTo(double target) async { + try { + await _offsetController + .animateTo(target, duration: _settleDuration, curve: Curves.easeOut) + .orCancel; + } on TickerCanceled { + // The overlay can be removed while the refresh future is still pending. + } + } + @override Widget build(BuildContext context) { return NotificationListener( onNotification: _onScroll, - child: Stack( - fit: StackFit.expand, - children: [ - Listener( - onPointerUp: (_) => _release(), - onPointerCancel: (_) => _release(), - child: widget.child, - ), - if (_state != _PullState.idle) - Positioned( - top: widget.edgeOffset, - left: 0, - right: 0, - child: IgnorePointer(child: _indicatorLayer(context)), - ), - ], + child: AnimatedBuilder( + animation: _offsetController, + child: Listener( + onPointerUp: (_) => _release(), + onPointerCancel: (_) => _release(), + child: widget.child, + ), + builder: (context, child) { + final offset = _offsetController.value; + return Stack( + fit: StackFit.expand, + children: [ + Transform.translate( + key: const Key('pull-refresh-content'), + offset: Offset(0, offset), + child: IgnorePointer(ignoring: _refreshing, child: child), + ), + if (_state != _PullState.idle && offset > 0) + Positioned( + top: widget.edgeOffset, + left: 0, + right: 0, + child: IgnorePointer(child: _indicatorLayer(context, offset)), + ), + ], + ); + }, ), ); } - Widget _indicatorLayer(BuildContext context) { - final progress = (_pullDistance / _refreshThreshold).clamp(0.0, 1.0); - final opacity = _state == _PullState.refreshing - ? 1.0 - : progress.clamp(0.0, 1.0); + Widget _indicatorLayer(BuildContext context, double offset) { + final progress = (offset / _refreshThreshold).clamp(0.0, 1.0); return SizedBox( height: _indicatorHeight, child: Opacity( - opacity: opacity, + opacity: progress, child: Transform.translate( offset: Offset(0, _indicatorMaxOffset * progress), child: Align( diff --git a/test/core/app_pull_to_refresh_test.dart b/test/core/app_pull_to_refresh_test.dart index deb4df4..d69d032 100644 --- a/test/core/app_pull_to_refresh_test.dart +++ b/test/core/app_pull_to_refresh_test.dart @@ -9,76 +9,92 @@ import 'package:fruitshorts/core/widgets/app_pull_to_refresh.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; void main() { - testWidgets( - 'should_show_pull_release_and_refreshing_states_and_refresh_once', - (tester) async { - final refresh = Completer(); - var calls = 0; - await tester.pumpWidget( - MaterialApp( - locale: const Locale('en'), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: Scaffold( - body: AppPullToRefresh( - onRefresh: () { - calls++; - return refresh.future; - }, - child: ListView( - key: const Key('pull-refresh-list'), - physics: AppPullToRefresh.physics, - children: const [SizedBox(height: 1200)], - ), + testWidgets('should_hold_pull_offset_until_refresh_completes', ( + tester, + ) async { + final refresh = Completer(); + var calls = 0; + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: AppPullToRefresh( + onRefresh: () { + calls++; + return refresh.future; + }, + 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 scrollable = tester.state( - find.descendant(of: list, matching: find.byType(Scrollable)).first, - ); - expect(scrollable.position.pixels, lessThan(0)); - expect(find.text('Pull to refresh'), findsOneWidget); - expect( - tester.widget(find.text('Pull to refresh')).style?.color, - AppColors.blue, - ); + 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 scrollable = tester.state( + find.descendant(of: list, matching: find.byType(Scrollable)).first, + ); + expect(scrollable.position.pixels, 0); + expect(find.text('Pull to refresh'), findsOneWidget); + expect(_contentOffset(tester), greaterThan(0)); + expect( + tester.widget(find.text('Pull to refresh')).style?.color, + AppColors.blue, + ); - await gesture.moveBy(const Offset(0, 120)); - await tester.pump(); - expect(find.text('Release to refresh'), findsOneWidget); - expect( - tester.widget(find.text('Release to refresh')).style?.color, - AppColors.blue, - ); + await gesture.moveBy(const Offset(0, 120)); + await tester.pump(); + expect(find.text('Release to refresh'), findsOneWidget); + expect( + tester.widget(find.text('Release to refresh')).style?.color, + AppColors.blue, + ); - await gesture.up(); - await tester.pump(); - expect(find.text('Refreshing…'), findsOneWidget); - expect( - tester.widget(find.text('Refreshing…')).style?.color, - AppColors.blue, - ); - expect(calls, 1); + await gesture.up(); + await tester.pump(); + expect(find.text('Refreshing…'), findsOneWidget); + expect( + tester.widget(find.text('Refreshing…')).style?.color, + AppColors.blue, + ); + expect(calls, 1); + expect(_contentOffset(tester), greaterThan(56)); + expect(find.byType(CircularProgressIndicator), findsOneWidget); - final duplicate = await tester.startGesture( - tester.getCenter(find.byKey(const Key('pull-refresh-list'))), - ); - await duplicate.moveBy(const Offset(0, 70)); - await duplicate.up(); - await tester.pump(); - expect(calls, 1); + 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)); - refresh.complete(); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 200)); - expect(find.text('Refreshing…'), findsNothing); - expect(find.text('Pull to refresh'), findsNothing); - }, + final duplicate = await tester.startGesture( + tester.getCenter(find.byKey(const Key('pull-refresh-list'))), + ); + await duplicate.moveBy(const Offset(0, 70)); + await duplicate.up(); + await tester.pump(); + expect(calls, 1); + expect(_contentOffset(tester), closeTo(56, 0.1)); + + refresh.complete(); + await tester.pump(); + await tester.pumpAndSettle(); + expect(_contentOffset(tester), closeTo(0, 0.1)); + expect(find.text('Refreshing…'), findsNothing); + expect(find.text('Pull to refresh'), findsNothing); + }); +} + +double _contentOffset(WidgetTester tester) { + final transform = tester.widget( + find.byKey(const Key('pull-refresh-content')), ); + return transform.transform.storage[13]; }