Repository: fujidaiti/smooth_sheets
Pull request #222 (2024-08-19 07:13:14)

feat: add swipe dismiss sensitivity params for modals
## 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.

appinteractive: Awesome! But I think it would make more sense to add a DismissConfig (or similar) that contains the both values. At least that is how the other configs get encapsulated as far as I can tell. 
fujidaiti: Can you also make the following changes?

- Bump the version to `0.10.0` in pubspec.yaml and update CHANGELOG file
- Add an example of `SwipeDismissSensitivity` to `tutorial/imperative_modal_route.dart` or somewhere
- Run `dart format .` to ensure the CI passes
fujidaiti: @ice-orion

I renamed `minFlingVelocity` to `minFlingVelocityRatio` to reflect its actual meaning and changed the default value from 1.0 to 2.0. Could you test the new default configuration to see if it's okay?
fujidaiti: @ice-orion Thank you! I'll resolve the conflicts and add some tests this weekend.
fujidaiti: @ice-orion I changed the default minDragDistance to 200, as 300 is a bit too large, especially for small sheets.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a01c8db..dc896a3 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 ad2eeb2..cdd1c89 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 c7e3dad..9786fcb 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 5f734be..99e94d1 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 1c6ff82..546491e 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 cc41bd1..c47828a 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 8c4aad4..d0b8008 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 988dda8..e1ae95f 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 0000000..b568bc0
--- /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 d29e739..bb5775e 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 0000000..cfeeaaf
--- /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);
+      },
+    );
+  });
+}