// 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. @TestOn('!chrome') import 'dart:async'; import 'package:flutter/cupertino.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { late FakeBuilder mockHelper; setUp(() { mockHelper = FakeBuilder(); }); int testListLength = 10; SliverList buildAListOfStuff() { return SliverList( delegate: SliverChildBuilderDelegate( (BuildContext context, int index) { return SizedBox( height: 200.0, child: Center(child: Text(index.toString())), ); }, childCount: testListLength, ), ); } void uiTestGroup() { testWidgets("doesn't invoke anything without user interaction", (WidgetTester tester) async { await tester.pumpWidget( CupertinoApp( home: CustomScrollView( slivers: <Widget>[ CupertinoSliverRefreshControl( builder: mockHelper.builder, ), buildAListOfStuff(), ], ), ), ); expect(mockHelper.invocations, isEmpty); expect( tester.getTopLeft(find.widgetWithText(SizedBox, '0')), Offset.zero, ); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets('calls the indicator builder when starting to overscroll', (WidgetTester tester) async { await tester.pumpWidget( CupertinoApp( home: CustomScrollView( slivers: <Widget>[ CupertinoSliverRefreshControl( builder: mockHelper.builder, ), buildAListOfStuff(), ], ), ), ); // Drag down but not enough to trigger the refresh. await tester.drag(find.text('0'), const Offset(0.0, 50.0), touchSlopY: 0); await tester.pump(); // The function is referenced once while passing into CupertinoSliverRefreshControl // and is called. expect(mockHelper.invocations.first, matchesBuilder( refreshState: RefreshIndicatorMode.drag, pulledExtent: 50, refreshTriggerPullDistance: 100, // default value. refreshIndicatorExtent: 60, // default value. )); expect(mockHelper.invocations, hasLength(1)); expect( tester.getTopLeft(find.widgetWithText(SizedBox, '0')), const Offset(0.0, 50.0), ); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets( "don't call the builder if overscroll doesn't move slivers like on Android", (WidgetTester tester) async { await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: MediaQuery( data: const MediaQueryData(), child: CustomScrollView( slivers: <Widget>[ CupertinoSliverRefreshControl( builder: mockHelper.builder, ), buildAListOfStuff(), ], ), ), ), ); // Drag down but not enough to trigger the refresh. await tester.drag(find.text('0'), const Offset(0.0, 50.0)); await tester.pump(); expect(mockHelper.invocations, isEmpty); expect( tester.getTopLeft(find.widgetWithText(SizedBox, '0')), Offset.zero, ); }, variant: TargetPlatformVariant.only(TargetPlatform.android), ); testWidgets('let the builder update as canceled drag scrolls away', (WidgetTester tester) async { await tester.pumpWidget( CupertinoApp( home: CustomScrollView( slivers: <Widget>[ CupertinoSliverRefreshControl( builder: mockHelper.builder, ), buildAListOfStuff(), ], ), ), ); // Drag down but not enough to trigger the refresh. await tester.drag(find.text('0'), const Offset(0.0, 50.0), touchSlopY: 0); await tester.pump(); await tester.pump(const Duration(milliseconds: 20)); await tester.pump(const Duration(milliseconds: 20)); await tester.pump(const Duration(seconds: 3)); expect(mockHelper.invocations, containsAllInOrder(<void>[ matchesBuilder( refreshState: RefreshIndicatorMode.drag, pulledExtent: 50, refreshTriggerPullDistance: 100, // default value. refreshIndicatorExtent: 60, // default value. ), matchesBuilder( refreshState: RefreshIndicatorMode.drag, pulledExtent: moreOrLessEquals(48.36801747187993), refreshTriggerPullDistance: 100, // default value. refreshIndicatorExtent: 60, // default value. ), matchesBuilder( refreshState: RefreshIndicatorMode.drag, pulledExtent: moreOrLessEquals(44.63031931875867), refreshTriggerPullDistance: 100, // default value. refreshIndicatorExtent: 60, // default value. ), ])); // The builder isn't called again when the sliver completely goes away. expect(mockHelper.invocations, hasLength(3)); expect( tester.getTopLeft(find.widgetWithText(SizedBox, '0')), Offset.zero, ); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets('drag past threshold triggers refresh task', (WidgetTester tester) async { final List<MethodCall> platformCallLog = <MethodCall>[]; tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (MethodCall methodCall) async { platformCallLog.add(methodCall); return null; }); await tester.pumpWidget( CupertinoApp( home: CustomScrollView( slivers: <Widget>[ CupertinoSliverRefreshControl( builder: mockHelper.builder, onRefresh: mockHelper.refreshTask, ), buildAListOfStuff(), ], ), ), ); final TestGesture gesture = await tester.startGesture(Offset.zero); await gesture.moveBy(const Offset(0.0, 99.0)); await tester.pump(); await gesture.moveBy(const Offset(0.0, -30.0)); await tester.pump(); await gesture.moveBy(const Offset(0.0, 50.0)); await tester.pump(); expect(mockHelper.invocations, containsAllInOrder(<void>[ matchesBuilder( refreshState: RefreshIndicatorMode.drag, pulledExtent: 99, refreshTriggerPullDistance: 100, // default value. refreshIndicatorExtent: 60, // default value. ), matchesBuilder( refreshState: RefreshIndicatorMode.drag, pulledExtent: moreOrLessEquals(86.78169), refreshTriggerPullDistance: 100, // default value. refreshIndicatorExtent: 60, // default value. ), matchesBuilder( refreshState: RefreshIndicatorMode.armed, pulledExtent: moreOrLessEquals(105.80452021305739), refreshTriggerPullDistance: 100, // default value. refreshIndicatorExtent: 60, // default value. ), ])); // The refresh callback is triggered after the frame. expect(mockHelper.invocations.last, const RefreshTaskInvocation()); expect(mockHelper.invocations, hasLength(4)); expect( platformCallLog.last, isMethodCall('HapticFeedback.vibrate', arguments: 'HapticFeedbackType.mediumImpact'), ); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets( 'refreshing task keeps the sliver expanded forever until done', (WidgetTester tester) async { await tester.pumpWidget( CupertinoApp( home: CustomScrollView( slivers: <Widget>[ CupertinoSliverRefreshControl( builder: mockHelper.builder, onRefresh: mockHelper.refreshTask, ), buildAListOfStuff(), ], ), ), ); await tester.drag(find.text('0'), const Offset(0.0, 150.0), touchSlopY: 0); await tester.pump(); // Let it start snapping back. await tester.pump(const Duration(milliseconds: 50)); expect(mockHelper.invocations, containsAllInOrder(<Matcher>[ matchesBuilder( refreshState: RefreshIndicatorMode.armed, pulledExtent: 150, refreshTriggerPullDistance: 100, // Default value. refreshIndicatorExtent: 60, // Default value. ), equals(const RefreshTaskInvocation()), matchesBuilder( refreshState: RefreshIndicatorMode.armed, pulledExtent: moreOrLessEquals(127.10396988577114), refreshTriggerPullDistance: 100, // Default value. refreshIndicatorExtent: 60, // Default value. ), ])); // Reaches refresh state and sliver's at 60.0 in height after a while. await tester.pump(const Duration(seconds: 1)); expect(mockHelper.invocations, contains(matchesBuilder( refreshState: RefreshIndicatorMode.refresh, pulledExtent: 60, refreshIndicatorExtent: 60, // Default value. refreshTriggerPullDistance: 100, // Default value. ))); // Stays in that state forever until future completes. await tester.pump(const Duration(seconds: 1000)); expect( tester.getTopLeft(find.widgetWithText(SizedBox, '0')), const Offset(0.0, 60.0), ); mockHelper.refreshCompleter.complete(null); await tester.pump(); expect(mockHelper.invocations, contains(matchesBuilder( refreshState: RefreshIndicatorMode.done, pulledExtent: 60, refreshIndicatorExtent: 60, // Default value. refreshTriggerPullDistance: 100, // Default value. ))); expect(mockHelper.invocations, hasLength(5)); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }), ); testWidgets( 'refreshing task keeps the sliver expanded forever until completes with error', (WidgetTester tester) async { final FlutterError error = FlutterError('Oops'); double errorCount = 0; runZonedGuarded( () async { mockHelper.refreshCompleter = Completer<void>.sync(); await tester.pumpWidget( CupertinoApp( home: CustomScrollView( slivers: <Widget>[ CupertinoSliverRefreshControl( builder: mockHelper.builder, onRefresh: mockHelper.refreshTask, ), buildAListOfStuff(), ], ), ), ); await tester.drag(find.text('0'), const Offset(0.0, 150.0), touchSlopY: 0); await tester.pump(); // Let it start snapping back. await tester.pump(const Duration(milliseconds: 50)); expect(mockHelper.invocations, containsAllInOrder(<Matcher>[ matchesBuilder( refreshState: RefreshIndicatorMode.armed, pulledExtent: 150, refreshIndicatorExtent: 60, // Default value. refreshTriggerPullDistance: 100, // Default value. ), equals(const RefreshTaskInvocation()), matchesBuilder( refreshState: RefreshIndicatorMode.armed, pulledExtent: moreOrLessEquals(127.10396988577114), refreshIndicatorExtent: 60, // Default value. refreshTriggerPullDistance: 100, // Default value. ), ])); // Reaches refresh state and sliver's at 60.0 in height after a while. await tester.pump(const Duration(seconds: 1)); expect(mockHelper.invocations, contains(matchesBuilder( refreshState: RefreshIndicatorMode.refresh, pulledExtent: 60, refreshIndicatorExtent: 60, // Default value. refreshTriggerPullDistance: 100, // Default value. ))); // Stays in that state forever until future completes. await tester.pump(const Duration(seconds: 1000)); expect( tester.getTopLeft(find.widgetWithText(SizedBox, '0')), const Offset(0.0, 60.0), ); mockHelper.refreshCompleter.completeError(error); await tester.pump(); expect(mockHelper.invocations, contains(matchesBuilder( refreshState: RefreshIndicatorMode.done, pulledExtent: 60, refreshIndicatorExtent: 60, // Default value. refreshTriggerPullDistance: 100, // Default value. ))); expect(mockHelper.invocations, hasLength(5)); }, (Object e, StackTrace stack) { expect(e, error); expect(errorCount, 0); errorCount++; }, ); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }), ); testWidgets('expanded refreshing sliver scrolls normally', (WidgetTester tester) async { mockHelper.refreshIndicator = const Center(child: Text('-1')); await tester.pumpWidget( CupertinoApp( home: CustomScrollView( slivers: <Widget>[ CupertinoSliverRefreshControl( builder: mockHelper.builder, onRefresh: mockHelper.refreshTask, ), buildAListOfStuff(), ], ), ), ); await tester.drag(find.text('0'), const Offset(0.0, 150.0), touchSlopY: 0); await tester.pump(); expect(mockHelper.invocations, contains(matchesBuilder( refreshState: RefreshIndicatorMode.armed, pulledExtent: 150, refreshIndicatorExtent: 60, // Default value. refreshTriggerPullDistance: 100, // Default value. ))); // Given a box constraint of 150, the Center will occupy all that height. expect( tester.getRect(find.widgetWithText(Center, '-1')), const Rect.fromLTRB(0.0, 0.0, 800.0, 150.0), ); await tester.drag(find.text('0'), const Offset(0.0, -300.0), touchSlopY: 0, warnIfMissed: false); // hits the list await tester.pump(); // Refresh indicator still being told to layout the same way. expect(mockHelper.invocations, contains(matchesBuilder( refreshState: RefreshIndicatorMode.refresh, pulledExtent: 60, refreshIndicatorExtent: 60, // Default value. refreshTriggerPullDistance: 100, // Default value. ))); // Now the sliver is scrolled off screen. expect( tester.getTopLeft(find.widgetWithText(Center, '-1', skipOffstage: false)).dy, moreOrLessEquals(-175.38461538461536), ); expect( tester.getBottomLeft(find.widgetWithText(Center, '-1', skipOffstage: false)).dy, moreOrLessEquals(-115.38461538461536), ); expect( tester.getTopLeft(find.widgetWithText(Center, '0')).dy, moreOrLessEquals(-115.38461538461536), ); // Scroll the top of the refresh indicator back to overscroll, it will // snap to the size of the refresh indicator and stay there. await tester.drag(find.text('1'), const Offset(0.0, 200.0), warnIfMissed: false); // hits the list await tester.pump(); await tester.pump(const Duration(seconds: 2)); expect( tester.getRect(find.widgetWithText(Center, '-1')), const Rect.fromLTRB(0.0, 0.0, 800.0, 60.0), ); expect( tester.getRect(find.widgetWithText(Center, '0')), const Rect.fromLTRB(0.0, 60.0, 800.0, 260.0), ); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets('expanded refreshing sliver goes away when done', (WidgetTester tester) async { mockHelper.refreshIndicator = const Center(child: Text('-1')); await tester.pumpWidget( CupertinoApp( home: CustomScrollView( slivers: <Widget>[ CupertinoSliverRefreshControl( builder: mockHelper.builder, onRefresh: mockHelper.refreshTask, ), buildAListOfStuff(), ], ), ), ); await tester.drag(find.text('0'), const Offset(0.0, 150.0), touchSlopY: 0); await tester.pump(); expect(mockHelper.invocations, contains(matchesBuilder( refreshState: RefreshIndicatorMode.armed, pulledExtent: 150, refreshIndicatorExtent: 60, // Default value. refreshTriggerPullDistance: 100, // Default value. ))); expect( tester.getRect(find.widgetWithText(Center, '-1')), const Rect.fromLTRB(0.0, 0.0, 800.0, 150.0), ); expect(mockHelper.invocations, contains(const RefreshTaskInvocation())); // Rebuilds the sliver with a layout extent now. await tester.pump(); // Let it snap back to occupy the indicator's final sliver space only. await tester.pump(const Duration(seconds: 2)); expect(mockHelper.invocations, contains(matchesBuilder( refreshState: RefreshIndicatorMode.refresh, pulledExtent: 60, refreshIndicatorExtent: 60, // Default value. refreshTriggerPullDistance: 100, // Default value. ))); expect( tester.getRect(find.widgetWithText(Center, '-1')), const Rect.fromLTRB(0.0, 0.0, 800.0, 60.0), ); expect( tester.getRect(find.widgetWithText(Center, '0')), const Rect.fromLTRB(0.0, 60.0, 800.0, 260.0), ); mockHelper.refreshCompleter.complete(null); await tester.pump(); expect(mockHelper.invocations, contains(matchesBuilder( refreshState: RefreshIndicatorMode.done, pulledExtent: 60, refreshIndicatorExtent: 60, // Default value. refreshTriggerPullDistance: 100, // Default value. ))); await tester.pump(const Duration(seconds: 5)); expect(find.text('-1'), findsNothing); expect( tester.getRect(find.widgetWithText(Center, '0')), const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0), ); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets('builder still called when sliver snapped back more than 90%', (WidgetTester tester) async { mockHelper.refreshIndicator = const Center(child: Text('-1')); await tester.pumpWidget( CupertinoApp( home: CustomScrollView( slivers: <Widget>[ CupertinoSliverRefreshControl( builder: mockHelper.builder, onRefresh: mockHelper.refreshTask, ), buildAListOfStuff(), ], ), ), ); await tester.drag(find.text('0'), const Offset(0.0, 150.0), touchSlopY: 0); await tester.pump(); expect(mockHelper.invocations, contains(matchesBuilder( refreshState: RefreshIndicatorMode.armed, pulledExtent: 150, refreshTriggerPullDistance: 100, // default value. refreshIndicatorExtent: 60, // default value. ))); expect( tester.getRect(find.widgetWithText(Center, '-1')), const Rect.fromLTRB(0.0, 0.0, 800.0, 150.0), ); expect(mockHelper.invocations, contains(const RefreshTaskInvocation())); // Rebuilds the sliver with a layout extent now. await tester.pump(); // Let it snap back to occupy the indicator's final sliver space only. await tester.pump(const Duration(seconds: 2)); expect(mockHelper.invocations, contains(matchesBuilder( refreshState: RefreshIndicatorMode.refresh, pulledExtent: 60, refreshTriggerPullDistance: 100, // default value. refreshIndicatorExtent: 60, // default value. ))); expect( tester.getRect(find.widgetWithText(Center, '-1')), const Rect.fromLTRB(0.0, 0.0, 800.0, 60.0), ); expect( tester.getRect(find.widgetWithText(Center, '0')), const Rect.fromLTRB(0.0, 60.0, 800.0, 260.0), ); mockHelper.refreshCompleter.complete(null); await tester.pump(); expect(mockHelper.invocations, contains(matchesBuilder( refreshState: RefreshIndicatorMode.done, pulledExtent: 60, refreshTriggerPullDistance: 100, // default value. refreshIndicatorExtent: 60, // default value. ))); // Waiting for refresh control to reach approximately 5% of height await tester.pump(const Duration(milliseconds: 400)); expect( tester.getRect(find.widgetWithText(Center, '0')).top, moreOrLessEquals(3.0, epsilon: 4e-1), ); expect( tester.getRect(find.widgetWithText(Center, '-1')).height, moreOrLessEquals(3.0, epsilon: 4e-1), ); expect(mockHelper.invocations, contains(matchesBuilder( refreshState: RefreshIndicatorMode.inactive, pulledExtent: 2.6980688300546443, // ~5% of 60.0 refreshTriggerPullDistance: 100, // default value. refreshIndicatorExtent: 60, // default value. ))); expect(find.text('-1'), findsOneWidget); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets( 'retracting sliver during done cannot be pulled to refresh again until fully retracted', (WidgetTester tester) async { mockHelper.refreshIndicator = const Center(child: Text('-1')); await tester.pumpWidget( CupertinoApp( home: CustomScrollView( slivers: <Widget>[ CupertinoSliverRefreshControl( builder: mockHelper.builder, onRefresh: mockHelper.refreshTask, ), buildAListOfStuff(), ], ), ), ); await tester.drag(find.text('0'), const Offset(0.0, 150.0), touchSlopY: 0.0); await tester.pump(); expect(mockHelper.invocations, contains(const RefreshTaskInvocation())); mockHelper.refreshCompleter.complete(null); await tester.pump(); expect(mockHelper.invocations, contains(matchesBuilder( refreshState: RefreshIndicatorMode.done, pulledExtent: 150.0, // Still overscrolled here. refreshTriggerPullDistance: 100, // default value. refreshIndicatorExtent: 60, // default value. ))); // Let it start going away but not fully. await tester.pump(const Duration(milliseconds: 100)); // The refresh indicator is still building. expect(mockHelper.invocations, contains(matchesBuilder( refreshState: RefreshIndicatorMode.done, pulledExtent: 91.31180913199277, refreshTriggerPullDistance: 100, // default value. refreshIndicatorExtent: 60, // default value. ))); expect( tester.getBottomLeft(find.widgetWithText(Center, '-1')).dy, moreOrLessEquals(91.311809131992776), ); // Start another drag by an amount that would have been enough to // trigger another refresh if it were in the right state. await tester.drag(find.text('0'), const Offset(0.0, 150.0), touchSlopY: 0.0, warnIfMissed: false); await tester.pump(); // Instead, it's still in the done state because the sliver never // fully retracted. expect(mockHelper.invocations, contains(matchesBuilder( refreshState: RefreshIndicatorMode.done, pulledExtent: 147.3772721631821, refreshTriggerPullDistance: 100, // default value. refreshIndicatorExtent: 60, // default value. ))); // Now let it fully go away. await tester.pump(const Duration(seconds: 5)); expect(find.text('-1'), findsNothing); expect( tester.getRect(find.widgetWithText(Center, '0')), const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0), ); // Start another drag. It's now in drag mode. await tester.drag(find.text('0'), const Offset(0.0, 40.0), touchSlopY: 0.0); await tester.pump(); expect(mockHelper.invocations, contains(matchesBuilder( refreshState: RefreshIndicatorMode.drag, pulledExtent: 40, refreshTriggerPullDistance: 100, // default value. refreshIndicatorExtent: 60, // default value. ))); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }), ); testWidgets( 'sliver held in overscroll when task finishes completes normally', (WidgetTester tester) async { mockHelper.refreshIndicator = const Center(child: Text('-1')); await tester.pumpWidget( CupertinoApp( home: CustomScrollView( slivers: <Widget>[ CupertinoSliverRefreshControl( builder: mockHelper.builder, onRefresh: mockHelper.refreshTask, ), buildAListOfStuff(), ], ), ), ); final TestGesture gesture = await tester.startGesture(Offset.zero); // Start a refresh. await gesture.moveBy(const Offset(0.0, 150.0)); await tester.pump(); expect(mockHelper.invocations, contains(const RefreshTaskInvocation())); // Complete the task while held down. mockHelper.refreshCompleter.complete(null); await tester.pump(); expect(mockHelper.invocations, contains(matchesBuilder( refreshState: RefreshIndicatorMode.done, pulledExtent: 150.0, // Still overscrolled here. refreshTriggerPullDistance: 100, // default value. refreshIndicatorExtent: 60, // default value. ))); expect( tester.getRect(find.widgetWithText(Center, '0')), const Rect.fromLTRB(0.0, 150.0, 800.0, 350.0), ); await gesture.up(); await tester.pump(); await tester.pump(const Duration(seconds: 5)); expect(find.text('-1'), findsNothing); expect( tester.getRect(find.widgetWithText(Center, '0')), const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0), ); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }), ); testWidgets( 'sliver scrolled away when task completes properly removes itself', (WidgetTester tester) async { if (testListLength < 4) { // This test only makes sense when the list is long enough that // the indicator can be scrolled away while refreshing. return; } mockHelper.refreshIndicator = const Center(child: Text('-1')); await tester.pumpWidget( CupertinoApp( home: CustomScrollView( slivers: <Widget>[ CupertinoSliverRefreshControl( builder: mockHelper.builder, onRefresh: mockHelper.refreshTask, ), buildAListOfStuff(), ], ), ), ); // Start a refresh. await tester.drag(find.text('0'), const Offset(0.0, 150.0)); await tester.pump(); expect(mockHelper.invocations, contains(const RefreshTaskInvocation())); await tester.drag(find.text('0'), const Offset(0.0, -300.0)); await tester.pump(); // Refresh indicator still being told to layout the same way. expect(mockHelper.invocations, contains(matchesBuilder( refreshState: RefreshIndicatorMode.done, pulledExtent: 60, refreshTriggerPullDistance: 100, // default value. refreshIndicatorExtent: 60, // default value. ))); // Now the sliver is scrolled off screen. expect( tester.getTopLeft(find.widgetWithText(Center, '-1', skipOffstage: false)).dy, moreOrLessEquals(-175.38461538461536), ); expect( tester.getBottomLeft(find.widgetWithText(Center, '-1', skipOffstage: false)).dy, moreOrLessEquals(-115.38461538461536), ); // Complete the task while scrolled away. mockHelper.refreshCompleter.complete(null); // The sliver is instantly gone since there is no overscroll physics // simulation. await tester.pump(); // The next item's position is not disturbed. expect( tester.getTopLeft(find.widgetWithText(Center, '0')).dy, moreOrLessEquals(-115.38461538461536), ); // Scrolling past the first item still results in a new overscroll. // The layout extent is gone. await tester.drag(find.text('1'), const Offset(0.0, 120.0)); await tester.pump(); expect(mockHelper.invocations, contains(matchesBuilder( refreshState: RefreshIndicatorMode.done, pulledExtent: 4.615384615384642, refreshTriggerPullDistance: 100, // default value. refreshIndicatorExtent: 60, // default value. ))); // Snaps away normally. await tester.pump(); await tester.pump(const Duration(seconds: 2)); expect(find.text('-1'), findsNothing); expect( tester.getRect(find.widgetWithText(Center, '0')), const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0), ); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }), ); testWidgets( "don't do anything unless it can be overscrolled at the start of the list", (WidgetTester tester) async { mockHelper.refreshIndicator = const Center(child: Text('-1')); await tester.pumpWidget( CupertinoApp( home: CustomScrollView( slivers: <Widget>[ buildAListOfStuff(), CupertinoSliverRefreshControl( // it's in the middle now. builder: mockHelper.builder, onRefresh: mockHelper.refreshTask, ), buildAListOfStuff(), ], ), ), ); await tester.fling(find.byType(SizedBox).first, const Offset(0.0, 200.0), 2000.0); await tester.fling(find.byType(SizedBox).first, const Offset(0.0, -200.0), 3000.0, warnIfMissed: false); // IgnorePointer is enabled while scroll is ballistic. expect(mockHelper.invocations, isEmpty); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }), ); testWidgets( 'without an onRefresh, builder is called with arm for one frame then sliver goes away', (WidgetTester tester) async { mockHelper.refreshIndicator = const Center(child: Text('-1')); await tester.pumpWidget( CupertinoApp( home: CustomScrollView( slivers: <Widget>[ CupertinoSliverRefreshControl( builder: mockHelper.builder, ), buildAListOfStuff(), ], ), ), ); await tester.drag(find.text('0'), const Offset(0.0, 150.0), touchSlopY: 0.0); await tester.pump(); expect(mockHelper.invocations.first, matchesBuilder( refreshState: RefreshIndicatorMode.armed, pulledExtent: 150.0, refreshTriggerPullDistance: 100.0, // Default value. refreshIndicatorExtent: 60.0, // Default value. )); await tester.pump(const Duration(milliseconds: 10)); expect(mockHelper.invocations.last, matchesBuilder( refreshState: RefreshIndicatorMode.done, pulledExtent: moreOrLessEquals(148.6463892921364), refreshTriggerPullDistance: 100.0, // Default value. refreshIndicatorExtent: 60.0, // Default value. )); await tester.pump(const Duration(seconds: 5)); expect(find.text('-1'), findsNothing); expect( tester.getRect(find.widgetWithText(Center, '0')), const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0), ); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }), ); testWidgets('Should not crash when dragged', (WidgetTester tester) async { await tester.pumpWidget( CupertinoApp( home: CustomScrollView( physics: const BouncingScrollPhysics(), slivers: <Widget>[ CupertinoSliverRefreshControl( onRefresh: () async => Future<void>.delayed(const Duration(days: 2000)), ), ], ), ), ); await tester.dragFrom(const Offset(100, 10), const Offset(0.0, 50.0), touchSlopY: 0); await tester.pump(); await tester.dragFrom(const Offset(100, 10), const Offset(0, 500), touchSlopY: 0); await tester.pump(); expect(tester.takeException(), isNull); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); // Test to make sure the refresh sliver's overscroll isn't eaten by the // nav bar sliver https://github.com/flutter/flutter/issues/74516. testWidgets( 'properly displays when the refresh sliver is behind the large title nav bar sliver', (WidgetTester tester) async { await tester.pumpWidget( CupertinoApp( home: CustomScrollView( slivers: <Widget>[ const CupertinoSliverNavigationBar( largeTitle: Text('Title'), ), CupertinoSliverRefreshControl( builder: mockHelper.builder, ), buildAListOfStuff(), ], ), ), ); final double initialFirstCellY = tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy; // Drag down but not enough to trigger the refresh. await tester.drag(find.text('0'), const Offset(0.0, 50.0), touchSlopY: 0); await tester.pump(); expect(mockHelper.invocations.first, matchesBuilder( refreshState: RefreshIndicatorMode.drag, pulledExtent: 50, refreshTriggerPullDistance: 100, // default value. refreshIndicatorExtent: 60, // default value. )); expect(mockHelper.invocations, hasLength(1)); expect( tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy, initialFirstCellY + 50, ); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }), ); } void stateMachineTestGroup() { testWidgets('starts in inactive state', (WidgetTester tester) async { await tester.pumpWidget( CupertinoApp( home: CustomScrollView( slivers: <Widget>[ CupertinoSliverRefreshControl( builder: mockHelper.builder, ), buildAListOfStuff(), ], ), ), ); expect( CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder, skipOffstage: false))), RefreshIndicatorMode.inactive, ); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets('goes to drag and returns to inactive in a small drag', (WidgetTester tester) async { await tester.pumpWidget( CupertinoApp( home: CustomScrollView( slivers: <Widget>[ CupertinoSliverRefreshControl( builder: mockHelper.builder, ), buildAListOfStuff(), ], ), ), ); await tester.drag(find.text('0'), const Offset(0.0, 20.0)); await tester.pump(); expect( CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))), RefreshIndicatorMode.drag, ); await tester.pump(const Duration(seconds: 2)); expect( CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder, skipOffstage: false))), RefreshIndicatorMode.inactive, ); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets('goes to armed the frame it passes the threshold', (WidgetTester tester) async { await tester.pumpWidget( CupertinoApp( home: CustomScrollView( slivers: <Widget>[ CupertinoSliverRefreshControl( builder: mockHelper.builder, refreshTriggerPullDistance: 80.0, ), buildAListOfStuff(), ], ), ), ); final TestGesture gesture = await tester.startGesture(Offset.zero); await gesture.moveBy(const Offset(0.0, 79.0)); await tester.pump(); expect( CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))), RefreshIndicatorMode.drag, ); await gesture.moveBy(const Offset(0.0, 3.0)); // Overscrolling, need to move more than 1px. await tester.pump(); expect( CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))), RefreshIndicatorMode.armed, ); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets( 'goes to refresh the frame it crossed back the refresh threshold', (WidgetTester tester) async { await tester.pumpWidget( CupertinoApp( home: CustomScrollView( slivers: <Widget>[ CupertinoSliverRefreshControl( builder: mockHelper.builder, onRefresh: mockHelper.refreshTask, refreshTriggerPullDistance: 90.0, refreshIndicatorExtent: 50.0, ), buildAListOfStuff(), ], ), ), ); final TestGesture gesture = await tester.startGesture(Offset.zero); await gesture.moveBy(const Offset(0.0, 90.0)); // Arm it. await tester.pump(); expect( CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))), RefreshIndicatorMode.armed, ); await gesture.moveBy(const Offset(0.0, -80.0)); // Overscrolling, need to move more than -40. await tester.pump(); expect( tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy, moreOrLessEquals(49.775111111111116), // Below 50 now. ); expect( CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))), RefreshIndicatorMode.refresh, ); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }), ); testWidgets( 'goes to done internally as soon as the task finishes', (WidgetTester tester) async { await tester.pumpWidget( CupertinoApp( home: CustomScrollView( slivers: <Widget>[ CupertinoSliverRefreshControl( builder: mockHelper.builder, onRefresh: mockHelper.refreshTask, ), buildAListOfStuff(), ], ), ), ); await tester.drag(find.text('0'), const Offset(0.0, 100.0), touchSlopY: 0.0); await tester.pump(); expect( CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))), RefreshIndicatorMode.armed, ); // The sliver scroll offset correction is applied on the next frame. await tester.pump(); await tester.pump(const Duration(seconds: 2)); expect( CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))), RefreshIndicatorMode.refresh, ); expect( tester.getRect(find.widgetWithText(SizedBox, '0')), const Rect.fromLTRB(0.0, 60.0, 800.0, 260.0), ); mockHelper.refreshCompleter.complete(null); // The task completed between frames. The internal state goes to done // right away even though the sliver gets a new offset correction the // next frame. expect( CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))), RefreshIndicatorMode.done, ); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }), ); testWidgets( 'goes back to inactive when retracting back past 10% of arming distance', (WidgetTester tester) async { await tester.pumpWidget( CupertinoApp( home: CustomScrollView( slivers: <Widget>[ CupertinoSliverRefreshControl( builder: mockHelper.builder, onRefresh: mockHelper.refreshTask, ), buildAListOfStuff(), ], ), ), ); final TestGesture gesture = await tester.startGesture(Offset.zero); await gesture.moveBy(const Offset(0.0, 150.0)); await tester.pump(); expect( CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))), RefreshIndicatorMode.armed, ); mockHelper.refreshCompleter.complete(null); expect( CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))), RefreshIndicatorMode.done, ); await tester.pump(); // Now back in overscroll mode. await gesture.moveBy(const Offset(0.0, -200.0)); await tester.pump(); expect( tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy, moreOrLessEquals(27.944444444444457), ); // Need to bring it to 100 * 0.1 to reset to inactive. expect( CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))), RefreshIndicatorMode.done, ); await gesture.moveBy(const Offset(0.0, -35.0)); await tester.pump(); expect( tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy, moreOrLessEquals(9.313890708161875), ); expect( CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))), RefreshIndicatorMode.inactive, ); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }), ); testWidgets( 'goes back to inactive if already scrolled away when task completes', (WidgetTester tester) async { await tester.pumpWidget( CupertinoApp( home: CustomScrollView( slivers: <Widget>[ CupertinoSliverRefreshControl( builder: mockHelper.builder, onRefresh: mockHelper.refreshTask, ), buildAListOfStuff(), ], ), ), ); final TestGesture gesture = await tester.startGesture(Offset.zero); await gesture.moveBy(const Offset(0.0, 150.0)); await tester.pump(); expect( CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))), RefreshIndicatorMode.armed, ); await tester.pump(); // Sliver scroll offset correction is applied one frame later. await gesture.moveBy(const Offset(0.0, -300.0)); await tester.pump(); // The refresh indicator is offscreen now. expect( tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy, moreOrLessEquals(-145.0332383665717), ); expect( CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder, skipOffstage: false))), RefreshIndicatorMode.refresh, ); mockHelper.refreshCompleter.complete(null); // The sliver layout extent is removed on next frame. await tester.pump(); expect( CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder, skipOffstage: false))), RefreshIndicatorMode.inactive, ); // Nothing moved. expect( tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy, moreOrLessEquals(-145.0332383665717), ); await tester.pump(const Duration(seconds: 2)); // Everything stayed as is. expect( tester.getTopLeft(find.widgetWithText(SizedBox, '0')).dy, moreOrLessEquals(-145.0332383665717), ); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }), ); testWidgets( "don't have to build any indicators or occupy space during refresh", (WidgetTester tester) async { mockHelper.refreshIndicator = const Center(child: Text('-1')); await tester.pumpWidget( CupertinoApp( home: CustomScrollView( slivers: <Widget>[ CupertinoSliverRefreshControl( builder: null, onRefresh: mockHelper.refreshTask, refreshIndicatorExtent: 0.0, ), buildAListOfStuff(), ], ), ), ); await tester.drag(find.text('0'), const Offset(0.0, 150.0)); await tester.pump(); expect( CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder))), RefreshIndicatorMode.armed, ); await tester.pump(); await tester.pump(const Duration(seconds: 5)); // In refresh mode but has no UI. expect( CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder, skipOffstage: false))), RefreshIndicatorMode.refresh, ); expect( tester.getRect(find.widgetWithText(Center, '0')), const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0), ); mockHelper.refreshCompleter.complete(null); await tester.pump(); // Goes to inactive right away since the sliver is already collapsed. expect( CupertinoSliverRefreshControl.state(tester.element(find.byType(LayoutBuilder, skipOffstage: false))), RefreshIndicatorMode.inactive, ); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }), ); testWidgets('buildRefreshIndicator progress', (WidgetTester tester) async { await tester.pumpWidget( CupertinoApp( home: Builder( builder: (BuildContext context) { return CupertinoSliverRefreshControl.buildRefreshIndicator( context, RefreshIndicatorMode.drag, 10, 100, 10, ); }, ), ), ); expect(tester.widget<CupertinoActivityIndicator>(find.byType(CupertinoActivityIndicator)).progress, 10.0 / 100.0); await tester.pumpWidget( CupertinoApp( home: Builder( builder: (BuildContext context) { return CupertinoSliverRefreshControl.buildRefreshIndicator( context, RefreshIndicatorMode.drag, 26, 100, 10, ); }, ), ), ); expect(tester.widget<CupertinoActivityIndicator>(find.byType(CupertinoActivityIndicator)).progress, 26.0 / 100.0); await tester.pumpWidget( CupertinoApp( home: Builder( builder: (BuildContext context) { return CupertinoSliverRefreshControl.buildRefreshIndicator( context, RefreshIndicatorMode.drag, 100, 100, 10, ); }, ), ), ); expect(tester.widget<CupertinoActivityIndicator>(find.byType(CupertinoActivityIndicator)).progress, 100.0 / 100.0); }); testWidgets('indicator should not become larger when overscrolled', (WidgetTester tester) async { // test for https://github.com/flutter/flutter/issues/79841 await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: Builder( builder: (BuildContext context) { return CupertinoSliverRefreshControl.buildRefreshIndicator( context, RefreshIndicatorMode.done, 120, 100, 10, ); }, ), ), ); expect(tester.widget<CupertinoActivityIndicator>(find.byType(CupertinoActivityIndicator)).radius, 14.0); }); } group('UI tests long list', uiTestGroup); // Test the internal state machine directly to make sure the UI aren't just // correct by coincidence. group('state machine test long list', stateMachineTestGroup); // Retest everything and make sure that it still works when the whole list // is smaller than the viewport size. testListLength = 2; group('UI tests short list', uiTestGroup); // Test the internal state machine directly to make sure the UI aren't just // correct by coincidence. group('state machine test short list', stateMachineTestGroup); testWidgets( 'Does not crash when paintExtent > remainingPaintExtent', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/46871. await tester.pumpWidget( CupertinoApp( home: CustomScrollView( physics: const BouncingScrollPhysics(), slivers: <Widget>[ const CupertinoSliverRefreshControl(), SliverList( delegate: SliverChildBuilderDelegate( (BuildContext context, int index) => const SizedBox(height: 100), childCount: 20, ), ), ], ), ), ); // Drag the content down far enough so that // geometry.paintExtent > constraints.maxPaintExtent await tester.dragFrom(const Offset(10, 10), const Offset(0, 500)); await tester.pump(); expect(tester.takeException(), isNull); }, ); } class FakeBuilder { Completer<void> refreshCompleter = Completer<void>.sync(); final List<MockHelperInvocation> invocations = <MockHelperInvocation>[]; Widget refreshIndicator = Container(); Widget builder( BuildContext context, RefreshIndicatorMode refreshState, double pulledExtent, double refreshTriggerPullDistance, double refreshIndicatorExtent, ) { if (pulledExtent < 0.0) { throw TestFailure('The pulledExtent should never be less than 0.0'); } if (refreshTriggerPullDistance < 0.0) { throw TestFailure('The refreshTriggerPullDistance should never be less than 0.0'); } if (refreshIndicatorExtent < 0.0) { throw TestFailure('The refreshIndicatorExtent should never be less than 0.0'); } invocations.add(BuilderInvocation( refreshState: refreshState, pulledExtent: pulledExtent, refreshTriggerPullDistance: refreshTriggerPullDistance, refreshIndicatorExtent: refreshIndicatorExtent, )); return refreshIndicator; } Future<void> refreshTask() { invocations.add(const RefreshTaskInvocation()); return refreshCompleter.future; } } abstract class MockHelperInvocation { const MockHelperInvocation(); } @immutable class RefreshTaskInvocation extends MockHelperInvocation { const RefreshTaskInvocation(); } @immutable class BuilderInvocation extends MockHelperInvocation { const BuilderInvocation({ required this.refreshState, required this.pulledExtent, required this.refreshIndicatorExtent, required this.refreshTriggerPullDistance, }); final RefreshIndicatorMode refreshState; final double pulledExtent; final double refreshTriggerPullDistance; final double refreshIndicatorExtent; @override String toString() => '{refreshState: $refreshState, pulledExtent: $pulledExtent, refreshTriggerPullDistance: $refreshTriggerPullDistance, refreshIndicatorExtent: $refreshIndicatorExtent}'; } Matcher matchesBuilder({ required RefreshIndicatorMode refreshState, required dynamic pulledExtent, required dynamic refreshTriggerPullDistance, required dynamic refreshIndicatorExtent, }) { return isA<BuilderInvocation>() .having((BuilderInvocation invocation) => invocation.refreshState, 'refreshState', refreshState) .having((BuilderInvocation invocation) => invocation.pulledExtent, 'pulledExtent', pulledExtent) .having((BuilderInvocation invocation) => invocation.refreshTriggerPullDistance, 'refreshTriggerPullDistance', refreshTriggerPullDistance) .having((BuilderInvocation invocation) => invocation.refreshIndicatorExtent, 'refreshIndicatorExtent', refreshIndicatorExtent); }