diff --git a/packages/cupertino_ui/lib/src/sheet.dart b/packages/cupertino_ui/lib/src/sheet.dart index 0044e001dca..b89a24bd0b1 100644 --- a/packages/cupertino_ui/lib/src/sheet.dart +++ b/packages/cupertino_ui/lib/src/sheet.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'package:flutter/gestures.dart'; +import 'package:flutter/physics.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; @@ -36,6 +37,39 @@ const double _kTopGapRatio = 0.08; // running iOS 18.0 simulators. const double _kStretchedTopGapRatio = 0.072; +// Spring description used for the bounce-back animation when enableDrag == false +// or when snapping back to position. Measured on iOS 26 +final SpringDescription _sheetSpring = SpringDescription.withDurationAndBounce( + duration: const Duration(milliseconds: 430), +); + +// The standard elasticity coefficient used in Apple's Rubber-Banding formula +// (WWDC 2018: "Designing Fluid Interfaces") +const double _kAppleRubberBandElasticity = 0.55; + +// The asymptotic maximum stretch distance in logical pixels when pulling against resistance. +const double _kMaxRubberBandDistance = 180.0; + +// Calculates the instantaneous friction factor for overscroll resistance using +// the exact derivative of Apple's Rubber-Banding equation: +// +// y(x) = (x * c * d) / (d + c * x) +// dy/dx = c * (1.0 - y / d)^2 +// +// where `c` is [_kAppleRubberBandElasticity], `d` is [_kMaxRubberBandDistance], +// and `y` is the current physical displacement in logical pixels. +// +// https://gist.github.com/originell/6961057 +double _computeRubberBandFriction({ + required double currentDisplacement, + double dimension = _kMaxRubberBandDistance, + double constant = _kAppleRubberBandElasticity, +}) { + final double stretchProgress = (currentDisplacement / dimension).clamp(0.0, 1.0); + final double remainingProgress = 1.0 - stretchProgress; + return constant * remainingProgress * remainingProgress; +} + // Tween for animating a Cupertino sheet onto the screen. // // Begins fully offscreen below the screen and ends onscreen with a small gap at @@ -776,9 +810,14 @@ class CupertinoSheetRoute extends PageRoute with _CupertinoSheetRouteTrans data: CupertinoUserInterfaceLevelData.elevated, child: _CupertinoSheetScope( child: _CupertinoDraggableScrollableSheet( - enabledCallback: () => enableDrag, - onStartPopGesture: () => - _CupertinoSheetRouteTransitionMixin._startPopGesture(this, topGap), + enabledCallback: () => !(controller?.isAnimating ?? false), + enableDrag: enableDrag, + onStartPopGesture: () => _CupertinoSheetRouteTransitionMixin._startPopGesture( + this, + topGap, + enableDrag: enableDrag, + ), + topGap: topGap, builder: _sheetWithDragHandle, ), ), @@ -878,14 +917,16 @@ mixin _CupertinoSheetRouteTransitionMixin on PageRoute { static _CupertinoDragGestureController _startPopGesture( ModalRoute route, - double topGap, - ) { + double topGap, { + required bool enableDrag, + }) { return _CupertinoDragGestureController( topGap: topGap, navigator: route.navigator!, getIsCurrent: () => route.isCurrent, getIsActive: () => route.isActive, popDragController: route.controller!, // protected access + enableDrag: enableDrag, ); } @@ -906,8 +947,8 @@ mixin _CupertinoSheetRouteTransitionMixin on PageRoute { linearTransition: linearTransition, topGap: topGap, child: _CupertinoDragGestureDetector( - enabledCallback: () => enableDrag, - onStartPopGesture: () => _startPopGesture(route, topGap), + enabledCallback: () => !(route.controller?.isAnimating ?? false), + onStartPopGesture: () => _startPopGesture(route, topGap, enableDrag: enableDrag), child: child, ), ); @@ -1023,9 +1064,9 @@ class _CupertinoDragGestureDetectorState extends State<_CupertinoDragGestureD } final double delta = sheetHeight > 0 ? details.primaryDelta! / sheetHeight : 0.0; _dragGestureController!.dragUpdate( - // Divide by size of the sheet. delta, _stretchDragController!.controller, + sheetHeight: sheetHeight, ); } @@ -1079,6 +1120,7 @@ class _CupertinoDragGestureController { required this.getIsActive, required this.getIsCurrent, required this.topGap, + required this.enableDrag, }) { navigator.didStartUserGesture(); } @@ -1088,10 +1130,11 @@ class _CupertinoDragGestureController { final ValueGetter getIsActive; final ValueGetter getIsCurrent; final double topGap; + final bool enableDrag; /// The drag gesture has changed by [delta]. The total range of the drag /// should be 0.0 to 1.0. - void dragUpdate(double delta, AnimationController? upController) { + void dragUpdate(double delta, AnimationController? upController, {required double sheetHeight}) { if (upController != null && popDragController.value == 1.0 && (upController.value > 0 || delta < 0)) { @@ -1100,7 +1143,17 @@ class _CupertinoDragGestureController { const double stretchDistance = _kTopGapRatio - _kStretchedTopGapRatio; upController.value -= delta / stretchDistance; } else { - popDragController.value -= delta; + if (!enableDrag) { + // Applies the exact derivative of Apple's Rubber-Banding equation. + final double currentDisplacement = + (1.0 - popDragController.value).clamp(0.0, 1.0) * sheetHeight; + final double friction = _computeRubberBandFriction( + currentDisplacement: currentDisplacement, + ); + popDragController.value -= delta * friction; + } else { + popDragController.value -= delta; + } } } @@ -1123,6 +1176,33 @@ class _CupertinoDragGestureController { return; } + if (!enableDrag) { + // When dragging is disabled, spring back to the open position using a physical spring simulation. + final Simulation simulation = SpringSimulation( + _sheetSpring, + popDragController.value, + 1.0, + -velocity, + ); + popDragController.animateWith(simulation); + + if (popDragController.isAnimating) { + void animationStatusCallback(AnimationStatus status) { + if (status == AnimationStatus.completed || status == AnimationStatus.dismissed) { + if (navigator.mounted) { + navigator.didStopUserGesture(); + } + popDragController.removeStatusListener(animationStatusCallback); + } + } + + popDragController.addStatusListener(animationStatusCallback); + } else { + navigator.didStopUserGesture(); + } + return; + } + // Fling in the appropriate direction. // // This curve has been determined through rigorously eyeballing native iOS @@ -1195,12 +1275,14 @@ class _CupertinoSheetScrollController extends ScrollController { required this.onDragUpdate, required this.onDragEnd, required this.sheetIsDraggedDown, + required this.hasActiveDragController, }); final _DragStartCallback onDragStart; final _DragEndCallback onDragUpdate; final _DragUpdateCallback onDragEnd; final _GetSheetDragged sheetIsDraggedDown; + final ValueGetter hasActiveDragController; @override _CupertinoSheetScrollPosition createScrollPosition( @@ -1216,6 +1298,7 @@ class _CupertinoSheetScrollController extends ScrollController { onDragUpdate: onDragUpdate, onDragEnd: onDragEnd, sheetIsDraggedDown: sheetIsDraggedDown, + hasActiveDragController: hasActiveDragController, ); } } @@ -1241,6 +1324,7 @@ class _CupertinoSheetScrollPosition extends ScrollPositionWithSingleContext { required this.onDragUpdate, required this.onDragEnd, required this.sheetIsDraggedDown, + required this.hasActiveDragController, }); VoidCallback? _dragCancelCallback; @@ -1252,6 +1336,7 @@ class _CupertinoSheetScrollPosition extends ScrollPositionWithSingleContext { final _DragUpdateCallback onDragEnd; final _GetSheetDragged sheetIsDraggedDown; + final ValueGetter hasActiveDragController; @override void absorb(ScrollPosition other) { @@ -1289,7 +1374,8 @@ class _CupertinoSheetScrollPosition extends ScrollPositionWithSingleContext { @override void applyUserOffset(double delta) { onDragStart(); - if (!listShouldScroll && (delta > 0 || sheetIsDraggedDown())) { + final bool canDragSheet = hasActiveDragController() || sheetIsDraggedDown(); + if (canDragSheet && !listShouldScroll && (delta > 0 || sheetIsDraggedDown())) { onDragUpdate(delta); } else { super.applyUserOffset(delta); @@ -1309,9 +1395,15 @@ class _CupertinoSheetScrollPosition extends ScrollPositionWithSingleContext { _dragCancelCallback?.call(); _dragCancelCallback = null; if (velocity < 0.0 && !listShouldScroll) { - onDragEnd(velocity); - super.goBallistic(0); - return; + if (sheetIsDraggedDown()) { + onDragEnd(velocity); + super.goBallistic(0); + return; + } else { + onDragEnd(0.0); + super.goBallistic(velocity); + return; + } } onDragEnd(0.0); super.goBallistic(velocity); @@ -1329,14 +1421,20 @@ class _CupertinoDraggableScrollableSheet extends StatefulWidget { const _CupertinoDraggableScrollableSheet({ super.key, required this.enabledCallback, + required this.enableDrag, required this.onStartPopGesture, required this.builder, + this.topGap = _kTopGapRatio, }); final ScrollableWidgetBuilder builder; final ValueGetter enabledCallback; + final bool enableDrag; + + final double topGap; + final ValueGetter<_CupertinoDragGestureController> onStartPopGesture; @override @@ -1357,6 +1455,7 @@ class _CupertinoDraggableScrollableSheetState onDragUpdate: _dragUpdate, onDragEnd: _handleDragEnd, sheetIsDraggedDown: () => _dragGestureController?.isDragged() ?? false, + hasActiveDragController: () => _dragGestureController != null, ); } @@ -1377,15 +1476,20 @@ class _CupertinoDraggableScrollableSheetState void _dragStart() { assert(mounted); + if (!widget.enabledCallback()) { + return; + } _dragGestureController ??= widget.onStartPopGesture(); } void _dragUpdate(double delta) { assert(mounted); if (_dragGestureController != null) { + final double sheetHeight = context.size?.height ?? 0.0; _dragGestureController!.dragUpdate( - delta / (context.size!.height - (context.size!.height * _kTopGapRatio)), + sheetHeight > 0 ? delta / sheetHeight : 0.0, null, + sheetHeight: sheetHeight, ); } } @@ -1393,7 +1497,8 @@ class _CupertinoDraggableScrollableSheetState void _handleDragEnd(double velocity) { assert(mounted); if (_dragGestureController != null) { - _dragGestureController!.dragEnd(-velocity / context.size!.height, null); + final double sheetHeight = context.size?.height ?? 0.0; + _dragGestureController!.dragEnd(sheetHeight > 0 ? -velocity / sheetHeight : 0.0, null); _dragGestureController = null; } } diff --git a/packages/cupertino_ui/test/sheet_test.dart b/packages/cupertino_ui/test/sheet_test.dart index 7e9f91570fb..b5cda608d21 100644 --- a/packages/cupertino_ui/test/sheet_test.dart +++ b/packages/cupertino_ui/test/sheet_test.dart @@ -1395,74 +1395,72 @@ void main() { expect(rootNavigatorPopped, false); }); - testWidgets('dragging does not move the sheet when enableDrag is false', ( - WidgetTester tester, - ) async { - Widget nonDragGestureApp(GlobalKey homeScaffoldKey, GlobalKey sheetScaffoldKey) { - return CupertinoApp( - home: CupertinoPageScaffold( - key: homeScaffoldKey, - child: Center( - child: Column( - children: [ - const Text('Page 1'), - CupertinoButton( - onPressed: () { - showCupertinoSheet( - context: homeScaffoldKey.currentContext!, - pageBuilder: (BuildContext context) { - return CupertinoPageScaffold( - key: sheetScaffoldKey, - child: const Center(child: Text('Page 2')), - ); - }, - enableDrag: false, - ); - }, - child: const Text('Push Page 2'), - ), - ], + testWidgets( + 'dragging with enableDrag: false rubber-bands and springs back without dismissing', + (WidgetTester tester) async { + Widget nonDragGestureApp(GlobalKey homeScaffoldKey, GlobalKey sheetScaffoldKey) { + return CupertinoApp( + home: CupertinoPageScaffold( + key: homeScaffoldKey, + child: Center( + child: Column( + children: [ + const Text('Page 1'), + CupertinoButton( + onPressed: () { + showCupertinoSheet( + context: homeScaffoldKey.currentContext!, + pageBuilder: (BuildContext context) { + return CupertinoPageScaffold( + key: sheetScaffoldKey, + child: const Center(child: Text('Page 2')), + ); + }, + enableDrag: false, + ); + }, + child: const Text('Push Page 2'), + ), + ], + ), ), ), - ), - ); - } + ); + } - final GlobalKey homeKey = GlobalKey(); - final GlobalKey sheetKey = GlobalKey(); + final GlobalKey homeKey = GlobalKey(); + final GlobalKey sheetKey = GlobalKey(); - await tester.pumpWidget(nonDragGestureApp(homeKey, sheetKey)); + await tester.pumpWidget(nonDragGestureApp(homeKey, sheetKey)); - await tester.tap(find.text('Push Page 2')); - await tester.pumpAndSettle(); + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); - expect(find.text('Page 2'), findsOneWidget); + expect(find.text('Page 2'), findsOneWidget); - var box = tester.renderObject(find.byKey(sheetKey)) as RenderBox; - final double initialPosition = box.localToGlobal(Offset.zero).dy; + var box = tester.renderObject(find.byKey(sheetKey)) as RenderBox; + final double initialPosition = box.localToGlobal(Offset.zero).dy; - final TestGesture gesture = await tester.startGesture(const Offset(100, 200)); - // Partial drag down - await gesture.moveBy(const Offset(0, 200)); - await tester.pump(); + final TestGesture gesture = await tester.startGesture(const Offset(100, 200)); + await gesture.moveBy(const Offset(0, 200)); + await tester.pump(); - // Release gesture. Sheet should not move. - box = tester.renderObject(find.byKey(sheetKey)) as RenderBox; - final double middlePosition = box.localToGlobal(Offset.zero).dy; + box = tester.renderObject(find.byKey(sheetKey)) as RenderBox; + final double middlePosition = box.localToGlobal(Offset.zero).dy; - expect(middlePosition, equals(initialPosition)); + expect(middlePosition, greaterThan(initialPosition)); - await gesture.up(); - await tester.pumpAndSettle(); + await gesture.up(); + await tester.pumpAndSettle(); - expect(find.text('Page 2'), findsOneWidget); + expect(find.text('Page 2'), findsOneWidget); - box = tester.renderObject(find.byKey(sheetKey)) as RenderBox; - final double finalPosition = box.localToGlobal(Offset.zero).dy; + box = tester.renderObject(find.byKey(sheetKey)) as RenderBox; + final double finalPosition = box.localToGlobal(Offset.zero).dy; - expect(finalPosition, equals(middlePosition)); - expect(finalPosition, equals(initialPosition)); - }); + expect(finalPosition, closeTo(initialPosition, 0.1)); + }, + ); testWidgets('partial upward drag stretches and returns without popping', ( WidgetTester tester, @@ -1495,6 +1493,57 @@ void main() { final double finalPosition = box.localToGlobal(Offset.zero).dy; expect(finalPosition, initialPosition); }); + + testWidgets('Sheet ignores gestures mid-dismissal and finishes closing', ( + WidgetTester tester, + ) async { + final GlobalKey homeKey = GlobalKey(); + final GlobalKey sheetKey = GlobalKey(); + + await tester.pumpWidget(dragGestureApp(homeKey, sheetKey)); + + // Open sheet + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + + final Finder sheetFinder = find.byKey(sheetKey); + final Size sheetSize = tester.getSize(sheetFinder); + final double sheetHeight = sheetSize.height; + + final double dragDistance = sheetHeight / 1.8; + + final Offset sheetTopLeft = tester.getTopLeft(sheetFinder); + final startPoint = Offset(sheetTopLeft.dx + (sheetSize.width / 1.8), sheetTopLeft.dy + 20.0); + + // Drag sheet down + final TestGesture gesture = await tester.startGesture(startPoint); + await gesture.moveBy(Offset(0, dragDistance)); + await tester.pump(); + + // Release sheet + await gesture.up(); + await tester.pump(); + + await tester.pump(const Duration(milliseconds: 50)); + + final box = tester.renderObject(sheetFinder) as RenderBox; + final double currentY = box.localToGlobal(Offset.zero).dy; + + // Try to intercept the gesture by dragging up + final TestGesture interceptGesture = await tester.startGesture( + Offset(startPoint.dx, currentY + 100), + ); + await tester.pump(); + + // Drag up + await interceptGesture.moveBy(const Offset(0, -50)); + await interceptGesture.up(); + + await tester.pumpAndSettle(); + + expect(find.text('Page 2'), findsNothing); + expect(find.text('Page 1'), findsOneWidget); + }); }); group('draggable scrollable CupertinoSheetRoute', () {