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

Add Snapping Behavior to DraggableScrollableSheet (#84394)

parent daa051c3
......@@ -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,7 +580,7 @@ class _DraggableScrollableSheetScrollPosition
@override
void goBallistic(double velocity) {
if (velocity == 0.0 ||
if ((velocity == 0.0 && !_shouldSnap) ||
(velocity < 0.0 && listShouldScroll) ||
(velocity > 0.0 && extent.isAtMax)) {
super.goBallistic(velocity);
......@@ -485,13 +590,24 @@ class _DraggableScrollableSheetScrollPosition
_dragCancelCallback?.call();
_dragCancelCallback = null;
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.
final Simulation simulation = ClampingScrollSimulation(
position: extent.currentExtent,
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,15 +24,19 @@ void main() {
child: MediaQuery(
data: const MediaQueryData(),
child: Stack(
key: stackKey,
children: <Widget>[
TextButton(
onPressed: onButtonPressed,
child: const Text('TapHere'),
),
DraggableScrollableSheet(
DraggableScrollableActuator(
child: DraggableScrollableSheet(
maxChildSize: maxChildSize,
minChildSize: minChildSize,
initialChildSize: initialChildSize,
snap: snap,
snapSizes: snapSizes,
builder: (BuildContext context, ScrollController scrollController) {
return NotificationListener<ScrollNotification>(
onNotification: onScrollNotification,
......@@ -46,6 +53,7 @@ void main() {
);
},
),
),
],
),
),
......@@ -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(
......
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