Repository: fujidaiti/smooth_sheets (dart)

Issue:
Add a way to customize sensitivity of swipe-to-dismiss action on modal sheet
### Discussed in https://github.com/fujidaiti/smooth_sheets/discussions/158

<div type='discussions-op-text'>

<sup>Originally posted by **mark8044** June  5, 2024</sup>
Right now, compared to most other Sheet packages (including Wolt) this packages pull down sensitivity seems too sensitive for my taste.  Meaning its too easy to pull it down to dismiss.  It becomes a problem when using a scrollableSheet.   Is there an easy way to modify this sensitivity?

Thank you much</div>

Pull request description:
## Description

Thank u for the great lib!

The PR adds `SwipeDismissConfig` class that can be passed to `CupertinoModalSheetPage`, `CupertinoModalSheetRoute`, `ModalSheetPage` and `ModalSheetRoute` classes to configure min swipe velocity and distance for a swipe to result in a dismissal.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a01c8db8..dc896a39 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
 # Changelog
 
+## 0.10.0 Aug 31, 2024
+
+- Add `SwipeDismissSensitivity`, a way to customize sensitivity of swipe-to-dismiss action on modal sheet (#222)
+
 ## 0.9.3 Aug 19, 2024
 
 - Fix: Press-and-hold gesture in PageView doesn't stop momentum scrolling (#219)
diff --git a/README.md b/README.md
index ad2eeb2c..cdd1c898 100644
--- a/README.md
+++ b/README.md
@@ -204,6 +204,7 @@ Furthermore, [the modal sheets in the style of iOS 15](https://medium.com/surf-d
 
 See also:
 
+- [SwipeDismissSensitivity](https://pub.dev/documentation/smooth_sheets/latest/smooth_sheets/SwipeDismissSensitivity-class.html), which can be used to tweak the sensitivity of the swipe-to-dismiss action.
 - [declarative_modal_sheet.dart](https://github.com/fujidaiti/smooth_sheets/blob/main/example/lib/tutorial/declarative_modal_sheet.dart), a tutorial of integration with declarative navigation using [go_router](https://pub.dev/packages/go_router) package.
 - [imperative_modal_sheet.dart](https://github.com/fujidaiti/smooth_sheets/blob/main/example/lib/tutorial/imperative_modal_sheet.dart), a tutorial of integration with imperative Navigator API.
 - [cupertino_modal_sheet.dart](https://github.com/fujidaiti/smooth_sheets/blob/main/example/lib/tutorial/cupertino_modal_sheet.dart), a tutorial of iOS style modal sheets.
diff --git a/example/lib/tutorial/cupertino_modal_sheet.dart b/example/lib/tutorial/cupertino_modal_sheet.dart
index c7e3dad6..9786fcbe 100644
--- a/example/lib/tutorial/cupertino_modal_sheet.dart
+++ b/example/lib/tutorial/cupertino_modal_sheet.dart
@@ -48,7 +48,13 @@ void _showModalSheet(BuildContext context, {required bool isFullScreen}) {
   // Use `CupertinoModalSheetRoute` to show an ios 15 style modal sheet.
   // For declarative navigation (Navigator 2.0), use `CupertinoModalSheetPage` instead.
   final modalRoute = CupertinoModalSheetRoute(
-    swipeDismissible: true, // Enable the swipe-to-dismiss behavior.
+    // Enable the swipe-to-dismiss behavior.
+    swipeDismissible: true,
+    // Use `SwipeDismissSensitivity` to tweak the sensitivity of the swipe-to-dismiss behavior.
+    swipeDismissSensitivity: const SwipeDismissSensitivity(
+      minFlingVelocityRatio: 2.0,
+      minDragDistance: 300.0,
+    ),
     builder: (context) => switch (isFullScreen) {
       true => const _FullScreenSheet(),
       false => const _HalfScreenSheet(),
diff --git a/example/lib/tutorial/declarative_modal_sheet.dart b/example/lib/tutorial/declarative_modal_sheet.dart
index 5f734beb..99e94d18 100644
--- a/example/lib/tutorial/declarative_modal_sheet.dart
+++ b/example/lib/tutorial/declarative_modal_sheet.dart
@@ -26,6 +26,11 @@ final _router = GoRouter(
               key: state.pageKey,
               // Enable the swipe-to-dismiss behavior.
               swipeDismissible: true,
+              // Use `SwipeDismissSensitivity` to tweak the sensitivity of the swipe-to-dismiss behavior.
+              swipeDismissSensitivity: const SwipeDismissSensitivity(
+                minFlingVelocityRatio: 2.0,
+                minDragDistance: 200.0,
+              ),
               child: const _ExampleSheet(),
             );
           },
diff --git a/example/lib/tutorial/imperative_modal_sheet.dart b/example/lib/tutorial/imperative_modal_sheet.dart
index 1c6ff82e..546491ef 100644
--- a/example/lib/tutorial/imperative_modal_sheet.dart
+++ b/example/lib/tutorial/imperative_modal_sheet.dart
@@ -38,6 +38,11 @@ void _showModalSheet(BuildContext context) {
   final modalRoute = ModalSheetRoute(
     // Enable the swipe-to-dismiss behavior.
     swipeDismissible: true,
+    // Use `SwipeDismissSensitivity` to tweak the sensitivity of the swipe-to-dismiss behavior.
+    swipeDismissSensitivity: const SwipeDismissSensitivity(
+      minFlingVelocityRatio: 2.0,
+      minDragDistance: 200.0,
+    ),
     builder: (context) => const _ExampleSheet(),
   );
 
diff --git a/lib/src/modal/cupertino.dart b/lib/src/modal/cupertino.dart
index cc41bd12..c47828a5 100644
--- a/lib/src/modal/cupertino.dart
+++ b/lib/src/modal/cupertino.dart
@@ -7,6 +7,7 @@ import 'package:flutter/rendering.dart';
 import '../foundation/sheet_controller.dart';
 import '../internal/double_utils.dart';
 import 'modal_sheet.dart';
+import 'swipe_dismiss_sensitivity.dart';
 
 const _minimizedViewportScale = 0.92;
 const _cupertinoBarrierColor = Color(0x18000000);
@@ -422,6 +423,7 @@ class CupertinoModalSheetPage<T> extends Page<T> {
     this.barrierColor = _cupertinoBarrierColor,
     this.transitionDuration = _cupertinoTransitionDuration,
     this.transitionCurve = _cupertinoTransitionCurve,
+    this.swipeDismissSensitivity = const SwipeDismissSensitivity(),
     required this.child,
   });
 
@@ -445,6 +447,8 @@ class CupertinoModalSheetPage<T> extends Page<T> {
 
   final Curve transitionCurve;
 
+  final SwipeDismissSensitivity swipeDismissSensitivity;
+
   @override
   Route<T> createRoute(BuildContext context) {
     return _PageBasedCupertinoModalSheetRoute(
@@ -485,6 +489,10 @@ class _PageBasedCupertinoModalSheetRoute<T>
   @override
   Duration get transitionDuration => _page.transitionDuration;
 
+  @override
+  SwipeDismissSensitivity get swipeDismissSensitivity =>
+      _page.swipeDismissSensitivity;
+
   @override
   String get debugLabel => '${super.debugLabel}(${_page.name})';
 
@@ -504,6 +512,7 @@ class CupertinoModalSheetRoute<T> extends _BaseCupertinoModalSheetRoute<T> {
     this.barrierColor = _cupertinoBarrierColor,
     this.transitionDuration = _cupertinoTransitionDuration,
     this.transitionCurve = _cupertinoTransitionCurve,
+    this.swipeDismissSensitivity = const SwipeDismissSensitivity(),
   });
 
   final WidgetBuilder builder;
@@ -529,6 +538,9 @@ class CupertinoModalSheetRoute<T> extends _BaseCupertinoModalSheetRoute<T> {
   @override
   final Curve transitionCurve;
 
+  @override
+  final SwipeDismissSensitivity swipeDismissSensitivity;
+
   @override
   Widget buildContent(BuildContext context) {
     return builder(context);
diff --git a/lib/src/modal/modal.dart b/lib/src/modal/modal.dart
index 8c4aad49..d0b80088 100644
--- a/lib/src/modal/modal.dart
+++ b/lib/src/modal/modal.dart
@@ -6,3 +6,4 @@ export 'cupertino.dart'
         CupertinoStackedTransition;
 export 'modal_sheet.dart'
     show ModalSheetPage, ModalSheetRoute, ModalSheetRouteMixin;
+export 'swipe_dismiss_sensitivity.dart' show SwipeDismissSensitivity;
diff --git a/lib/src/modal/modal_sheet.dart b/lib/src/modal/modal_sheet.dart
index 988dda81..e1ae95f7 100644
--- a/lib/src/modal/modal_sheet.dart
+++ b/lib/src/modal/modal_sheet.dart
@@ -6,9 +6,8 @@ import 'package:flutter/material.dart';
 import '../foundation/sheet_drag.dart';
 import '../foundation/sheet_gesture_tamperer.dart';
 import '../internal/float_comp.dart';
+import 'swipe_dismiss_sensitivity.dart';
 
-const _minFlingVelocityToDismiss = 1.0;
-const _minDragDistanceToDismiss = 100.0; // Logical pixels.
 const _minReleasedPageForwardAnimationTime = 300; // Milliseconds.
 const _releasedPageForwardAnimationCurve = Curves.fastLinearToSlowEaseIn;
 
@@ -26,6 +25,7 @@ class ModalSheetPage<T> extends Page<T> {
     this.barrierColor = Colors.black54,
     this.transitionDuration = const Duration(milliseconds: 300),
     this.transitionCurve = Curves.fastEaseInToSlowEaseOut,
+    this.swipeDismissSensitivity = const SwipeDismissSensitivity(),
     required this.child,
   });
 
@@ -49,6 +49,8 @@ class ModalSheetPage<T> extends Page<T> {
 
   final Curve transitionCurve;
 
+  final SwipeDismissSensitivity swipeDismissSensitivity;
+
   @override
   Route<T> createRoute(BuildContext context) {
     return _PageBasedModalSheetRoute(
@@ -88,6 +90,10 @@ class _PageBasedModalSheetRoute<T> extends PageRoute<T>
   @override
   Duration get transitionDuration => _page.transitionDuration;
 
+  @override
+  SwipeDismissSensitivity get swipeDismissSensitivity =>
+      _page.swipeDismissSensitivity;
+
   @override
   String get debugLabel => '${super.debugLabel}(${_page.name})';
 
@@ -107,6 +113,7 @@ class ModalSheetRoute<T> extends PageRoute<T> with ModalSheetRouteMixin<T> {
     this.swipeDismissible = false,
     this.transitionDuration = const Duration(milliseconds: 300),
     this.transitionCurve = Curves.fastEaseInToSlowEaseOut,
+    this.swipeDismissSensitivity = const SwipeDismissSensitivity(),
   });
 
   final WidgetBuilder builder;
@@ -132,6 +139,9 @@ class ModalSheetRoute<T> extends PageRoute<T> with ModalSheetRouteMixin<T> {
   @override
   final Curve transitionCurve;
 
+  @override
+  final SwipeDismissSensitivity swipeDismissSensitivity;
+
   @override
   Widget buildContent(BuildContext context) {
     return builder(context);
@@ -141,6 +151,7 @@ class ModalSheetRoute<T> extends PageRoute<T> with ModalSheetRouteMixin<T> {
 mixin ModalSheetRouteMixin<T> on ModalRoute<T> {
   bool get swipeDismissible;
   Curve get transitionCurve;
+  SwipeDismissSensitivity get swipeDismissSensitivity;
 
   @override
   bool get opaque => false;
@@ -149,6 +160,7 @@ mixin ModalSheetRouteMixin<T> on ModalRoute<T> {
   late final _swipeDismissibleController = _SwipeDismissibleController(
     route: this,
     transitionController: controller!,
+    sensitivity: swipeDismissSensitivity,
   );
 
   Widget buildContent(BuildContext context);
@@ -226,10 +238,12 @@ class _SwipeDismissibleController with SheetGestureTamperer {
   _SwipeDismissibleController({
     required this.route,
     required this.transitionController,
+    required this.sensitivity,
   });
 
   final ModalRoute<dynamic> route;
   final AnimationController transitionController;
+  final SwipeDismissSensitivity sensitivity;
 
   BuildContext get _context => route.subtreeContext!;
 
@@ -344,12 +358,12 @@ class _SwipeDismissibleController with SheetGestureTamperer {
       invokePop = false;
     } else if (effectiveVelocity < 0) {
       // Flings down.
-      invokePop = effectiveVelocity.abs() > _minFlingVelocityToDismiss;
+      invokePop = effectiveVelocity.abs() > sensitivity.minFlingVelocityRatio;
     } else if (FloatComp.velocity(MediaQuery.devicePixelRatioOf(_context))
         .isApprox(effectiveVelocity, 0)) {
       assert(draggedDistance >= 0);
       // Dragged down enough to dismiss.
-      invokePop = draggedDistance > _minDragDistanceToDismiss;
+      invokePop = draggedDistance > sensitivity.minDragDistance;
     } else {
       // Flings up.
       invokePop = false;
diff --git a/lib/src/modal/swipe_dismiss_sensitivity.dart b/lib/src/modal/swipe_dismiss_sensitivity.dart
new file mode 100644
index 00000000..b568bc0e
--- /dev/null
+++ b/lib/src/modal/swipe_dismiss_sensitivity.dart
@@ -0,0 +1,40 @@
+import 'package:flutter/widgets.dart';
+
+import 'modal_sheet.dart';
+
+/// Configuration for the swipe-to-dismiss sensitivity of [ModalSheetRoute],
+/// [ModalSheetPage], and related classes.
+///
+/// The modal will be dismissed under the following conditions:
+/// - A downward fling gesture with the ratio of the velocity to the viewport
+///   height that exceeds [minFlingVelocityRatio].
+/// - A drag gesture ending with zero velocity, where the downward distance
+///   exceeds [minDragDistance].
+class SwipeDismissSensitivity {
+  /// Creates a swipe-to-dismiss sensitivity configuration.
+  const SwipeDismissSensitivity({
+    this.minFlingVelocityRatio = 2.0,
+    this.minDragDistance = 200.0,
+  });
+
+  /// Minimum ratio of gesture velocity to viewport height required to
+  /// trigger dismissal for a downward fling gesture.
+  ///
+  /// The viewport height is obtained from the `size` property of the
+  /// navigator's [BuildContext] where the modal route belongs to.
+  /// Therefore, the larger the viewport height, the higher the velocity
+  /// required to dismiss the modal (and vice versa). This is to ensure that
+  /// the swipe-to-dismiss behavior is consistent across different screen sizes.
+  ///
+  /// As a reference, the ratio of 1.0 corresponds to the velocity such that
+  /// the user moves their finger from the top to the bottom of the screen
+  /// in exactly 1 second.
+  final double minFlingVelocityRatio;
+
+  /// Minimum downward drag distance required for dismissal when the
+  /// gesture ends with zero velocity.
+  ///
+  /// If the drag gesture ends with a non-zero velocity, it's treated as
+  /// a fling gesture, and this value is not used.
+  final double minDragDistance;
+}
diff --git a/pubspec.yaml b/pubspec.yaml
index d29e7391..bb5775ec 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,6 +1,6 @@
 name: smooth_sheets
 description: Sheet widgets with smooth motion and great flexibility. Also supports nested navigation in both imperative and declarative ways.
-version: 0.9.3
+version: 0.10.0
 repository: https://github.com/fujidaiti/smooth_sheets
 screenshots:
   - description: Practical examples of smooth_sheets.


Test patch:
diff --git a/test/modal/modal_sheet_test.dart b/test/modal/modal_sheet_test.dart
new file mode 100644
index 00000000..cfeeaafe
--- /dev/null
+++ b/test/modal/modal_sheet_test.dart
@@ -0,0 +1,150 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:smooth_sheets/smooth_sheets.dart';
+
+void main() {
+  group('Swipe-to-dismiss action test', () {
+    Widget boilerplate(SwipeDismissSensitivity sensitivity) {
+      return MaterialApp(
+        home: Builder(
+          builder: (context) {
+            return Scaffold(
+              body: Center(
+                child: ElevatedButton(
+                  onPressed: () {
+                    Navigator.push(
+                      context,
+                      ModalSheetRoute<dynamic>(
+                        swipeDismissible: true,
+                        swipeDismissSensitivity: sensitivity,
+                        builder: (context) {
+                          return DraggableSheet(
+                            child: Container(
+                              key: const Key('sheet'),
+                              color: Colors.white,
+                              width: double.infinity,
+                              height: 600,
+                            ),
+                          );
+                        },
+                      ),
+                    );
+                  },
+                  child: const Text('Open modal'),
+                ),
+              ),
+            );
+          },
+        ),
+      );
+    }
+
+    testWidgets(
+      'modal should be dismissed if swipe gesture has enough speed',
+      (tester) async {
+        await tester.binding.setSurfaceSize(const Size(400, 900));
+        addTearDown(() => tester.binding.setSurfaceSize(null));
+
+        await tester.pumpWidget(
+          boilerplate(
+            const SwipeDismissSensitivity(
+              minFlingVelocityRatio: 1.0,
+              minDragDistance: 1000,
+            ),
+          ),
+        );
+
+        await tester.tap(find.text('Open modal'));
+        await tester.pumpAndSettle();
+        expect(find.byKey(const Key('sheet')), findsOneWidget);
+
+        await tester.fling(
+          find.byKey(const Key('sheet')),
+          const Offset(0, 200),
+          901, // ratio = velocity (901.0) / screen-height (900.0) > threshold-ratio
+        );
+        await tester.pumpAndSettle();
+        expect(find.byKey(const Key('sheet')), findsNothing);
+      },
+    );
+
+    testWidgets(
+      'modal should not be dismissed if swipe gesture has not enough speed',
+      (tester) async {
+        await tester.binding.setSurfaceSize(const Size(400, 900));
+        addTearDown(() => tester.binding.setSurfaceSize(null));
+
+        await tester.pumpWidget(
+          boilerplate(
+            const SwipeDismissSensitivity(
+              minFlingVelocityRatio: 1.0,
+              minDragDistance: 1000,
+            ),
+          ),
+        );
+
+        await tester.tap(find.text('Open modal'));
+        await tester.pumpAndSettle();
+        expect(find.byKey(const Key('sheet')), findsOneWidget);
+
+        await tester.fling(
+          find.byKey(const Key('sheet')),
+          const Offset(0, 200),
+          899, // ratio = velocity (899.0) / screen-height (900.0) < threshold-ratio
+        );
+        await tester.pumpAndSettle();
+        expect(find.byKey(const Key('sheet')), findsOneWidget);
+      },
+    );
+
+    testWidgets(
+      'modal should be dismissed if drag distance is enough',
+      (tester) async {
+        await tester.pumpWidget(
+          boilerplate(
+            const SwipeDismissSensitivity(
+              minFlingVelocityRatio: 5.0,
+              minDragDistance: 100,
+            ),
+          ),
+        );
+
+        await tester.tap(find.text('Open modal'));
+        await tester.pumpAndSettle();
+        expect(find.byKey(const Key('sheet')), findsOneWidget);
+
+        await tester.drag(
+          find.byKey(const Key('sheet')),
+          const Offset(0, 101),
+        );
+        await tester.pumpAndSettle();
+        expect(find.byKey(const Key('sheet')), findsNothing);
+      },
+    );
+
+    testWidgets(
+      'modal should not be dismissed if drag distance is not enough',
+      (tester) async {
+        await tester.pumpWidget(
+          boilerplate(
+            const SwipeDismissSensitivity(
+              minFlingVelocityRatio: 5.0,
+              minDragDistance: 100,
+            ),
+          ),
+        );
+
+        await tester.tap(find.text('Open modal'));
+        await tester.pumpAndSettle();
+        expect(find.byKey(const Key('sheet')), findsOneWidget);
+
+        await tester.drag(
+          find.byKey(const Key('sheet')),
+          const Offset(0, 99),
+        );
+        await tester.pumpAndSettle();
+        expect(find.byKey(const Key('sheet')), findsOneWidget);
+      },
+    );
+  });
+}