diff --git a/packages/flutter/lib/src/widgets/draggable_scrollable_sheet.dart b/packages/flutter/lib/src/widgets/draggable_scrollable_sheet.dart index 60fbae424b..118f48d054 100644 --- a/packages/flutter/lib/src/widgets/draggable_scrollable_sheet.dart +++ b/packages/flutter/lib/src/widgets/draggable_scrollable_sheet.dart @@ -6,6 +6,7 @@ import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; +import 'package:flutter/physics.dart'; import 'basic.dart'; import 'binding.dart'; @@ -34,6 +35,138 @@ typedef ScrollableWidgetBuilder = Widget Function( ScrollController scrollController, ); +/// Controls a [DraggableScrollableSheet]. +/// +/// Draggable scrollable controllers are typically stored as member variables in +/// [State] objects and are reused in each [State.build]. Controllers can only +/// be used to control one sheet at a time. A controller can be reused with a +/// new sheet if the previous sheet has been disposed. +/// +/// The controller's methods cannot be used until after the controller has been +/// passed into a [DraggableScrollableSheet] and the sheet has run initState. +class DraggableScrollableController { + _DraggableScrollableSheetScrollController? _attachedController; + + /// Get the current size (as a fraction of the parent height) of the attached sheet. + double get size { + _assertAttached(); + return _attachedController!.extent.currentSize; + } + + /// Get the current pixel height of the attached sheet. + double get pixels { + _assertAttached(); + return _attachedController!.extent.currentPixels; + } + + /// Convert a sheet's size (fractional value of parent container height) to pixels. + double sizeToPixels(double size) { + _assertAttached(); + return _attachedController!.extent.sizeToPixels(size); + } + + /// Convert a sheet's pixel height to size (fractional value of parent container height). + double pixelsToSize(double pixels) { + _assertAttached(); + return _attachedController!.extent.pixelsToSize(pixels); + } + + /// Animates the attached sheet from its current size to [size] to the + /// provided new `size`, a fractional value of the parent container's height. + /// + /// Any active sheet animation is canceled. If the sheet's internal scrollable + /// is currently animating (e.g. responding to a user fling), that animation is + /// canceled as well. + /// + /// An animation will be interrupted whenever the user attempts to scroll + /// manually, whenever another activity is started, or when the sheet hits its + /// max or min size (e.g. if you animate to 1 but the max size is .8, the + /// animation will stop playing when it reaches .8). + /// + /// The duration must not be zero. To jump to a particular value without an + /// animation, use [jumpTo]. + /// + /// When calling [animateTo] in widget tests, `await`ing the returned + /// [Future] may cause the test to hang and timeout. Instead, use + /// [WidgetTester.pumpAndSettle]. + Future animateTo( + double size, { + required Duration duration, + required Curve curve, + }) async { + _assertAttached(); + assert(size >= 0 && size <= 1); + assert(duration != Duration.zero); + final AnimationController animationController = AnimationController.unbounded( + vsync: _attachedController!.position.context.vsync, + value: _attachedController!.extent.currentSize, + ); + _attachedController!.position.goIdle(); + // This disables any snapping until the next user interaction with the sheet. + _attachedController!.extent.hasDragged = false; + _attachedController!.extent.startActivity(onCanceled: () { + // Don't stop the controller if it's already finished and may have been disposed. + if (animationController.isAnimating) { + animationController.stop(); + } + }); + CurvedAnimation(parent: animationController, curve: curve).addListener(() { + _attachedController!.extent.updateSize( + animationController.value, + _attachedController!.position.context.notificationContext!, + ); + if (animationController.value > _attachedController!.extent.maxSize || + animationController.value < _attachedController!.extent.minSize) { + // Animation hit the max or min size, stop animating. + animationController.stop(canceled: false); + } + }); + await animationController.animateTo(size, duration: duration); + } + + /// Jumps the attached sheet from its current size to the given [size], a + /// fractional value of the parent container's height. + /// + /// If [size] is outside of a the attached sheet's min or max child size, + /// [jumpTo] will jump the sheet to the nearest valid size instead. + /// + /// Any active sheet animation is canceled. If the sheet's inner scrollable + /// is currently animating (e.g. responding to a user fling), that animation is + /// canceled as well. + void jumpTo(double size) { + _assertAttached(); + assert(size >= 0 && size <= 1); + // Call start activity to interrupt any other playing activities. + _attachedController!.extent.startActivity(onCanceled: () {}); + _attachedController!.position.goIdle(); + _attachedController!.extent.hasDragged = false; + _attachedController!.extent.updateSize(size, _attachedController!.position.context.notificationContext!); + } + + /// Reset the attached sheet to its initial size (see: [DraggableScrollableSheet.initialChildSize]). + void reset() { + _assertAttached(); + _attachedController!.reset(); + } + + void _assertAttached() { + assert( + _attachedController != null, + 'DraggableScrollableController is not attached to a sheet. A DraggableScrollableController ' + 'must be used in a DraggableScrollableSheet before any of its methods are called.', + ); + } + + void _attach(_DraggableScrollableSheetScrollController scrollController) { + assert(_attachedController == null, 'Draggable scrollable controller is already attached to a sheet.'); + _attachedController = scrollController; + } + + void _detach() { + _attachedController = null; + } +} + /// A container for a [Scrollable] that responds to drag gestures by resizing /// the scrollable until a limit is reached, and then scrolling. /// @@ -118,6 +251,7 @@ class DraggableScrollableSheet extends StatefulWidget { this.expand = true, this.snap = false, this.snapSizes, + this.controller, required this.builder, }) : assert(initialChildSize != null), assert(minChildSize != null), @@ -194,6 +328,9 @@ class DraggableScrollableSheet extends StatefulWidget { /// being built or since the last call to [DraggableScrollableActuator.reset]. final List? snapSizes; + /// A controller that can be used to programmatically control this sheet. + final DraggableScrollableController? controller; + /// The builder that creates a child to display in this widget, which will /// use the provided [ScrollController] to enable dragging and scrolling /// of the contents. @@ -293,7 +430,7 @@ class _DraggableSheetExtent { required this.initialSize, required this.onSizeChanged, ValueNotifier? currentSize, - bool? hasChanged, + bool? hasDragged, }) : assert(minSize != null), assert(maxSize != null), assert(initialSize != null), @@ -304,7 +441,9 @@ class _DraggableSheetExtent { _currentSize = (currentSize ?? ValueNotifier(initialSize)) ..addListener(onSizeChanged), availablePixels = double.infinity, - hasChanged = hasChanged ?? false; + hasDragged = hasDragged ?? false; + + VoidCallback? _cancelActivity; final double minSize; final double maxSize; @@ -315,19 +454,13 @@ class _DraggableSheetExtent { final VoidCallback onSizeChanged; double availablePixels; - // Used to disable snapping until the user interacts with the sheet. We set - // this to false on initialization and after programmatic interaction with the - // sheet to prevent snapping away from the initial size and after animateTo/jumpTo. - bool hasChanged; + // Used to disable snapping until the user has dragged on the sheet. We do + // this because we don't want to snap away from an initial or programmatically set size. + bool hasDragged; bool get isAtMin => minSize >= _currentSize.value; bool get isAtMax => maxSize <= _currentSize.value; - set currentSize(double value) { - assert(value != null); - hasChanged = true; - _currentSize.value = value.clamp(minSize, maxSize); - } double get currentSize => _currentSize.value; double get currentPixels => sizeToPixels(_currentSize.value); @@ -335,18 +468,43 @@ class _DraggableSheetExtent { double get additionalMaxSize => isAtMax ? 0.0 : 1.0; List get pixelSnapSizes => snapSizes.map(sizeToPixels).toList(); + /// Start an activity that affects the sheet and register a cancel call back + /// that will be called if another activity starts. + /// + /// Note that `onCanceled` will get called even if the subsequent activity + /// started after this one finished so `onCanceled` should be safe to call at + /// any time. + void startActivity({required VoidCallback onCanceled}) { + _cancelActivity?.call(); + _cancelActivity = onCanceled; + } + /// The scroll position gets inputs in terms of pixels, but the size is /// expected to be expressed as a number between 0..1. + /// + /// This should only be called to respond to a user drag. To update the + /// size in response to a programmatic call, use [updateSize] directly. void addPixelDelta(double delta, BuildContext context) { + // Stop any playing sheet animations. + _cancelActivity?.call(); + _cancelActivity = null; + // The user has interacted with the sheet, set `hasDragged` to true so that + // we'll snap if applicable. + hasDragged = true; if (availablePixels == 0) { return; } updateSize(currentSize + pixelsToSize(delta), context); } - /// Set the size to the new value. [newSize] should be a number between 0..1. + /// Set the size to the new value. [newSize] should be a number between + /// [minSize] and [maxSize]. + /// + /// This can be triggered by a programmatic (e.g. controller triggered) change + /// or a user drag. void updateSize(double newSize, BuildContext context) { - currentSize = newSize; + assert(newSize != null); + _currentSize.value = newSize.clamp(minSize, maxSize); DraggableScrollableNotification( minExtent: minSize, maxExtent: maxSize, @@ -360,8 +518,8 @@ class _DraggableSheetExtent { return pixels / availablePixels * maxSize; } - double sizeToPixels(double extent) { - return extent / maxSize * availablePixels; + double sizeToPixels(double size) { + return size / maxSize * availablePixels; } void dispose() { @@ -383,11 +541,11 @@ class _DraggableSheetExtent { snapSizes: snapSizes, initialSize: initialSize, onSizeChanged: onSizeChanged, - // Use the possibly updated initialExtent if the user hasn't dragged yet. - currentSize: ValueNotifier(hasChanged + // Use the possibly updated initialSize if the user hasn't dragged yet. + currentSize: ValueNotifier(hasDragged ? _currentSize.value.clamp(minSize, maxSize) : initialSize), - hasChanged: hasChanged, + hasDragged: hasDragged, ); } } @@ -405,9 +563,10 @@ class _DraggableScrollableSheetState extends State { snap: widget.snap, snapSizes: _impliedSnapSizes(), initialSize: widget.initialChildSize, - onSizeChanged: _setSize, + onSizeChanged: _setExtent, ); _scrollController = _DraggableScrollableSheetScrollController(extent: _extent); + widget.controller?._attach(_scrollController); } List _impliedSnapSizes() { @@ -418,8 +577,6 @@ class _DraggableScrollableSheetState extends State { assert(index == 0 || snapSize > widget.snapSizes![index - 1], '${_snapSizeErrorMessage(index)}\nSnap sizes must be in ascending order. '); } - widget.snapSizes?.asMap().forEach((int index, double snapSize) { - }); // Ensure the snap sizes start and end with the min and max child sizes. if (widget.snapSizes == null || widget.snapSizes!.isEmpty) { return [ @@ -444,22 +601,11 @@ class _DraggableScrollableSheetState extends State { void didChangeDependencies() { super.didChangeDependencies(); if (_InheritedResetNotifier.shouldReset(context)) { - // jumpTo can result in trying to replace semantics during build. - // Just animate really fast. - // Avoid doing it at all if the offset is already 0.0. - if (_scrollController.offset != 0.0) { - _scrollController.animateTo( - 0.0, - duration: const Duration(milliseconds: 1), - curve: Curves.linear, - ); - } - _extent.hasChanged = false; - _extent._currentSize.value = _extent.initialSize; + _scrollController.reset(); } } - void _setSize() { + void _setExtent() { setState(() { // _extent has been updated when this is called. }); @@ -482,6 +628,7 @@ class _DraggableScrollableSheetState extends State { @override void dispose() { + widget.controller?._detach(); _scrollController.dispose(); _extent.dispose(); super.dispose(); @@ -495,7 +642,7 @@ class _DraggableScrollableSheetState extends State { snap: widget.snap, snapSizes: _impliedSnapSizes(), initialSize: widget.initialChildSize, - onSizeChanged: _setSize, + onSizeChanged: _setExtent, ); // Modify the existing scroll controller instead of replacing it so that // developers listening to the controller do not have to rebuild their listeners. @@ -513,7 +660,7 @@ class _DraggableScrollableSheetState extends State { String _snapSizeErrorMessage(int invalidIndex) { final List snapSizesWithIndicator = widget.snapSizes!.asMap().keys.map( - (int index) { + (int index) { final String snapSizeString = widget.snapSizes![index].toString(); if (index == invalidIndex) { return '>>> $snapSizeString <<<'; @@ -576,6 +723,22 @@ class _DraggableScrollableSheetScrollController extends ScrollController { @override _DraggableScrollableSheetScrollPosition get position => super.position as _DraggableScrollableSheetScrollPosition; + + void reset() { + extent._cancelActivity?.call(); + extent.hasDragged = false; + // jumpTo can result in trying to replace semantics during build. + // Just animate really fast. + // Avoid doing it at all if the offset is already 0.0. + if (offset != 0.0) { + animateTo( + 0.0, + duration: const Duration(milliseconds: 1), + curve: Curves.linear, + ); + } + extent.updateSize(extent.initialSize, position.context.notificationContext!); + } } /// A scroll position that manages scroll activities for @@ -653,7 +816,7 @@ class _DraggableScrollableSheetScrollPosition }, ); } - bool get _shouldSnap => extent.snap && extent.hasChanged && !_isAtSnapSize; + bool get _shouldSnap => extent.snap && extent.hasDragged && !_isAtSnapSize; @override void dispose() { @@ -742,6 +905,13 @@ class _DraggableScrollableSheetScrollPosition /// the user has tapped back if the sheet has started to cover more of the body /// than when at its initial position. This is important for users of assistive /// technology, where dragging may be difficult to communicate. +/// +/// This is just a wrapper on top of [DraggableScrollableController]. It is +/// primarily useful for controlling a sheet in a part of the widget tree that +/// the current code does not control (e.g. library code trying to affect a sheet +/// in library users' code). Generally, it's easier to control the sheet +/// directly by creating a controller and passing the controller to the sheet in +/// its constructor (see [DraggableScrollableSheet.controller]). class DraggableScrollableActuator extends StatelessWidget { /// Creates a widget that can notify descendent [DraggableScrollableSheet]s /// to reset to their initial position. diff --git a/packages/flutter/test/widgets/draggable_scrollable_sheet_test.dart b/packages/flutter/test/widgets/draggable_scrollable_sheet_test.dart index ce9b0b38a5..915347390a 100644 --- a/packages/flutter/test/widgets/draggable_scrollable_sheet_test.dart +++ b/packages/flutter/test/widgets/draggable_scrollable_sheet_test.dart @@ -8,6 +8,7 @@ import 'package:flutter_test/flutter_test.dart'; void main() { Widget _boilerplate(VoidCallback? onButtonPressed, { + DraggableScrollableController? controller, int itemCount = 100, double initialChildSize = .5, double maxChildSize = 1.0, @@ -33,6 +34,7 @@ void main() { ), DraggableScrollableActuator( child: DraggableScrollableSheet( + controller: controller, maxChildSize: maxChildSize, minChildSize: minChildSize, initialChildSize: initialChildSize, @@ -346,33 +348,42 @@ void main() { )); }, variant: TargetPlatformVariant.all()); - testWidgets('Does not snap away from initial child on reset', (WidgetTester tester) async { - const Key containerKey = ValueKey('container'); - const Key stackKey = ValueKey('stack'); - await tester.pumpWidget(_boilerplate(null, - snap: true, - containerKey: containerKey, - stackKey: stackKey, - )); - await tester.pumpAndSettle(); - final double screenHeight = tester.getSize(find.byKey(stackKey)).height; + for (final bool useActuator in [false, true]) { + testWidgets('Does not snap away from initial child on ${useActuator ? 'actuator' : 'controller'}.reset()', (WidgetTester tester) async { + const Key containerKey = ValueKey('container'); + const Key stackKey = ValueKey('stack'); + final DraggableScrollableController controller = DraggableScrollableController(); + await tester.pumpWidget(_boilerplate( + 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), - ); + 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), + ); - DraggableScrollableActuator.reset(tester.element(find.byKey(containerKey))); - await tester.pumpAndSettle(); + 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), - ); - }, variant: TargetPlatformVariant.all()); + // The sheet should have reset without snapping away from initial child. + expect( + tester.getSize(find.byKey(containerKey)).height / screenHeight, + closeTo(.5, precisionErrorTolerance), + ); + }); + } testWidgets('Zero velocity drag snaps to nearest snap target', (WidgetTester tester) async { const Key stackKey = ValueKey('stack'); @@ -695,4 +706,305 @@ void main() { expect(tester.takeException(), isNull); }); + + for (final bool shouldAnimate in [true, false]) { + testWidgets('Can ${shouldAnimate ? 'animate' : 'jump'} to arbitrary positions', (WidgetTester tester) async { + const Key stackKey = ValueKey('stack'); + const Key containerKey = ValueKey('container'); + final DraggableScrollableController controller = DraggableScrollableController(); + await tester.pumpWidget(_boilerplate( + 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 reuse a controller after the old controller is disposed', (WidgetTester tester) async { + const Key stackKey = ValueKey('stack'); + const Key containerKey = ValueKey('container'); + final DraggableScrollableController controller = DraggableScrollableController(); + await tester.pumpWidget(_boilerplate( + 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(_boilerplate( + 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('stack'); + const Key containerKey = ValueKey('container'); + final DraggableScrollableController controller = DraggableScrollableController(); + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: _boilerplate( + 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('stack'); + const Key containerKey = ValueKey('container'); + final DraggableScrollableController controller = DraggableScrollableController(); + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: _boilerplate( + 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('stack'); + const Key containerKey = ValueKey('container'); + final DraggableScrollableController controller = DraggableScrollableController(); + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: _boilerplate( + 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('stack'); + const Key containerKey = ValueKey('container'); + final DraggableScrollableController controller = DraggableScrollableController(); + await tester.pumpWidget(_boilerplate( + 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: [ + _boilerplate( + null, + controller: controller, + ), + _boilerplate( + null, + controller: controller, + ) + ], + ), + ), null, EnginePhase.build); + expect(tester.takeException(), isAssertionError); + }); + + 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(_boilerplate( + 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); + }); }