diff --git a/packages/flutter/lib/src/widgets/draggable_scrollable_sheet.dart b/packages/flutter/lib/src/widgets/draggable_scrollable_sheet.dart index 6101f8d110507f1ff53200c966ed333d1e4a31a7..84fe3d988b8a0d4baa8375f4ecb4b297ca7a7329 100644 --- a/packages/flutter/lib/src/widgets/draggable_scrollable_sheet.dart +++ b/packages/flutter/lib/src/widgets/draggable_scrollable_sheet.dart @@ -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]; + } +} diff --git a/packages/flutter/test/widgets/draggable_scrollable_sheet_test.dart b/packages/flutter/test/widgets/draggable_scrollable_sheet_test.dart index 27ed9a4c610a01f98cf5e99b3fa6f187d0ed5958..8fca9060684b5a7f7e579938edb81f65ba57a5f8 100644 --- a/packages/flutter/test/widgets/draggable_scrollable_sheet_test.dart +++ b/packages/flutter/test/widgets/draggable_scrollable_sheet_test.dart @@ -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(