// 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/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { Widget boilerplateWidget(VoidCallback? onButtonPressed, { DraggableScrollableController? controller, int itemCount = 100, double initialChildSize = .5, double maxChildSize = 1.0, double minChildSize = .25, bool snap = false, List<double>? snapSizes, Duration? snapAnimationDuration, double? itemExtent, Key? containerKey, Key? stackKey, NotificationListenerCallback<ScrollNotification>? onScrollNotification, bool ignoreController = false, }) { return Directionality( textDirection: TextDirection.ltr, child: MediaQuery( data: const MediaQueryData(), child: Stack( key: stackKey, children: <Widget>[ TextButton( onPressed: onButtonPressed, child: const Text('TapHere'), ), DraggableScrollableActuator( child: DraggableScrollableSheet( controller: controller, maxChildSize: maxChildSize, minChildSize: minChildSize, initialChildSize: initialChildSize, snap: snap, snapSizes: snapSizes, snapAnimationDuration: snapAnimationDuration, builder: (BuildContext context, ScrollController scrollController) { return NotificationListener<ScrollNotification>( onNotification: onScrollNotification, child: Container( key: containerKey, color: const Color(0xFFABCDEF), child: ListView.builder( controller: ignoreController ? null : scrollController, itemExtent: itemExtent, itemCount: itemCount, itemBuilder: (BuildContext context, int index) => Text('Item $index'), ), ), ); }, ), ), ], ), ), ); } testWidgets('Do not crash when replacing scroll position during the drag', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/89681 bool showScrollbars = false; await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: MediaQuery( data: const MediaQueryData(), child: Align( alignment: Alignment.bottomCenter, child: DraggableScrollableSheet( initialChildSize: 0.7, minChildSize: 0.2, maxChildSize: 0.9, expand: false, builder: (BuildContext context, ScrollController scrollController) { showScrollbars = !showScrollbars; // Change the scroll behavior will trigger scroll position replace. final ScrollBehavior behavior = const ScrollBehavior().copyWith(scrollbars: showScrollbars); return ScrollConfiguration( behavior: behavior, child: ListView.separated( physics: const BouncingScrollPhysics(), controller: scrollController, separatorBuilder: (_, __) => const Divider(), itemCount: 100, itemBuilder: (_, int index) => SizedBox( height: 100, child: ColoredBox( color: Colors.primaries[index % Colors.primaries.length], child: Text('Item $index'), ), ), ), ); }, ), ), ), ), ); await tester.fling(find.text('Item 1'), const Offset(0, 200), 350); await tester.pumpAndSettle(); // Go without throw. }); testWidgets('Scrolls correct amount when maxChildSize < 1.0', (WidgetTester tester) async { const Key key = ValueKey<String>('container'); await tester.pumpWidget(boilerplateWidget( null, maxChildSize: .6, initialChildSize: .25, itemExtent: 25.0, containerKey: key, )); expect(tester.getRect(find.byKey(key)), const Rect.fromLTRB(0.0, 450.0, 800.0, 600.0)); await tester.drag(find.text('Item 5'), const Offset(0, -125)); await tester.pumpAndSettle(); expect(tester.getRect(find.byKey(key)), const Rect.fromLTRB(0.0, 325.0, 800.0, 600.0)); }); testWidgets('Scrolls correct amount when maxChildSize == 1.0', (WidgetTester tester) async { const Key key = ValueKey<String>('container'); await tester.pumpWidget(boilerplateWidget( null, initialChildSize: .25, itemExtent: 25.0, containerKey: key, )); expect(tester.getRect(find.byKey(key)), const Rect.fromLTRB(0.0, 450.0, 800.0, 600.0)); await tester.drag(find.text('Item 5'), const Offset(0, -125)); await tester.pumpAndSettle(); expect(tester.getRect(find.byKey(key)), const Rect.fromLTRB(0.0, 325.0, 800.0, 600.0)); }); testWidgets('Invalid snap targets throw assertion errors.', (WidgetTester tester) async { await tester.pumpWidget(boilerplateWidget( null, maxChildSize: .8, snapSizes: <double>[.9], )); expect(tester.takeException(), isAssertionError); await tester.pumpWidget(boilerplateWidget( null, snapSizes: <double>[.1], )); expect(tester.takeException(), isAssertionError); await tester.pumpWidget(boilerplateWidget( null, snapSizes: <double>[.6, .6, .9], )); expect(tester.takeException(), isAssertionError); }); group('Scroll Physics', () { testWidgets('Can be dragged up without covering its container', (WidgetTester tester) async { int taps = 0; await tester.pumpWidget(boilerplateWidget(() => taps++)); expect(find.text('TapHere'), findsOneWidget); await tester.tap(find.text('TapHere')); expect(taps, 1); expect(find.text('Item 1'), findsOneWidget); expect(find.text('Item 21'), findsOneWidget); expect(find.text('Item 31'), findsNothing); await tester.drag(find.text('Item 1'), const Offset(0, -200)); await tester.pumpAndSettle(); expect(find.text('TapHere'), findsOneWidget); await tester.tap(find.text('TapHere')); expect(taps, 2); expect(find.text('Item 1'), findsOneWidget); expect(find.text('Item 21'), findsOneWidget); expect(find.text('Item 31'), findsOneWidget); }, variant: TargetPlatformVariant.all()); testWidgets('Can be dragged down when not full height', (WidgetTester tester) async { await tester.pumpWidget(boilerplateWidget(null)); expect(find.text('Item 1'), findsOneWidget); expect(find.text('Item 21'), findsOneWidget); expect(find.text('Item 36'), findsNothing); await tester.drag(find.text('Item 1'), const Offset(0, 325)); await tester.pumpAndSettle(); expect(find.text('Item 1'), findsOneWidget); expect(find.text('Item 21'), findsNothing); expect(find.text('Item 36'), findsNothing); }, variant: TargetPlatformVariant.all()); testWidgets('Can be dragged down when list is shorter than full height', (WidgetTester tester) async { await tester.pumpWidget(boilerplateWidget(null, itemCount: 30, initialChildSize: .25)); expect(find.text('Item 1').hitTestable(), findsOneWidget); expect(find.text('Item 29').hitTestable(), findsNothing); await tester.drag(find.text('Item 1'), const Offset(0, -325)); await tester.pumpAndSettle(); expect(find.text('Item 1').hitTestable(), findsOneWidget); expect(find.text('Item 29').hitTestable(), findsOneWidget); await tester.drag(find.text('Item 1'), const Offset(0, 325)); await tester.pumpAndSettle(); expect(find.text('Item 1').hitTestable(), findsOneWidget); expect(find.text('Item 29').hitTestable(), findsNothing); }, variant: TargetPlatformVariant.all()); testWidgets('Can be dragged up and cover its container and scroll in single motion, and then dragged back down', (WidgetTester tester) async { int taps = 0; await tester.pumpWidget(boilerplateWidget(() => taps++)); expect(find.text('TapHere'), findsOneWidget); await tester.tap(find.text('TapHere')); expect(taps, 1); expect(find.text('Item 1'), findsOneWidget); expect(find.text('Item 21'), findsOneWidget); expect(find.text('Item 36'), findsNothing); await tester.drag(find.text('Item 1'), const Offset(0, -325)); await tester.pumpAndSettle(); expect(find.text('TapHere'), findsOneWidget); await tester.tap(find.text('TapHere'), warnIfMissed: false); expect(taps, 1); expect(find.text('Item 1'), findsOneWidget); expect(find.text('Item 21'), findsOneWidget); expect(find.text('Item 36'), findsOneWidget); await tester.dragFrom(const Offset(20, 20), const Offset(0, 325)); await tester.pumpAndSettle(); await tester.tap(find.text('TapHere')); expect(taps, 2); expect(find.text('Item 1'), findsOneWidget); expect(find.text('Item 18'), findsOneWidget); expect(find.text('Item 36'), findsNothing); }, variant: TargetPlatformVariant.all()); testWidgets('Can be flung up gently', (WidgetTester tester) async { int taps = 0; await tester.pumpWidget(boilerplateWidget(() => taps++)); expect(find.text('TapHere'), findsOneWidget); await tester.tap(find.text('TapHere')); expect(taps, 1); expect(find.text('Item 1'), findsOneWidget); expect(find.text('Item 21'), findsOneWidget); expect(find.text('Item 36'), findsNothing); expect(find.text('Item 70'), findsNothing); await tester.fling(find.text('Item 1'), const Offset(0, -200), 350); await tester.pumpAndSettle(); expect(find.text('TapHere'), findsOneWidget); await tester.tap(find.text('TapHere')); expect(taps, 2); expect(find.text('Item 1'), findsOneWidget); expect(find.text('Item 21'), findsOneWidget); expect(find.text('Item 36'), findsOneWidget); expect(find.text('Item 70'), findsNothing); }, variant: TargetPlatformVariant.all()); testWidgets('Can be flung up', (WidgetTester tester) async { int taps = 0; await tester.pumpWidget(boilerplateWidget(() => taps++)); expect(find.text('TapHere'), findsOneWidget); await tester.tap(find.text('TapHere')); expect(taps, 1); expect(find.text('Item 1'), findsOneWidget); expect(find.text('Item 21'), findsOneWidget); expect(find.text('Item 70'), findsNothing); await tester.fling(find.text('Item 1'), const Offset(0, -200), 2000); await tester.pumpAndSettle(); expect(find.text('TapHere'), findsOneWidget); await tester.tap(find.text('TapHere'), warnIfMissed: false); expect(taps, 1); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 21'), findsNothing); expect(find.text('Item 70'), findsOneWidget); }, variant: TargetPlatformVariant.all()); testWidgets('Can be flung down when not full height', (WidgetTester tester) async { await tester.pumpWidget(boilerplateWidget(null)); expect(find.text('Item 1'), findsOneWidget); expect(find.text('Item 21'), findsOneWidget); expect(find.text('Item 36'), findsNothing); await tester.fling(find.text('Item 1'), const Offset(0, 325), 2000); await tester.pumpAndSettle(); expect(find.text('Item 1'), findsOneWidget); expect(find.text('Item 21'), findsNothing); expect(find.text('Item 36'), findsNothing); }, variant: TargetPlatformVariant.all()); testWidgets('Can be flung up and then back down', (WidgetTester tester) async { int taps = 0; await tester.pumpWidget(boilerplateWidget(() => taps++)); expect(find.text('TapHere'), findsOneWidget); await tester.tap(find.text('TapHere')); expect(taps, 1); expect(find.text('Item 1'), findsOneWidget); expect(find.text('Item 21'), findsOneWidget); expect(find.text('Item 70'), findsNothing); await tester.fling(find.text('Item 1'), const Offset(0, -200), 2000); await tester.pumpAndSettle(); expect(find.text('TapHere'), findsOneWidget); await tester.tap(find.text('TapHere'), warnIfMissed: false); expect(taps, 1); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 21'), findsNothing); expect(find.text('Item 70'), findsOneWidget); await tester.fling(find.text('Item 70'), const Offset(0, 200), 2000); await tester.pumpAndSettle(); expect(find.text('TapHere'), findsOneWidget); await tester.tap(find.text('TapHere'), warnIfMissed: false); expect(taps, 1); expect(find.text('Item 1'), findsOneWidget); expect(find.text('Item 21'), findsOneWidget); expect(find.text('Item 70'), findsNothing); await tester.fling(find.text('Item 1'), const Offset(0, 200), 2000); await tester.pumpAndSettle(); expect(find.text('TapHere'), findsOneWidget); await tester.tap(find.text('TapHere')); expect(taps, 2); expect(find.text('Item 1'), findsOneWidget); expect(find.text('Item 21'), findsNothing); expect(find.text('Item 70'), findsNothing); }, variant: TargetPlatformVariant.all()); testWidgets('Ballistic animation on fling can be interrupted', (WidgetTester tester) async { int taps = 0; await tester.pumpWidget(boilerplateWidget(() => taps++)); expect(find.text('TapHere'), findsOneWidget); await tester.tap(find.text('TapHere')); expect(taps, 1); expect(find.text('Item 1'), findsOneWidget); expect(find.text('Item 31'), findsNothing); expect(find.text('Item 70'), findsNothing); await tester.fling(find.text('Item 1'), const Offset(0, -200), 2000); // Don't pump and settle because we want to interrupt the ballistic scrolling animation. expect(find.text('TapHere'), findsOneWidget); await tester.tap(find.text('TapHere'), warnIfMissed: false); expect(taps, 2); expect(find.text('Item 1'), findsOneWidget); expect(find.text('Item 31'), findsOneWidget); expect(find.text('Item 70'), findsNothing); // Use `dragFrom` here because calling `drag` on a list item without // first calling `pumpAndSettle` fails with a hit test error. await tester.dragFrom(const Offset(0, 200), const Offset(0, 200)); await tester.pumpAndSettle(); // Verify that the ballistic animation has canceled and the sheet has // returned to it's original position. await tester.tap(find.text('TapHere')); expect(taps, 3); expect(find.text('Item 1'), findsOneWidget); expect(find.text('Item 31'), findsNothing); expect(find.text('Item 70'), findsNothing); }, variant: TargetPlatformVariant.all()); testWidgets('Ballistic animation on fling should not leak Ticker', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/101061 await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: MediaQuery( data: const MediaQueryData(), child: Align( alignment: Alignment.bottomCenter, child: DraggableScrollableSheet( initialChildSize: 0.8, minChildSize: 0.2, maxChildSize: 0.9, expand: false, builder: (_, ScrollController scrollController) { return ListView.separated( physics: const BouncingScrollPhysics(), controller: scrollController, separatorBuilder: (_, __) => const Divider(), itemCount: 100, itemBuilder: (_, int index) => SizedBox( height: 100, child: ColoredBox( color: Colors.primaries[index % Colors.primaries.length], child: Text('Item $index'), ), ), ); }, ), ), ), ), ); await tester.flingFrom( tester.getCenter(find.text('Item 1')), const Offset(0, 50), 10000, ); // Pumps several times to let the DraggableScrollableSheet react to scroll position changes. const int numberOfPumpsBeforeError = 22; for (int i = 0; i < numberOfPumpsBeforeError; i++) { await tester.pump(const Duration(milliseconds: 10)); } // Dispose the DraggableScrollableSheet await tester.pumpWidget(const SizedBox.shrink()); // When a Ticker leaks an exception is thrown expect(tester.takeException(), isNull); }); }); testWidgets('Does not snap away from initial child on build', (WidgetTester tester) async { const Key containerKey = ValueKey<String>('container'); const Key stackKey = ValueKey<String>('stack'); await tester.pumpWidget(boilerplateWidget(null, snap: true, initialChildSize: .7, containerKey: containerKey, stackKey: stackKey, )); await tester.pumpAndSettle(); final double screenHeight = tester.getSize(find.byKey(stackKey)).height; // The sheet should not have snapped. expect( tester.getSize(find.byKey(containerKey)).height / screenHeight, closeTo(.7, precisionErrorTolerance, )); }, variant: TargetPlatformVariant.all()); for (final bool useActuator in <bool>[false, true]) { testWidgets('Does not snap away from initial child on ${useActuator ? 'actuator' : 'controller'}.reset()', (WidgetTester tester) async { const Key containerKey = ValueKey<String>('container'); const Key stackKey = ValueKey<String>('stack'); final DraggableScrollableController controller = DraggableScrollableController(); await tester.pumpWidget(boilerplateWidget( null, controller: controller, snap: true, containerKey: containerKey, stackKey: stackKey, )); await tester.pumpAndSettle(); final double screenHeight = tester.getSize(find.byKey(stackKey)).height; await tester.drag(find.text('Item 1'), Offset(0, -.4 * screenHeight)); await tester.pumpAndSettle(); expect( tester.getSize(find.byKey(containerKey)).height / screenHeight, closeTo(1.0, precisionErrorTolerance), ); if (useActuator) { DraggableScrollableActuator.reset(tester.element(find.byKey(containerKey))); } else { controller.reset(); } await tester.pumpAndSettle(); // The sheet should have reset without snapping away from initial child. expect( tester.getSize(find.byKey(containerKey)).height / screenHeight, closeTo(.5, precisionErrorTolerance), ); }); } for (final Duration? snapAnimationDuration in <Duration?>[null, const Duration(seconds: 2)]) { testWidgets( 'Zero velocity drag snaps to nearest snap target with ' 'snapAnimationDuration: $snapAnimationDuration', (WidgetTester tester) async { const Key stackKey = ValueKey<String>('stack'); const Key containerKey = ValueKey<String>('container'); await tester.pumpWidget(boilerplateWidget(null, snap: true, stackKey: stackKey, containerKey: containerKey, snapSizes: <double>[.25, .5, .75, 1.0], snapAnimationDuration: snapAnimationDuration )); await tester.pumpAndSettle(); final double screenHeight = tester.getSize(find.byKey(stackKey)).height; // We are dragging up, but we'll snap down because we're closer to .75 than 1. await tester.drag(find.text('Item 1'), Offset(0, -.35 * screenHeight)); await tester.pumpAndSettle(); expect( tester.getSize(find.byKey(containerKey)).height / screenHeight, closeTo(.75, precisionErrorTolerance), ); // Drag up and snap up. await tester.drag(find.text('Item 1'), Offset(0, -.2 * screenHeight)); await tester.pumpAndSettle(); expect( tester.getSize(find.byKey(containerKey)).height / screenHeight, closeTo(1.0, precisionErrorTolerance), ); // Drag down and snap up. await tester.drag(find.text('Item 1'), Offset(0, .1 * screenHeight)); await tester.pumpAndSettle(); expect( tester.getSize(find.byKey(containerKey)).height / screenHeight, closeTo(1.0, precisionErrorTolerance), ); // Drag down and snap down. await tester.drag(find.text('Item 1'), Offset(0, .45 * screenHeight)); await tester.pumpAndSettle(); expect( tester.getSize(find.byKey(containerKey)).height / screenHeight, closeTo(.5, precisionErrorTolerance), ); // Fling up with negligible velocity and snap down. await tester.fling(find.text('Item 1'), Offset(0, .1 * screenHeight), 1); await tester.pumpAndSettle(); expect( tester.getSize(find.byKey(containerKey)).height / screenHeight, closeTo(.5, precisionErrorTolerance), ); }, variant: TargetPlatformVariant.all()); } for (final List<double>? snapSizes in <List<double>?>[null, <double>[]]) { testWidgets('Setting snapSizes to $snapSizes resolves to min and max', (WidgetTester tester) async { const Key stackKey = ValueKey<String>('stack'); const Key containerKey = ValueKey<String>('container'); await tester.pumpWidget(boilerplateWidget(null, snap: true, stackKey: stackKey, containerKey: containerKey, snapSizes: snapSizes, )); await tester.pumpAndSettle(); final double screenHeight = tester.getSize(find.byKey(stackKey)).height; await tester.drag(find.text('Item 1'), Offset(0, -.4 * screenHeight)); await tester.pumpAndSettle(); expect( tester.getSize(find.byKey(containerKey)).height / screenHeight, closeTo(1.0, precisionErrorTolerance, )); await tester.drag(find.text('Item 1'), Offset(0, .7 * screenHeight)); await tester.pumpAndSettle(); expect( tester.getSize(find.byKey(containerKey)).height / screenHeight, closeTo(.25, precisionErrorTolerance), ); }, variant: TargetPlatformVariant.all()); } testWidgets('Min and max are implicitly added to snapSizes', (WidgetTester tester) async { const Key stackKey = ValueKey<String>('stack'); const Key containerKey = ValueKey<String>('container'); await tester.pumpWidget(boilerplateWidget(null, snap: true, stackKey: stackKey, containerKey: containerKey, snapSizes: <double>[.5], )); await tester.pumpAndSettle(); final double screenHeight = tester.getSize(find.byKey(stackKey)).height; await tester.drag(find.text('Item 1'), Offset(0, -.4 * screenHeight)); await tester.pumpAndSettle(); expect( tester.getSize(find.byKey(containerKey)).height / screenHeight, closeTo(1.0, precisionErrorTolerance), ); await tester.drag(find.text('Item 1'), Offset(0, .7 * screenHeight)); await tester.pumpAndSettle(); expect( tester.getSize(find.byKey(containerKey)).height / screenHeight, closeTo(.25, precisionErrorTolerance), ); }, variant: TargetPlatformVariant.all()); testWidgets('Changes to widget parameters are propagated', (WidgetTester tester) async { const Key stackKey = ValueKey<String>('stack'); const Key containerKey = ValueKey<String>('container'); await tester.pumpWidget(boilerplateWidget( null, stackKey: stackKey, containerKey: containerKey, )); await tester.pumpAndSettle(); final double screenHeight = tester.getSize(find.byKey(stackKey)).height; expect( tester.getSize(find.byKey(containerKey)).height / screenHeight, closeTo(.5, precisionErrorTolerance), ); // Pump the same widget but with a new initial child size. await tester.pumpWidget(boilerplateWidget( null, stackKey: stackKey, containerKey: containerKey, initialChildSize: .6, )); await tester.pumpAndSettle(); // We jump to the new initial size because the sheet hasn't changed yet. expect( tester.getSize(find.byKey(containerKey)).height / screenHeight, closeTo(.6, precisionErrorTolerance), ); // Pump the same widget but with a new max child size. await tester.pumpWidget(boilerplateWidget( null, stackKey: stackKey, containerKey: containerKey, initialChildSize: .6, maxChildSize: .9 )); await tester.pumpAndSettle(); expect( tester.getSize(find.byKey(containerKey)).height / screenHeight, closeTo(.6, precisionErrorTolerance), ); await tester.drag(find.text('Item 1'), Offset(0, -.6 * screenHeight)); await tester.pumpAndSettle(); expect( tester.getSize(find.byKey(containerKey)).height / screenHeight, closeTo(.9, precisionErrorTolerance), ); // Pump the same widget but with a new max child size and initial size. await tester.pumpWidget(boilerplateWidget( null, stackKey: stackKey, containerKey: containerKey, maxChildSize: .8, initialChildSize: .7, )); await tester.pumpAndSettle(); // The max child size has been reduced, we should be rebuilt at the new // max of .8. We changed the initial size again, but the sheet has already // been changed so the new initial is ignored. expect( tester.getSize(find.byKey(containerKey)).height / screenHeight, closeTo(.8, precisionErrorTolerance), ); await tester.drag(find.text('Item 1'), Offset(0, .2 * screenHeight)); // Pump the same widget but with snapping enabled. await tester.pumpWidget(boilerplateWidget( null, snap: true, stackKey: stackKey, containerKey: containerKey, maxChildSize: .8, snapSizes: <double>[.5], )); await tester.pumpAndSettle(); // Sheet snaps immediately on a change to snap. expect( tester.getSize(find.byKey(containerKey)).height / screenHeight, closeTo(.5, precisionErrorTolerance), ); final List<double> snapSizes = <double>[.6]; // Change the snap sizes. await tester.pumpWidget(boilerplateWidget( null, snap: true, stackKey: stackKey, containerKey: containerKey, maxChildSize: .8, snapSizes: snapSizes, )); await tester.pumpAndSettle(); expect( tester.getSize(find.byKey(containerKey)).height / screenHeight, closeTo(.6, precisionErrorTolerance), ); }, variant: TargetPlatformVariant.all()); testWidgets('Fling snaps in direction of momentum', (WidgetTester tester) async { const Key stackKey = ValueKey<String>('stack'); const Key containerKey = ValueKey<String>('container'); await tester.pumpWidget(boilerplateWidget(null, snap: true, stackKey: stackKey, containerKey: containerKey, snapSizes: <double>[.5, .75], )); await tester.pumpAndSettle(); final double screenHeight = tester.getSize(find.byKey(stackKey)).height; await tester.fling(find.text('Item 1'), Offset(0, -.1 * screenHeight), 1000); await tester.pumpAndSettle(); expect( tester.getSize(find.byKey(containerKey)).height / screenHeight, closeTo(.75, precisionErrorTolerance), ); await tester.fling(find.text('Item 1'), Offset(0, .3 * screenHeight), 1000); await tester.pumpAndSettle(); expect( tester.getSize(find.byKey(containerKey)).height / screenHeight, closeTo(.25, precisionErrorTolerance), ); }, variant: TargetPlatformVariant.all()); testWidgets("Changing parameters with an un-listened controller doesn't throw", (WidgetTester tester) async { await tester.pumpWidget(boilerplateWidget( null, snap: true, // Will prevent the sheet's child from listening to the controller. ignoreController: true, )); await tester.pumpAndSettle(); await tester.pumpWidget(boilerplateWidget( null, snap: true, )); await tester.pumpAndSettle(); }, variant: TargetPlatformVariant.all()); testWidgets('Transitioning between scrollable children sharing a scroll controller will not throw', (WidgetTester tester) async { int s = 0; await tester.pumpWidget(MaterialApp( home: StatefulBuilder( builder: (BuildContext context, StateSetter setState) { return Scaffold( body: DraggableScrollableSheet( initialChildSize: 0.25, snap: true, snapSizes: const <double>[0.25, 0.5, 1.0], builder: (BuildContext context, ScrollController scrollController) { return PrimaryScrollController( controller: scrollController, child: AnimatedSwitcher( duration: const Duration(milliseconds: 500), child: (s.isEven) ? ListView( children: <Widget>[ ElevatedButton( onPressed: () => setState(() => ++s), child: const Text('Switch to 2'), ), Container( height: 400, color: Colors.blue, ), ], ) : SingleChildScrollView( child: Column( children: <Widget>[ ElevatedButton( onPressed: () => setState(() => ++s), child: const Text('Switch to 1'), ), Container( height: 400, color: Colors.blue, ), ], ) ), ), ); }, ), ); }, ), )); // Trigger the AnimatedSwitcher between ListViews await tester.tap(find.text('Switch to 2')); await tester.pump(); // Completes without throwing }); testWidgets('ScrollNotification correctly dispatched when flung without covering its container', (WidgetTester tester) async { final List<Type> notificationTypes = <Type>[]; await tester.pumpWidget(boilerplateWidget( null, onScrollNotification: (ScrollNotification notification) { notificationTypes.add(notification.runtimeType); return false; }, )); await tester.fling(find.text('Item 1'), const Offset(0, -200), 200); await tester.pumpAndSettle(); // TODO(itome): Make sure UserScrollNotification and ScrollUpdateNotification are called correctly. final List<Type> types = <Type>[ ScrollStartNotification, ScrollEndNotification, ]; expect(notificationTypes, equals(types)); }); testWidgets('ScrollNotification correctly dispatched when flung with contents scroll', (WidgetTester tester) async { final List<Type> notificationTypes = <Type>[]; await tester.pumpWidget(boilerplateWidget( null, onScrollNotification: (ScrollNotification notification) { notificationTypes.add(notification.runtimeType); return false; }, )); await tester.flingFrom(const Offset(0, 325), const Offset(0, -325), 200); await tester.pumpAndSettle(); final List<Type> types = <Type>[ ScrollStartNotification, UserScrollNotification, ...List<Type>.filled(5, ScrollUpdateNotification), ScrollEndNotification, UserScrollNotification, ]; expect(notificationTypes, types); }); testWidgets('Do not crash when remove the tree during animation.', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/89214 await tester.pumpWidget(boilerplateWidget( null, onScrollNotification: (ScrollNotification notification) { return false; }, )); await tester.flingFrom(const Offset(0, 325), const Offset(0, 325), 200); // The animation is running. await tester.pumpWidget(const SizedBox.shrink()); expect(tester.takeException(), isNull); }); for (final bool shouldAnimate in <bool>[true, false]) { testWidgets('Can ${shouldAnimate ? 'animate' : 'jump'} to arbitrary positions', (WidgetTester tester) async { const Key stackKey = ValueKey<String>('stack'); const Key containerKey = ValueKey<String>('container'); final DraggableScrollableController controller = DraggableScrollableController(); await tester.pumpWidget(boilerplateWidget( null, controller: controller, stackKey: stackKey, containerKey: containerKey, )); await tester.pumpAndSettle(); final double screenHeight = tester.getSize(find.byKey(stackKey)).height; // Use a local helper to animate so we can share code across a jumpTo test // and an animateTo test. void goTo(double size) => shouldAnimate ? controller.animateTo(size, duration: const Duration(milliseconds: 200), curve: Curves.linear) : controller.jumpTo(size); // If we're animating, pump will call four times, two of which are for the // animation duration. final int expectedPumpCount = shouldAnimate ? 4 : 2; goTo(.6); expect(await tester.pumpAndSettle(), expectedPumpCount); expect( tester.getSize(find.byKey(containerKey)).height / screenHeight, closeTo(.6, precisionErrorTolerance), ); expect(find.text('Item 1'), findsOneWidget); expect(find.text('Item 20'), findsOneWidget); expect(find.text('Item 70'), findsNothing); goTo(.4); expect(await tester.pumpAndSettle(), expectedPumpCount); expect( tester.getSize(find.byKey(containerKey)).height / screenHeight, closeTo(.4, precisionErrorTolerance), ); expect(find.text('Item 1'), findsOneWidget); expect(find.text('Item 20'), findsNothing); expect(find.text('Item 70'), findsNothing); await tester.fling(find.text('Item 1'), Offset(0, -screenHeight), 100); await tester.pumpAndSettle(); expect( tester.getSize(find.byKey(containerKey)).height / screenHeight, closeTo(1, precisionErrorTolerance), ); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 20'), findsOneWidget); expect(find.text('Item 70'), findsNothing); // Programmatic control does not affect the inner scrollable's position. goTo(.8); expect(await tester.pumpAndSettle(), expectedPumpCount); expect( tester.getSize(find.byKey(containerKey)).height / screenHeight, closeTo(.8, precisionErrorTolerance), ); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 20'), findsOneWidget); expect(find.text('Item 70'), findsNothing); // Attempting to move to a size too big or too small instead moves to the // min or max child size. goTo(.5); await tester.pumpAndSettle(); goTo(0); // The animation was cut short by half, there should have been on less pumps final int truncatedPumpCount = shouldAnimate ? expectedPumpCount - 1 : expectedPumpCount; expect(await tester.pumpAndSettle(), truncatedPumpCount); expect( tester.getSize(find.byKey(containerKey)).height / screenHeight, closeTo(.25, precisionErrorTolerance), ); }); } testWidgets('Can animateTo with a nonlinear curve', (WidgetTester tester) async { const Key stackKey = ValueKey<String>('stack'); const Key containerKey = ValueKey<String>('container'); final DraggableScrollableController controller = DraggableScrollableController(); await tester.pumpWidget(boilerplateWidget( null, controller: controller, stackKey: stackKey, containerKey: containerKey, )); await tester.pumpAndSettle(); final double screenHeight = tester.getSize(find.byKey(stackKey)).height; controller.animateTo(.6, curve: Curves.linear, duration: const Duration(milliseconds: 100)); // We need to call one pump first to get the animation to start. await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); expect( tester.getSize(find.byKey(containerKey)).height / screenHeight, closeTo(.55, precisionErrorTolerance), ); await tester.pumpAndSettle(); expect( tester.getSize(find.byKey(containerKey)).height / screenHeight, closeTo(.6, precisionErrorTolerance), ); controller.animateTo(.7, curve: const Interval(.5, 1), duration: const Duration(milliseconds: 100)); await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); // The curve should result in the sheet not moving for the first 50 ms. expect( tester.getSize(find.byKey(containerKey)).height / screenHeight, closeTo(.6, precisionErrorTolerance), ); await tester.pump(const Duration(milliseconds: 25)); expect( tester.getSize(find.byKey(containerKey)).height / screenHeight, closeTo(.65, precisionErrorTolerance), ); await tester.pumpAndSettle(); expect( tester.getSize(find.byKey(containerKey)).height / screenHeight, closeTo(.7, precisionErrorTolerance), ); }); testWidgets('Can reuse a controller after the old controller is disposed', (WidgetTester tester) async { const Key stackKey = ValueKey<String>('stack'); const Key containerKey = ValueKey<String>('container'); final DraggableScrollableController controller = DraggableScrollableController(); await tester.pumpWidget(boilerplateWidget( null, controller: controller, stackKey: stackKey, containerKey: containerKey, )); await tester.pumpAndSettle(); // Pump a new sheet with the same controller. This will dispose of the old sheet first. await tester.pumpWidget(boilerplateWidget( null, controller: controller, stackKey: stackKey, containerKey: containerKey, )); await tester.pumpAndSettle(); final double screenHeight = tester.getSize(find.byKey(stackKey)).height; controller.jumpTo(.6); await tester.pumpAndSettle(); expect( tester.getSize(find.byKey(containerKey)).height / screenHeight, closeTo(.6, precisionErrorTolerance), ); }); testWidgets('animateTo interrupts other animations', (WidgetTester tester) async { const Key stackKey = ValueKey<String>('stack'); const Key containerKey = ValueKey<String>('container'); final DraggableScrollableController controller = DraggableScrollableController(); await tester.pumpWidget(Directionality( textDirection: TextDirection.ltr, child: boilerplateWidget( null, controller: controller, stackKey: stackKey, containerKey: containerKey, ), )); await tester.pumpAndSettle(); final double screenHeight = tester.getSize(find.byKey(stackKey)).height; await tester.flingFrom(Offset(0, .5*screenHeight), Offset(0, -.5*screenHeight), 2000); // Wait until `flinFrom` finished dragging, but before the scrollable goes ballistic. await tester.pump(const Duration(seconds: 1)); expect( tester.getSize(find.byKey(containerKey)).height / screenHeight, closeTo(1, precisionErrorTolerance), ); expect(find.text('Item 1'), findsOneWidget); controller.animateTo(.9, duration: const Duration(milliseconds: 200), curve: Curves.linear); await tester.pumpAndSettle(); expect( tester.getSize(find.byKey(containerKey)).height / screenHeight, closeTo(.9, precisionErrorTolerance), ); // The ballistic animation should have been canceled so item 1 should still be visible. expect(find.text('Item 1'), findsOneWidget); }); testWidgets('Other animations interrupt animateTo', (WidgetTester tester) async { const Key stackKey = ValueKey<String>('stack'); const Key containerKey = ValueKey<String>('container'); final DraggableScrollableController controller = DraggableScrollableController(); await tester.pumpWidget(Directionality( textDirection: TextDirection.ltr, child: boilerplateWidget( null, controller: controller, stackKey: stackKey, containerKey: containerKey, ), )); await tester.pumpAndSettle(); final double screenHeight = tester.getSize(find.byKey(stackKey)).height; controller.animateTo(1, duration: const Duration(milliseconds: 200), curve: Curves.linear); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); expect( tester.getSize(find.byKey(containerKey)).height / screenHeight, closeTo(.75, precisionErrorTolerance), ); // Interrupt animation and drag downward. await tester.drag(find.text('Item 1'), Offset(0, .1 * screenHeight)); await tester.pumpAndSettle(); expect( tester.getSize(find.byKey(containerKey)).height / screenHeight, closeTo(.65, precisionErrorTolerance), ); }); testWidgets('animateTo can be interrupted by other animateTo or jumpTo', (WidgetTester tester) async { const Key stackKey = ValueKey<String>('stack'); const Key containerKey = ValueKey<String>('container'); final DraggableScrollableController controller = DraggableScrollableController(); await tester.pumpWidget(Directionality( textDirection: TextDirection.ltr, child: boilerplateWidget( null, controller: controller, stackKey: stackKey, containerKey: containerKey, ), )); await tester.pumpAndSettle(); final double screenHeight = tester.getSize(find.byKey(stackKey)).height; controller.animateTo(1, duration: const Duration(milliseconds: 200), curve: Curves.linear); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); expect( tester.getSize(find.byKey(containerKey)).height / screenHeight, closeTo(.75, precisionErrorTolerance), ); // Interrupt animation with a new `animateTo`. controller.animateTo(.25, duration: const Duration(milliseconds: 200), curve: Curves.linear); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); expect( tester.getSize(find.byKey(containerKey)).height / screenHeight, closeTo(.5, precisionErrorTolerance), ); // Interrupt animation with a jump. controller.jumpTo(.6); await tester.pumpAndSettle(); expect( tester.getSize(find.byKey(containerKey)).height / screenHeight, closeTo(.6, precisionErrorTolerance), ); }); testWidgets('Can get size and pixels', (WidgetTester tester) async { const Key stackKey = ValueKey<String>('stack'); const Key containerKey = ValueKey<String>('container'); final DraggableScrollableController controller = DraggableScrollableController(); await tester.pumpWidget(boilerplateWidget( null, controller: controller, stackKey: stackKey, containerKey: containerKey, )); await tester.pumpAndSettle(); final double screenHeight = tester.getSize(find.byKey(stackKey)).height; expect(controller.sizeToPixels(.25), .25*screenHeight); expect(controller.pixelsToSize(.25*screenHeight), .25); controller.animateTo(.6, duration: const Duration(milliseconds: 200), curve: Curves.linear); await tester.pumpAndSettle(); expect( tester.getSize(find.byKey(containerKey)).height / screenHeight, closeTo(.6, precisionErrorTolerance), ); expect(controller.size, closeTo(.6, precisionErrorTolerance)); expect(controller.pixels, closeTo(.6*screenHeight, precisionErrorTolerance)); await tester.drag(find.text('Item 5'), Offset(0, .2*screenHeight)); expect(controller.size, closeTo(.4, precisionErrorTolerance)); expect(controller.pixels, closeTo(.4*screenHeight, precisionErrorTolerance)); }); testWidgets('Cannot attach a controller to multiple sheets', (WidgetTester tester) async { final DraggableScrollableController controller = DraggableScrollableController(); await tester.pumpWidget(Directionality( textDirection: TextDirection.ltr, child: Stack( children: <Widget>[ boilerplateWidget( null, controller: controller, ), boilerplateWidget( null, controller: controller, ), ], ), ), null, EnginePhase.build); expect(tester.takeException(), isAssertionError); }); testWidgets('Can listen for changes in sheet size', (WidgetTester tester) async { const Key stackKey = ValueKey<String>('stack'); const Key containerKey = ValueKey<String>('container'); final List<double> loggedSizes = <double>[]; final DraggableScrollableController controller = DraggableScrollableController(); controller.addListener(() { loggedSizes.add(controller.size); }); await tester.pumpWidget(boilerplateWidget( null, controller: controller, stackKey: stackKey, containerKey: containerKey, )); await tester.pumpAndSettle(); final double screenHeight = tester .getSize(find.byKey(stackKey)) .height; // The initial size shouldn't be logged because no change has occurred yet. expect(loggedSizes.isEmpty, true); await tester.drag(find.text('Item 1'), Offset(0, .1 * screenHeight), touchSlopY: 0); await tester.pumpAndSettle(); expect(loggedSizes, <double>[.4].map((double v) => closeTo(v, precisionErrorTolerance))); loggedSizes.clear(); await tester.timedDrag(find.text('Item 1'), Offset(0, -.1 * screenHeight), const Duration(seconds: 1), frequency: 2); await tester.pumpAndSettle(); expect(loggedSizes, <double>[.45, .5].map((double v) => closeTo(v, precisionErrorTolerance))); loggedSizes.clear(); controller.jumpTo(.6); await tester.pumpAndSettle(); expect(loggedSizes, <double>[.6].map((double v) => closeTo(v, precisionErrorTolerance))); loggedSizes.clear(); controller.animateTo(1, duration: const Duration(milliseconds: 400), curve: Curves.linear); await tester.pumpAndSettle(); expect(loggedSizes, <double>[.7, .8, .9, 1].map((double v) => closeTo(v, precisionErrorTolerance))); loggedSizes.clear(); DraggableScrollableActuator.reset(tester.element(find.byKey(containerKey))); await tester.pumpAndSettle(); expect(loggedSizes, <double>[.5].map((double v) => closeTo(v, precisionErrorTolerance))); loggedSizes.clear(); }); testWidgets('Listener does not fire on parameter change and persists after change', (WidgetTester tester) async { const Key stackKey = ValueKey<String>('stack'); const Key containerKey = ValueKey<String>('container'); final List<double> loggedSizes = <double>[]; final DraggableScrollableController controller = DraggableScrollableController(); controller.addListener(() { loggedSizes.add(controller.size); }); await tester.pumpWidget(boilerplateWidget( null, controller: controller, stackKey: stackKey, containerKey: containerKey, )); await tester.pumpAndSettle(); final double screenHeight = tester .getSize(find.byKey(stackKey)) .height; expect(loggedSizes.isEmpty, true); await tester.drag(find.text('Item 1'), Offset(0, .1 * screenHeight), touchSlopY: 0); await tester.pumpAndSettle(); expect(loggedSizes, <double>[.4].map((double v) => closeTo(v, precisionErrorTolerance))); loggedSizes.clear(); // Update a parameter without forcing a change in the current size. await tester.pumpWidget(boilerplateWidget( null, minChildSize: .1, controller: controller, stackKey: stackKey, containerKey: containerKey, )); expect(loggedSizes.isEmpty, true); await tester.drag(find.text('Item 1'), Offset(0, .1 * screenHeight), touchSlopY: 0); await tester.pumpAndSettle(); expect(loggedSizes, <double>[.3].map((double v) => closeTo(v, precisionErrorTolerance))); loggedSizes.clear(); }); testWidgets('Listener fires if a parameter change forces a change in size', (WidgetTester tester) async { const Key stackKey = ValueKey<String>('stack'); const Key containerKey = ValueKey<String>('container'); final List<double> loggedSizes = <double>[]; final DraggableScrollableController controller = DraggableScrollableController(); controller.addListener(() { loggedSizes.add(controller.size); }); await tester.pumpWidget(boilerplateWidget( null, controller: controller, stackKey: stackKey, containerKey: containerKey, )); await tester.pumpAndSettle(); final double screenHeight = tester .getSize(find.byKey(stackKey)) .height; expect(loggedSizes.isEmpty, true); // Set a new `initialChildSize` which will trigger a size change because we // haven't moved away initial size yet. await tester.pumpWidget(boilerplateWidget( null, initialChildSize: .6, controller: controller, stackKey: stackKey, containerKey: containerKey, )); expect(loggedSizes, <double>[.6].map((double v) => closeTo(v, precisionErrorTolerance))); loggedSizes.clear(); // Move away from initial child size. await tester.drag(find.text('Item 1'), Offset(0, .3 * screenHeight), touchSlopY: 0); await tester.pumpAndSettle(); expect(loggedSizes, <double>[.3].map((double v) => closeTo(v, precisionErrorTolerance))); loggedSizes.clear(); // Set a `minChildSize` greater than the current size. await tester.pumpWidget(boilerplateWidget( null, minChildSize: .4, controller: controller, stackKey: stackKey, containerKey: containerKey, )); expect(loggedSizes, <double>[.4].map((double v) => closeTo(v, precisionErrorTolerance))); loggedSizes.clear(); }); testWidgets('Invalid controller interactions throw assertion errors', (WidgetTester tester) async { final DraggableScrollableController controller = DraggableScrollableController(); // Can't use a controller before attaching it. expect(() => controller.jumpTo(.1), throwsAssertionError); expect(() => controller.pixels, throwsAssertionError); expect(() => controller.size, throwsAssertionError); expect(() => controller.pixelsToSize(0), throwsAssertionError); expect(() => controller.sizeToPixels(0), throwsAssertionError); await tester.pumpWidget(boilerplateWidget( null, controller: controller, )); // Can't jump or animate to invalid sizes. expect(() => controller.jumpTo(-1), throwsAssertionError); expect(() => controller.jumpTo(1.1), throwsAssertionError); expect( () => controller.animateTo(-1, duration: const Duration(milliseconds: 1), curve: Curves.linear), throwsAssertionError, ); expect( () => controller.animateTo(1.1, duration: const Duration(milliseconds: 1), curve: Curves.linear), throwsAssertionError, ); // Can't use animateTo with a zero duration. expect(() => controller.animateTo(.5, duration: Duration.zero, curve: Curves.linear), throwsAssertionError); }); testWidgets('DraggableScrollableController must be attached before using any of its parameters', (WidgetTester tester) async { final DraggableScrollableController controller = DraggableScrollableController(); expect(controller.isAttached, false); expect(()=>controller.size, throwsAssertionError); final Widget boilerplate = boilerplateWidget( null, minChildSize: 0.4, controller: controller, ); await tester.pumpWidget(boilerplate); expect(controller.isAttached, true); expect(controller.size, isNotNull); }); testWidgets('DraggableScrollableController.animateTo should not leak Ticker', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/102483 final DraggableScrollableController controller = DraggableScrollableController(); await tester.pumpWidget(boilerplateWidget(() {}, controller: controller)); controller.animateTo(0.0, curve: Curves.linear, duration: const Duration(milliseconds: 200)); await tester.pump(); // Dispose the DraggableScrollableSheet await tester.pumpWidget(const SizedBox.shrink()); // Controller should be detached and no exception should be thrown expect(controller.isAttached, false); expect(tester.takeException(), isNull); }); testWidgets('DraggableScrollableSheet should not reset programmatic drag on rebuild', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/101114 const Key stackKey = ValueKey<String>('stack'); const Key containerKey = ValueKey<String>('container'); final DraggableScrollableController controller = DraggableScrollableController(); await tester.pumpWidget(boilerplateWidget( null, controller: controller, stackKey: stackKey, containerKey: containerKey, )); await tester.pumpAndSettle(); final double screenHeight = tester.getSize(find.byKey(stackKey)).height; controller.jumpTo(.6); await tester.pumpAndSettle(); expect( tester.getSize(find.byKey(containerKey)).height / screenHeight, closeTo(.6, precisionErrorTolerance), ); // Force an arbitrary rebuild by pushing a new widget. await tester.pumpWidget(boilerplateWidget( null, controller: controller, stackKey: stackKey, containerKey: containerKey, )); // Sheet remains at .6. expect( tester.getSize(find.byKey(containerKey)).height / screenHeight, closeTo(.6, precisionErrorTolerance), ); controller.reset(); await tester.pumpAndSettle(); expect( tester.getSize(find.byKey(containerKey)).height / screenHeight, closeTo(.5, precisionErrorTolerance), ); controller.animateTo( .6, curve: Curves.linear, duration: const Duration(milliseconds: 200), ); await tester.pumpAndSettle(); expect( tester.getSize(find.byKey(containerKey)).height / screenHeight, closeTo(.6, precisionErrorTolerance), ); // Force an arbitrary rebuild by pushing a new widget. await tester.pumpWidget(boilerplateWidget( null, controller: controller, stackKey: stackKey, containerKey: containerKey, )); // Sheet remains at .6. expect( tester.getSize(find.byKey(containerKey)).height / screenHeight, closeTo(.6, precisionErrorTolerance), ); }); }