Allow Programmatic Control of Draggable Sheet (#92440)
This commit is contained in:
parent
1f6c6e2649
commit
a6702f6aa1
@ -6,6 +6,7 @@ import 'dart:math' as math;
|
|||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/gestures.dart';
|
import 'package:flutter/gestures.dart';
|
||||||
|
import 'package:flutter/physics.dart';
|
||||||
|
|
||||||
import 'basic.dart';
|
import 'basic.dart';
|
||||||
import 'binding.dart';
|
import 'binding.dart';
|
||||||
@ -34,6 +35,138 @@ typedef ScrollableWidgetBuilder = Widget Function(
|
|||||||
ScrollController scrollController,
|
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
|
/// A container for a [Scrollable] that responds to drag gestures by resizing
|
||||||
/// the scrollable until a limit is reached, and then scrolling.
|
/// the scrollable until a limit is reached, and then scrolling.
|
||||||
///
|
///
|
||||||
@ -118,6 +251,7 @@ class DraggableScrollableSheet extends StatefulWidget {
|
|||||||
this.expand = true,
|
this.expand = true,
|
||||||
this.snap = false,
|
this.snap = false,
|
||||||
this.snapSizes,
|
this.snapSizes,
|
||||||
|
this.controller,
|
||||||
required this.builder,
|
required this.builder,
|
||||||
}) : assert(initialChildSize != null),
|
}) : assert(initialChildSize != null),
|
||||||
assert(minChildSize != null),
|
assert(minChildSize != null),
|
||||||
@ -194,6 +328,9 @@ class DraggableScrollableSheet extends StatefulWidget {
|
|||||||
/// being built or since the last call to [DraggableScrollableActuator.reset].
|
/// being built or since the last call to [DraggableScrollableActuator.reset].
|
||||||
final List<double>? snapSizes;
|
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
|
/// The builder that creates a child to display in this widget, which will
|
||||||
/// use the provided [ScrollController] to enable dragging and scrolling
|
/// use the provided [ScrollController] to enable dragging and scrolling
|
||||||
/// of the contents.
|
/// of the contents.
|
||||||
@ -293,7 +430,7 @@ class _DraggableSheetExtent {
|
|||||||
required this.initialSize,
|
required this.initialSize,
|
||||||
required this.onSizeChanged,
|
required this.onSizeChanged,
|
||||||
ValueNotifier<double>? currentSize,
|
ValueNotifier<double>? currentSize,
|
||||||
bool? hasChanged,
|
bool? hasDragged,
|
||||||
}) : assert(minSize != null),
|
}) : assert(minSize != null),
|
||||||
assert(maxSize != null),
|
assert(maxSize != null),
|
||||||
assert(initialSize != null),
|
assert(initialSize != null),
|
||||||
@ -304,7 +441,9 @@ class _DraggableSheetExtent {
|
|||||||
_currentSize = (currentSize ?? ValueNotifier<double>(initialSize))
|
_currentSize = (currentSize ?? ValueNotifier<double>(initialSize))
|
||||||
..addListener(onSizeChanged),
|
..addListener(onSizeChanged),
|
||||||
availablePixels = double.infinity,
|
availablePixels = double.infinity,
|
||||||
hasChanged = hasChanged ?? false;
|
hasDragged = hasDragged ?? false;
|
||||||
|
|
||||||
|
VoidCallback? _cancelActivity;
|
||||||
|
|
||||||
final double minSize;
|
final double minSize;
|
||||||
final double maxSize;
|
final double maxSize;
|
||||||
@ -315,19 +454,13 @@ class _DraggableSheetExtent {
|
|||||||
final VoidCallback onSizeChanged;
|
final VoidCallback onSizeChanged;
|
||||||
double availablePixels;
|
double availablePixels;
|
||||||
|
|
||||||
// Used to disable snapping until the user interacts with the sheet. We set
|
// Used to disable snapping until the user has dragged on the sheet. We do
|
||||||
// this to false on initialization and after programmatic interaction with the
|
// this because we don't want to snap away from an initial or programmatically set size.
|
||||||
// sheet to prevent snapping away from the initial size and after animateTo/jumpTo.
|
bool hasDragged;
|
||||||
bool hasChanged;
|
|
||||||
|
|
||||||
bool get isAtMin => minSize >= _currentSize.value;
|
bool get isAtMin => minSize >= _currentSize.value;
|
||||||
bool get isAtMax => maxSize <= _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 currentSize => _currentSize.value;
|
||||||
double get currentPixels => sizeToPixels(_currentSize.value);
|
double get currentPixels => sizeToPixels(_currentSize.value);
|
||||||
|
|
||||||
@ -335,18 +468,43 @@ class _DraggableSheetExtent {
|
|||||||
double get additionalMaxSize => isAtMax ? 0.0 : 1.0;
|
double get additionalMaxSize => isAtMax ? 0.0 : 1.0;
|
||||||
List<double> get pixelSnapSizes => snapSizes.map(sizeToPixels).toList();
|
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
|
/// The scroll position gets inputs in terms of pixels, but the size is
|
||||||
/// expected to be expressed as a number between 0..1.
|
/// 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) {
|
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) {
|
if (availablePixels == 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateSize(currentSize + pixelsToSize(delta), context);
|
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) {
|
void updateSize(double newSize, BuildContext context) {
|
||||||
currentSize = newSize;
|
assert(newSize != null);
|
||||||
|
_currentSize.value = newSize.clamp(minSize, maxSize);
|
||||||
DraggableScrollableNotification(
|
DraggableScrollableNotification(
|
||||||
minExtent: minSize,
|
minExtent: minSize,
|
||||||
maxExtent: maxSize,
|
maxExtent: maxSize,
|
||||||
@ -360,8 +518,8 @@ class _DraggableSheetExtent {
|
|||||||
return pixels / availablePixels * maxSize;
|
return pixels / availablePixels * maxSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
double sizeToPixels(double extent) {
|
double sizeToPixels(double size) {
|
||||||
return extent / maxSize * availablePixels;
|
return size / maxSize * availablePixels;
|
||||||
}
|
}
|
||||||
|
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@ -383,11 +541,11 @@ class _DraggableSheetExtent {
|
|||||||
snapSizes: snapSizes,
|
snapSizes: snapSizes,
|
||||||
initialSize: initialSize,
|
initialSize: initialSize,
|
||||||
onSizeChanged: onSizeChanged,
|
onSizeChanged: onSizeChanged,
|
||||||
// Use the possibly updated initialExtent if the user hasn't dragged yet.
|
// Use the possibly updated initialSize if the user hasn't dragged yet.
|
||||||
currentSize: ValueNotifier<double>(hasChanged
|
currentSize: ValueNotifier<double>(hasDragged
|
||||||
? _currentSize.value.clamp(minSize, maxSize)
|
? _currentSize.value.clamp(minSize, maxSize)
|
||||||
: initialSize),
|
: initialSize),
|
||||||
hasChanged: hasChanged,
|
hasDragged: hasDragged,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -405,9 +563,10 @@ class _DraggableScrollableSheetState extends State<DraggableScrollableSheet> {
|
|||||||
snap: widget.snap,
|
snap: widget.snap,
|
||||||
snapSizes: _impliedSnapSizes(),
|
snapSizes: _impliedSnapSizes(),
|
||||||
initialSize: widget.initialChildSize,
|
initialSize: widget.initialChildSize,
|
||||||
onSizeChanged: _setSize,
|
onSizeChanged: _setExtent,
|
||||||
);
|
);
|
||||||
_scrollController = _DraggableScrollableSheetScrollController(extent: _extent);
|
_scrollController = _DraggableScrollableSheetScrollController(extent: _extent);
|
||||||
|
widget.controller?._attach(_scrollController);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<double> _impliedSnapSizes() {
|
List<double> _impliedSnapSizes() {
|
||||||
@ -418,8 +577,6 @@ class _DraggableScrollableSheetState extends State<DraggableScrollableSheet> {
|
|||||||
assert(index == 0 || snapSize > widget.snapSizes![index - 1],
|
assert(index == 0 || snapSize > widget.snapSizes![index - 1],
|
||||||
'${_snapSizeErrorMessage(index)}\nSnap sizes must be in ascending order. ');
|
'${_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.
|
// Ensure the snap sizes start and end with the min and max child sizes.
|
||||||
if (widget.snapSizes == null || widget.snapSizes!.isEmpty) {
|
if (widget.snapSizes == null || widget.snapSizes!.isEmpty) {
|
||||||
return <double>[
|
return <double>[
|
||||||
@ -444,22 +601,11 @@ class _DraggableScrollableSheetState extends State<DraggableScrollableSheet> {
|
|||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
if (_InheritedResetNotifier.shouldReset(context)) {
|
if (_InheritedResetNotifier.shouldReset(context)) {
|
||||||
// jumpTo can result in trying to replace semantics during build.
|
_scrollController.reset();
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _setSize() {
|
void _setExtent() {
|
||||||
setState(() {
|
setState(() {
|
||||||
// _extent has been updated when this is called.
|
// _extent has been updated when this is called.
|
||||||
});
|
});
|
||||||
@ -482,6 +628,7 @@ class _DraggableScrollableSheetState extends State<DraggableScrollableSheet> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
widget.controller?._detach();
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
_extent.dispose();
|
_extent.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@ -495,7 +642,7 @@ class _DraggableScrollableSheetState extends State<DraggableScrollableSheet> {
|
|||||||
snap: widget.snap,
|
snap: widget.snap,
|
||||||
snapSizes: _impliedSnapSizes(),
|
snapSizes: _impliedSnapSizes(),
|
||||||
initialSize: widget.initialChildSize,
|
initialSize: widget.initialChildSize,
|
||||||
onSizeChanged: _setSize,
|
onSizeChanged: _setExtent,
|
||||||
);
|
);
|
||||||
// Modify the existing scroll controller instead of replacing it so that
|
// Modify the existing scroll controller instead of replacing it so that
|
||||||
// developers listening to the controller do not have to rebuild their listeners.
|
// 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) {
|
String _snapSizeErrorMessage(int invalidIndex) {
|
||||||
final List<String> snapSizesWithIndicator = widget.snapSizes!.asMap().keys.map(
|
final List<String> snapSizesWithIndicator = widget.snapSizes!.asMap().keys.map(
|
||||||
(int index) {
|
(int index) {
|
||||||
final String snapSizeString = widget.snapSizes![index].toString();
|
final String snapSizeString = widget.snapSizes![index].toString();
|
||||||
if (index == invalidIndex) {
|
if (index == invalidIndex) {
|
||||||
return '>>> $snapSizeString <<<';
|
return '>>> $snapSizeString <<<';
|
||||||
@ -576,6 +723,22 @@ class _DraggableScrollableSheetScrollController extends ScrollController {
|
|||||||
@override
|
@override
|
||||||
_DraggableScrollableSheetScrollPosition get position =>
|
_DraggableScrollableSheetScrollPosition get position =>
|
||||||
super.position as _DraggableScrollableSheetScrollPosition;
|
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
|
/// 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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@ -742,6 +905,13 @@ class _DraggableScrollableSheetScrollPosition
|
|||||||
/// the user has tapped back if the sheet has started to cover more of the body
|
/// 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
|
/// than when at its initial position. This is important for users of assistive
|
||||||
/// technology, where dragging may be difficult to communicate.
|
/// 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 {
|
class DraggableScrollableActuator extends StatelessWidget {
|
||||||
/// Creates a widget that can notify descendent [DraggableScrollableSheet]s
|
/// Creates a widget that can notify descendent [DraggableScrollableSheet]s
|
||||||
/// to reset to their initial position.
|
/// to reset to their initial position.
|
||||||
|
@ -8,6 +8,7 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
Widget _boilerplate(VoidCallback? onButtonPressed, {
|
Widget _boilerplate(VoidCallback? onButtonPressed, {
|
||||||
|
DraggableScrollableController? controller,
|
||||||
int itemCount = 100,
|
int itemCount = 100,
|
||||||
double initialChildSize = .5,
|
double initialChildSize = .5,
|
||||||
double maxChildSize = 1.0,
|
double maxChildSize = 1.0,
|
||||||
@ -33,6 +34,7 @@ void main() {
|
|||||||
),
|
),
|
||||||
DraggableScrollableActuator(
|
DraggableScrollableActuator(
|
||||||
child: DraggableScrollableSheet(
|
child: DraggableScrollableSheet(
|
||||||
|
controller: controller,
|
||||||
maxChildSize: maxChildSize,
|
maxChildSize: maxChildSize,
|
||||||
minChildSize: minChildSize,
|
minChildSize: minChildSize,
|
||||||
initialChildSize: initialChildSize,
|
initialChildSize: initialChildSize,
|
||||||
@ -346,33 +348,42 @@ void main() {
|
|||||||
));
|
));
|
||||||
}, variant: TargetPlatformVariant.all());
|
}, variant: TargetPlatformVariant.all());
|
||||||
|
|
||||||
testWidgets('Does not snap away from initial child on reset', (WidgetTester tester) async {
|
for (final bool useActuator in <bool>[false, true]) {
|
||||||
const Key containerKey = ValueKey<String>('container');
|
testWidgets('Does not snap away from initial child on ${useActuator ? 'actuator' : 'controller'}.reset()', (WidgetTester tester) async {
|
||||||
const Key stackKey = ValueKey<String>('stack');
|
const Key containerKey = ValueKey<String>('container');
|
||||||
await tester.pumpWidget(_boilerplate(null,
|
const Key stackKey = ValueKey<String>('stack');
|
||||||
snap: true,
|
final DraggableScrollableController controller = DraggableScrollableController();
|
||||||
containerKey: containerKey,
|
await tester.pumpWidget(_boilerplate(
|
||||||
stackKey: stackKey,
|
null,
|
||||||
));
|
controller: controller,
|
||||||
await tester.pumpAndSettle();
|
snap: true,
|
||||||
final double screenHeight = tester.getSize(find.byKey(stackKey)).height;
|
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.drag(find.text('Item 1'), Offset(0, -.4 * screenHeight));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(
|
expect(
|
||||||
tester.getSize(find.byKey(containerKey)).height / screenHeight,
|
tester.getSize(find.byKey(containerKey)).height / screenHeight,
|
||||||
closeTo(1.0, precisionErrorTolerance),
|
closeTo(1.0, precisionErrorTolerance),
|
||||||
);
|
);
|
||||||
|
|
||||||
DraggableScrollableActuator.reset(tester.element(find.byKey(containerKey)));
|
if (useActuator) {
|
||||||
await tester.pumpAndSettle();
|
DraggableScrollableActuator.reset(tester.element(find.byKey(containerKey)));
|
||||||
|
} else {
|
||||||
|
controller.reset();
|
||||||
|
}
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// The sheet should have reset without snapping away from initial child.
|
// The sheet should have reset without snapping away from initial child.
|
||||||
expect(
|
expect(
|
||||||
tester.getSize(find.byKey(containerKey)).height / screenHeight,
|
tester.getSize(find.byKey(containerKey)).height / screenHeight,
|
||||||
closeTo(.5, precisionErrorTolerance),
|
closeTo(.5, precisionErrorTolerance),
|
||||||
);
|
);
|
||||||
}, variant: TargetPlatformVariant.all());
|
});
|
||||||
|
}
|
||||||
|
|
||||||
testWidgets('Zero velocity drag snaps to nearest snap target', (WidgetTester tester) async {
|
testWidgets('Zero velocity drag snaps to nearest snap target', (WidgetTester tester) async {
|
||||||
const Key stackKey = ValueKey<String>('stack');
|
const Key stackKey = ValueKey<String>('stack');
|
||||||
@ -695,4 +706,305 @@ void main() {
|
|||||||
|
|
||||||
expect(tester.takeException(), isNull);
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user