Unverified Commit a6702f6a authored by Casey Rogers's avatar Casey Rogers Committed by GitHub

Allow Programmatic Control of Draggable Sheet (#92440)

parent 1f6c6e26
......@@ -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<void> 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<double>? 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<double>? currentSize,
bool? hasChanged,
bool? hasDragged,
}) : assert(minSize != null),
assert(maxSize != null),
assert(initialSize != null),
......@@ -304,7 +441,9 @@ class _DraggableSheetExtent {
_currentSize = (currentSize ?? ValueNotifier<double>(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<double> 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<double>(hasChanged
// Use the possibly updated initialSize if the user hasn't dragged yet.
currentSize: ValueNotifier<double>(hasDragged
? _currentSize.value.clamp(minSize, maxSize)
: initialSize),
hasChanged: hasChanged,
hasDragged: hasDragged,
);
}
}
......@@ -405,9 +563,10 @@ class _DraggableScrollableSheetState extends State<DraggableScrollableSheet> {
snap: widget.snap,
snapSizes: _impliedSnapSizes(),
initialSize: widget.initialChildSize,
onSizeChanged: _setSize,
onSizeChanged: _setExtent,
);
_scrollController = _DraggableScrollableSheetScrollController(extent: _extent);
widget.controller?._attach(_scrollController);
}
List<double> _impliedSnapSizes() {
......@@ -418,8 +577,6 @@ class _DraggableScrollableSheetState extends State<DraggableScrollableSheet> {
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 <double>[
......@@ -444,22 +601,11 @@ class _DraggableScrollableSheetState extends State<DraggableScrollableSheet> {
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<DraggableScrollableSheet> {
@override
void dispose() {
widget.controller?._detach();
_scrollController.dispose();
_extent.dispose();
super.dispose();
......@@ -495,7 +642,7 @@ class _DraggableScrollableSheetState extends State<DraggableScrollableSheet> {
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<DraggableScrollableSheet> {
String _snapSizeErrorMessage(int invalidIndex) {
final List<String> 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.
......
......@@ -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<String>('container');
const Key stackKey = ValueKey<String>('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 <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(_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<String>('stack');
......@@ -695,4 +706,305 @@ void main() {
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(_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<String>('stack');
const Key containerKey = ValueKey<String>('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<String>('stack');
const Key containerKey = ValueKey<String>('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<String>('stack');
const Key containerKey = ValueKey<String>('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<String>('stack');
const Key containerKey = ValueKey<String>('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<String>('stack');
const Key containerKey = ValueKey<String>('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: <Widget>[
_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);
});
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment