Add Snapping Behavior to DraggableScrollableSheet (#84394)
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
|
||||
@@ -51,6 +53,12 @@ typedef ScrollableWidgetBuilder = Widget Function(
|
||||
/// [ScrollableWidgetBuilder] does not use the provided [ScrollController], the
|
||||
/// sheet will remain at the initialChildSize.
|
||||
///
|
||||
/// By default, the widget will stay at whatever size the user drags it to. To
|
||||
/// make the widget snap to specific sizes whenever they lift their finger
|
||||
/// during a drag, set [snap] to `true`. The sheet will snap between
|
||||
/// [minChildSize] and [maxChildSize]. Use [snapSizes] to add more sizes for
|
||||
/// the sheet to snap between.
|
||||
///
|
||||
/// By default, the widget will expand its non-occupied area to fill available
|
||||
/// space in the parent. If this is not desired, e.g. because the parent wants
|
||||
/// to position sheet based on the space it is taking, the [expand] property
|
||||
@@ -107,6 +115,8 @@ class DraggableScrollableSheet extends StatefulWidget {
|
||||
this.minChildSize = 0.25,
|
||||
this.maxChildSize = 1.0,
|
||||
this.expand = true,
|
||||
this.snap = false,
|
||||
this.snapSizes,
|
||||
required this.builder,
|
||||
}) : assert(initialChildSize != null),
|
||||
assert(minChildSize != null),
|
||||
@@ -147,6 +157,26 @@ class DraggableScrollableSheet extends StatefulWidget {
|
||||
/// The default value is true.
|
||||
final bool expand;
|
||||
|
||||
/// Whether the widget should snap between [snapSizes] when the user lifts
|
||||
/// their finger during a drag.
|
||||
///
|
||||
/// If the user's finger was still moving when they lifted it, the widget will
|
||||
/// snap to the next snap size (see [snapSizes]) in the direction of the drag.
|
||||
/// If their finger was still, the widget will snap to the nearest snap size.
|
||||
final bool snap;
|
||||
|
||||
/// A list of target sizes that the widget should snap to.
|
||||
///
|
||||
/// Snap sizes are fractional values of the parent container's height. They
|
||||
/// must be listed in increasing order and be between [minChildSize] and
|
||||
/// [maxChildSize].
|
||||
///
|
||||
/// The [minChildSize] and [maxChildSize] are implicitly included in snap
|
||||
/// sizes and do not need to be specified here. For example, `snapSizes = [.5]`
|
||||
/// will result in a sheet that snaps between [minChildSize], `.5`, and
|
||||
/// [maxChildSize].
|
||||
final List<double>? snapSizes;
|
||||
|
||||
/// 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.
|
||||
@@ -241,6 +271,8 @@ class _DraggableSheetExtent {
|
||||
_DraggableSheetExtent({
|
||||
required this.minExtent,
|
||||
required this.maxExtent,
|
||||
required this.snap,
|
||||
required this.snapSizes,
|
||||
required this.initialExtent,
|
||||
required VoidCallback listener,
|
||||
}) : assert(minExtent != null),
|
||||
@@ -255,21 +287,30 @@ class _DraggableSheetExtent {
|
||||
|
||||
final double minExtent;
|
||||
final double maxExtent;
|
||||
final bool snap;
|
||||
final List<double> snapSizes;
|
||||
final double initialExtent;
|
||||
final ValueNotifier<double> _currentExtent;
|
||||
double availablePixels;
|
||||
|
||||
// Used to disable snapping until the extent has changed. We do this because
|
||||
// we don't want to snap away from the initial extent.
|
||||
bool hasChanged = false;
|
||||
|
||||
bool get isAtMin => minExtent >= _currentExtent.value;
|
||||
bool get isAtMax => maxExtent <= _currentExtent.value;
|
||||
|
||||
set currentExtent(double value) {
|
||||
assert(value != null);
|
||||
hasChanged = true;
|
||||
_currentExtent.value = value.clamp(minExtent, maxExtent);
|
||||
}
|
||||
double get currentExtent => _currentExtent.value;
|
||||
double get currentPixels => extentToPixels(_currentExtent.value);
|
||||
|
||||
double get additionalMinExtent => isAtMin ? 0.0 : 1.0;
|
||||
double get additionalMaxExtent => isAtMax ? 0.0 : 1.0;
|
||||
List<double> get pixelSnapSizes => snapSizes.map(extentToPixels).toList();
|
||||
|
||||
/// The scroll position gets inputs in terms of pixels, but the extent is
|
||||
/// expected to be expressed as a number between 0..1.
|
||||
@@ -277,7 +318,13 @@ class _DraggableSheetExtent {
|
||||
if (availablePixels == 0) {
|
||||
return;
|
||||
}
|
||||
currentExtent += delta / availablePixels * maxExtent;
|
||||
updateExtent(currentExtent + pixelsToExtent(delta), context);
|
||||
}
|
||||
|
||||
/// Set the extent to the new value. [newExtent] should be a number between
|
||||
/// 0..1.
|
||||
void updateExtent(double newExtent, BuildContext context) {
|
||||
currentExtent = newExtent;
|
||||
DraggableScrollableNotification(
|
||||
minExtent: minExtent,
|
||||
maxExtent: maxExtent,
|
||||
@@ -286,6 +333,14 @@ class _DraggableSheetExtent {
|
||||
context: context,
|
||||
).dispatch(context);
|
||||
}
|
||||
|
||||
double pixelsToExtent(double pixels) {
|
||||
return pixels / availablePixels * maxExtent;
|
||||
}
|
||||
|
||||
double extentToPixels(double extent) {
|
||||
return extent / maxExtent * availablePixels;
|
||||
}
|
||||
}
|
||||
|
||||
class _DraggableScrollableSheetState extends State<DraggableScrollableSheet> {
|
||||
@@ -298,12 +353,38 @@ class _DraggableScrollableSheetState extends State<DraggableScrollableSheet> {
|
||||
_extent = _DraggableSheetExtent(
|
||||
minExtent: widget.minChildSize,
|
||||
maxExtent: widget.maxChildSize,
|
||||
snap: widget.snap,
|
||||
snapSizes: _impliedSnapSizes(),
|
||||
initialExtent: widget.initialChildSize,
|
||||
listener: _setExtent,
|
||||
);
|
||||
_scrollController = _DraggableScrollableSheetScrollController(extent: _extent);
|
||||
}
|
||||
|
||||
List<double> _impliedSnapSizes() {
|
||||
for (int index = 0; index < (widget.snapSizes?.length ?? 0); index += 1) {
|
||||
final double snapSize = widget.snapSizes![index];
|
||||
assert(snapSize >= widget.minChildSize && snapSize <= widget.maxChildSize,
|
||||
'${_snapSizeErrorMessage(index)}\nSnap sizes must be between `minChildSize` and `maxChildSize`. ');
|
||||
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>[
|
||||
widget.minChildSize,
|
||||
widget.maxChildSize,
|
||||
];
|
||||
}
|
||||
return <double>[
|
||||
if (widget.snapSizes!.first != widget.minChildSize) widget.minChildSize,
|
||||
...widget.snapSizes!,
|
||||
if (widget.snapSizes!.last != widget.maxChildSize) widget.maxChildSize,
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
@@ -318,6 +399,7 @@ class _DraggableScrollableSheetState extends State<DraggableScrollableSheet> {
|
||||
curve: Curves.linear,
|
||||
);
|
||||
}
|
||||
_extent.hasChanged = false;
|
||||
_extent._currentExtent.value = _extent.initialExtent;
|
||||
}
|
||||
}
|
||||
@@ -349,6 +431,20 @@ class _DraggableScrollableSheetState extends State<DraggableScrollableSheet> {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _snapSizeErrorMessage(int invalidIndex) {
|
||||
final List<String> snapSizesWithIndicator = widget.snapSizes!.asMap().keys.map(
|
||||
(int index) {
|
||||
final String snapSizeString = widget.snapSizes![index].toString();
|
||||
if (index == invalidIndex) {
|
||||
return '>>> $snapSizeString <<<';
|
||||
}
|
||||
return snapSizeString;
|
||||
},
|
||||
).toList();
|
||||
return "Invalid snapSize '${widget.snapSizes![invalidIndex]}' at index $invalidIndex of:\n"
|
||||
' $snapSizesWithIndicator';
|
||||
}
|
||||
}
|
||||
|
||||
/// A [ScrollController] suitable for use in a [ScrollableWidgetBuilder] created
|
||||
@@ -466,6 +562,15 @@ class _DraggableScrollableSheetScrollPosition
|
||||
}
|
||||
}
|
||||
|
||||
bool get _isAtSnapSize {
|
||||
return extent.snapSizes.any(
|
||||
(double snapSize) {
|
||||
return (extent.currentExtent - snapSize).abs() <= extent.pixelsToExtent(physics.tolerance.distance);
|
||||
},
|
||||
);
|
||||
}
|
||||
bool get _shouldSnap => extent.snap && extent.hasChanged && !_isAtSnapSize;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// Stop the animation before dispose.
|
||||
@@ -475,9 +580,9 @@ class _DraggableScrollableSheetScrollPosition
|
||||
|
||||
@override
|
||||
void goBallistic(double velocity) {
|
||||
if (velocity == 0.0 ||
|
||||
(velocity < 0.0 && listShouldScroll) ||
|
||||
(velocity > 0.0 && extent.isAtMax)) {
|
||||
if ((velocity == 0.0 && !_shouldSnap) ||
|
||||
(velocity < 0.0 && listShouldScroll) ||
|
||||
(velocity > 0.0 && extent.isAtMax)) {
|
||||
super.goBallistic(velocity);
|
||||
return;
|
||||
}
|
||||
@@ -485,13 +590,24 @@ class _DraggableScrollableSheetScrollPosition
|
||||
_dragCancelCallback?.call();
|
||||
_dragCancelCallback = null;
|
||||
|
||||
// The iOS bouncing simulation just isn't right here - once we delegate
|
||||
// the ballistic back to the ScrollView, it will use the right simulation.
|
||||
final Simulation simulation = ClampingScrollSimulation(
|
||||
position: extent.currentExtent,
|
||||
velocity: velocity,
|
||||
tolerance: physics.tolerance,
|
||||
);
|
||||
late final Simulation simulation;
|
||||
if (extent.snap) {
|
||||
// Snap is enabled, simulate snapping instead of clamping scroll.
|
||||
simulation = _SnappingSimulation(
|
||||
position: extent.currentPixels,
|
||||
initialVelocity: velocity,
|
||||
pixelSnapSize: extent.pixelSnapSizes,
|
||||
tolerance: physics.tolerance);
|
||||
} else {
|
||||
// The iOS bouncing simulation just isn't right here - once we delegate
|
||||
// the ballistic back to the ScrollView, it will use the right simulation.
|
||||
simulation = ClampingScrollSimulation(
|
||||
// Run the simulation in terms of pixels, not extent.
|
||||
position: extent.currentPixels,
|
||||
velocity: velocity,
|
||||
tolerance: physics.tolerance,
|
||||
);
|
||||
}
|
||||
|
||||
final AnimationController ballisticController = AnimationController.unbounded(
|
||||
debugLabel: objectRuntimeType(this, '_DraggableScrollableSheetPosition'),
|
||||
@@ -500,10 +616,10 @@ class _DraggableScrollableSheetScrollPosition
|
||||
// Stop the ballistic animation if a new activity starts.
|
||||
// See: [beginActivity].
|
||||
_ballisticCancelCallback = ballisticController.stop;
|
||||
double lastDelta = 0;
|
||||
double lastPosition = extent.currentPixels;
|
||||
void _tick() {
|
||||
final double delta = ballisticController.value - lastDelta;
|
||||
lastDelta = ballisticController.value;
|
||||
final double delta = ballisticController.value - lastPosition;
|
||||
lastPosition = ballisticController.value;
|
||||
extent.addPixelDelta(delta, context.notificationContext!);
|
||||
if ((velocity > 0 && extent.isAtMax) || (velocity < 0 && extent.isAtMin)) {
|
||||
// Make sure we pass along enough velocity to keep scrolling - otherwise
|
||||
@@ -630,3 +746,80 @@ class _InheritedResetNotifier extends InheritedNotifier<_ResetNotifier> {
|
||||
return wasCalled;
|
||||
}
|
||||
}
|
||||
|
||||
class _SnappingSimulation extends Simulation {
|
||||
_SnappingSimulation({
|
||||
required this.position,
|
||||
required double initialVelocity,
|
||||
required List<double> pixelSnapSize,
|
||||
Tolerance tolerance = Tolerance.defaultTolerance,
|
||||
}) : super(tolerance: tolerance) {
|
||||
_pixelSnapSize = _getSnapSize(initialVelocity, pixelSnapSize);
|
||||
// Check the direction of the target instead of the sign of the velocity because
|
||||
// we may snap in the opposite direction of velocity if velocity is very low.
|
||||
if (_pixelSnapSize < position) {
|
||||
velocity = math.min(-minimumSpeed, initialVelocity);
|
||||
} else {
|
||||
velocity = math.max(minimumSpeed, initialVelocity);
|
||||
}
|
||||
}
|
||||
|
||||
final double position;
|
||||
late final double velocity;
|
||||
|
||||
// A minimum speed to snap at. Used to ensure that the snapping animation
|
||||
// does not play too slowly.
|
||||
static const double minimumSpeed = 1600.0;
|
||||
|
||||
late final double _pixelSnapSize;
|
||||
|
||||
@override
|
||||
double dx(double time) {
|
||||
if (isDone(time)) {
|
||||
return 0;
|
||||
}
|
||||
return velocity;
|
||||
}
|
||||
|
||||
@override
|
||||
bool isDone(double time) {
|
||||
return x(time) == _pixelSnapSize;
|
||||
}
|
||||
|
||||
@override
|
||||
double x(double time) {
|
||||
final double newPosition = position + velocity * time;
|
||||
if ((velocity >= 0 && newPosition > _pixelSnapSize) ||
|
||||
(velocity < 0 && newPosition < _pixelSnapSize)) {
|
||||
// We're passed the snap size, return it instead.
|
||||
return _pixelSnapSize;
|
||||
}
|
||||
return newPosition;
|
||||
}
|
||||
|
||||
// Find the two closest snap sizes to the position. If the velocity is
|
||||
// non-zero, select the size in the velocity's direction. Otherwise,
|
||||
// the nearest snap size.
|
||||
double _getSnapSize(double initialVelocity, List<double> pixelSnapSizes) {
|
||||
final int indexOfNextSize = pixelSnapSizes
|
||||
.indexWhere((double size) => size >= position);
|
||||
if (indexOfNextSize == 0) {
|
||||
return pixelSnapSizes.first;
|
||||
}
|
||||
final double nextSize = pixelSnapSizes[indexOfNextSize];
|
||||
final double previousSize = pixelSnapSizes[indexOfNextSize - 1];
|
||||
if (initialVelocity.abs() <= tolerance.velocity) {
|
||||
// If velocity is zero, snap to the nearest snap size with the minimum velocity.
|
||||
if (position - previousSize < nextSize - position) {
|
||||
return previousSize;
|
||||
} else {
|
||||
return nextSize;
|
||||
}
|
||||
}
|
||||
// Snap forward or backward depending on current velocity.
|
||||
if (initialVelocity < 0.0) {
|
||||
return pixelSnapSizes[indexOfNextSize - 1];
|
||||
}
|
||||
return pixelSnapSizes[indexOfNextSize];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,11 @@ void main() {
|
||||
double initialChildSize = .5,
|
||||
double maxChildSize = 1.0,
|
||||
double minChildSize = .25,
|
||||
bool snap = false,
|
||||
List<double>? snapSizes,
|
||||
double? itemExtent,
|
||||
Key? containerKey,
|
||||
Key? stackKey,
|
||||
NotificationListenerCallback<ScrollNotification>? onScrollNotification,
|
||||
}) {
|
||||
return Directionality(
|
||||
@@ -21,30 +24,35 @@ void main() {
|
||||
child: MediaQuery(
|
||||
data: const MediaQueryData(),
|
||||
child: Stack(
|
||||
key: stackKey,
|
||||
children: <Widget>[
|
||||
TextButton(
|
||||
onPressed: onButtonPressed,
|
||||
child: const Text('TapHere'),
|
||||
),
|
||||
DraggableScrollableSheet(
|
||||
maxChildSize: maxChildSize,
|
||||
minChildSize: minChildSize,
|
||||
initialChildSize: initialChildSize,
|
||||
builder: (BuildContext context, ScrollController scrollController) {
|
||||
return NotificationListener<ScrollNotification>(
|
||||
onNotification: onScrollNotification,
|
||||
child: Container(
|
||||
key: containerKey,
|
||||
color: const Color(0xFFABCDEF),
|
||||
child: ListView.builder(
|
||||
controller: scrollController,
|
||||
itemExtent: itemExtent,
|
||||
itemCount: itemCount,
|
||||
itemBuilder: (BuildContext context, int index) => Text('Item $index'),
|
||||
DraggableScrollableActuator(
|
||||
child: DraggableScrollableSheet(
|
||||
maxChildSize: maxChildSize,
|
||||
minChildSize: minChildSize,
|
||||
initialChildSize: initialChildSize,
|
||||
snap: snap,
|
||||
snapSizes: snapSizes,
|
||||
builder: (BuildContext context, ScrollController scrollController) {
|
||||
return NotificationListener<ScrollNotification>(
|
||||
onNotification: onScrollNotification,
|
||||
child: Container(
|
||||
key: containerKey,
|
||||
color: const Color(0xFFABCDEF),
|
||||
child: ListView.builder(
|
||||
controller: scrollController,
|
||||
itemExtent: itemExtent,
|
||||
itemCount: itemCount,
|
||||
itemBuilder: (BuildContext context, int index) => Text('Item $index'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -84,6 +92,27 @@ void main() {
|
||||
expect(tester.getRect(find.byKey(key)), const Rect.fromLTRB(0.0, 325.0, 800.0, 600.0));
|
||||
});
|
||||
|
||||
testWidgets('Invalid snap targets throw assertion errors.', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(_boilerplate(
|
||||
null,
|
||||
maxChildSize: .8,
|
||||
snapSizes: <double>[.9],
|
||||
));
|
||||
expect(tester.takeException(), isAssertionError);
|
||||
|
||||
await tester.pumpWidget(_boilerplate(
|
||||
null,
|
||||
snapSizes: <double>[.1],
|
||||
));
|
||||
expect(tester.takeException(), isAssertionError);
|
||||
|
||||
await tester.pumpWidget(_boilerplate(
|
||||
null,
|
||||
snapSizes: <double>[.6, .6, .9],
|
||||
));
|
||||
expect(tester.takeException(), isAssertionError);
|
||||
});
|
||||
|
||||
for (final TargetPlatform platform in TargetPlatform.values) {
|
||||
group('$platform Scroll Physics', () {
|
||||
debugDefaultTargetPlatformOverride = platform;
|
||||
@@ -301,6 +330,190 @@ void main() {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
|
||||
testWidgets('Does not snap away from initial child on build', (WidgetTester tester) async {
|
||||
const Key containerKey = ValueKey<String>('container');
|
||||
const Key stackKey = ValueKey<String>('stack');
|
||||
await tester.pumpWidget(_boilerplate(null,
|
||||
snap: true,
|
||||
initialChildSize: .7,
|
||||
containerKey: containerKey,
|
||||
stackKey: stackKey,
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
final double screenHeight = tester.getSize(find.byKey(stackKey)).height;
|
||||
|
||||
// The sheet should not have snapped.
|
||||
expect(
|
||||
tester.getSize(find.byKey(containerKey)).height / screenHeight,
|
||||
closeTo(.7, precisionErrorTolerance,
|
||||
));
|
||||
}, variant: TargetPlatformVariant.all());
|
||||
|
||||
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;
|
||||
|
||||
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();
|
||||
|
||||
// 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());
|
||||
|
||||
testWidgets('Zero velocity drag snaps to nearest snap target', (WidgetTester tester) async {
|
||||
const Key stackKey = ValueKey<String>('stack');
|
||||
const Key containerKey = ValueKey<String>('container');
|
||||
await tester.pumpWidget(_boilerplate(null,
|
||||
snap: true,
|
||||
stackKey: stackKey,
|
||||
containerKey: containerKey,
|
||||
snapSizes: <double>[.25, .5, .75, 1.0],
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
final double screenHeight = tester.getSize(find.byKey(stackKey)).height;
|
||||
|
||||
// We are dragging up, but we'll snap down because we're closer to .75 than 1.
|
||||
await tester.drag(find.text('Item 1'), Offset(0, -.35 * screenHeight));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
tester.getSize(find.byKey(containerKey)).height / screenHeight,
|
||||
closeTo(.75, precisionErrorTolerance),
|
||||
);
|
||||
|
||||
// Drag up and snap up.
|
||||
await tester.drag(find.text('Item 1'), Offset(0, -.2 * screenHeight));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
tester.getSize(find.byKey(containerKey)).height / screenHeight,
|
||||
closeTo(1.0, precisionErrorTolerance),
|
||||
);
|
||||
|
||||
// Drag down and snap up.
|
||||
await tester.drag(find.text('Item 1'), Offset(0, .1 * screenHeight));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
tester.getSize(find.byKey(containerKey)).height / screenHeight,
|
||||
closeTo(1.0, precisionErrorTolerance),
|
||||
);
|
||||
|
||||
// Drag down and snap down.
|
||||
await tester.drag(find.text('Item 1'), Offset(0, .45 * screenHeight));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
tester.getSize(find.byKey(containerKey)).height / screenHeight,
|
||||
closeTo(.5, precisionErrorTolerance),
|
||||
);
|
||||
|
||||
// Fling up with negligible velocity and snap down.
|
||||
await tester.fling(find.text('Item 1'), Offset(0, .1 * screenHeight), 1);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
tester.getSize(find.byKey(containerKey)).height / screenHeight,
|
||||
closeTo(.5, precisionErrorTolerance),
|
||||
);
|
||||
}, variant: TargetPlatformVariant.all());
|
||||
|
||||
for (final List<double>? snapSizes in <List<double>?>[null, <double>[]]) {
|
||||
testWidgets('Setting snapSizes to $snapSizes resolves to min and max', (WidgetTester tester) async {
|
||||
const Key stackKey = ValueKey<String>('stack');
|
||||
const Key containerKey = ValueKey<String>('container');
|
||||
await tester.pumpWidget(_boilerplate(null,
|
||||
snap: true,
|
||||
stackKey: stackKey,
|
||||
containerKey: containerKey,
|
||||
snapSizes: snapSizes,
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
final double screenHeight = tester.getSize(find.byKey(stackKey)).height;
|
||||
|
||||
await tester.drag(find.text('Item 1'), Offset(0, -.4 * screenHeight));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
tester.getSize(find.byKey(containerKey)).height / screenHeight,
|
||||
closeTo(1.0, precisionErrorTolerance,
|
||||
));
|
||||
|
||||
await tester.drag(find.text('Item 1'), Offset(0, .7 * screenHeight));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
tester.getSize(find.byKey(containerKey)).height / screenHeight,
|
||||
closeTo(.25, precisionErrorTolerance),
|
||||
);
|
||||
}, variant: TargetPlatformVariant.all());
|
||||
}
|
||||
|
||||
testWidgets('Min and max are implicitly added to snapSizes.', (WidgetTester tester) async {
|
||||
const Key stackKey = ValueKey<String>('stack');
|
||||
const Key containerKey = ValueKey<String>('container');
|
||||
await tester.pumpWidget(_boilerplate(null,
|
||||
snap: true,
|
||||
stackKey: stackKey,
|
||||
containerKey: containerKey,
|
||||
snapSizes: <double>[.5],
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
final double screenHeight = tester.getSize(find.byKey(stackKey)).height;
|
||||
|
||||
await tester.drag(find.text('Item 1'), Offset(0, -.4 * screenHeight));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
tester.getSize(find.byKey(containerKey)).height / screenHeight,
|
||||
closeTo(1.0, precisionErrorTolerance),
|
||||
);
|
||||
|
||||
await tester.drag(find.text('Item 1'), Offset(0, .7 * screenHeight));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
tester.getSize(find.byKey(containerKey)).height / screenHeight,
|
||||
closeTo(.25, precisionErrorTolerance),
|
||||
);
|
||||
}, variant: TargetPlatformVariant.all());
|
||||
|
||||
testWidgets('Fling snaps in direction of momentum', (WidgetTester tester) async {
|
||||
const Key stackKey = ValueKey<String>('stack');
|
||||
const Key containerKey = ValueKey<String>('container');
|
||||
await tester.pumpWidget(_boilerplate(null,
|
||||
snap: true,
|
||||
stackKey: stackKey,
|
||||
containerKey: containerKey,
|
||||
snapSizes: <double>[.5, .75],
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
final double screenHeight = tester.getSize(find.byKey(stackKey)).height;
|
||||
|
||||
await tester.fling(find.text('Item 1'), Offset(0, -.1 * screenHeight), 1000);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
tester.getSize(find.byKey(containerKey)).height / screenHeight,
|
||||
closeTo(.75, precisionErrorTolerance),
|
||||
);
|
||||
|
||||
await tester.fling(find.text('Item 1'), Offset(0, .3 * screenHeight), 1000);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
tester.getSize(find.byKey(containerKey)).height / screenHeight,
|
||||
closeTo(.25, precisionErrorTolerance),
|
||||
);
|
||||
|
||||
}, variant: TargetPlatformVariant.all());
|
||||
|
||||
testWidgets('ScrollNotification correctly dispatched when flung without covering its container', (WidgetTester tester) async {
|
||||
final List<Type> notificationTypes = <Type>[];
|
||||
await tester.pumpWidget(_boilerplate(
|
||||
|
||||
Reference in New Issue
Block a user