From 9d07275ae13ee7859b31c62e7ca74c4f383beecc Mon Sep 17 00:00:00 2001 From: ice-orion <102020833+ice-orion@users.noreply.github.com> Date: Sat, 31 Aug 2024 09:02:39 +0300 Subject: [PATCH] feat: add swipe dismiss sensitivity params for modals (#222) ## Related issues (optional) Closes #162. ## Description This PR adds the `swipeDismissSensitivity` property to `ModalSheetRoute` and related classes to support a way to tweak the sensitivity of the swipe-to-dismiss action. ## Summary (check all that apply) - [x] Modified / added code - [x] Modified / added tests - [x] Modified / added examples - [ ] Modified / added others (pubspec.yaml, workflows, etc...) - [x] Updated README - [ ] Contains breaking changes - [ ] Created / updated migration guide - [x] Incremented version number - [x] Updated CHANGELOG --------- Co-authored-by: fujidaiti Co-authored-by: Daichi Fujita <68946713+fujidaiti@users.noreply.github.com> --- CHANGELOG.md | 4 + README.md | 1 + .../lib/tutorial/cupertino_modal_sheet.dart | 8 +- .../lib/tutorial/declarative_modal_sheet.dart | 5 + .../lib/tutorial/imperative_modal_sheet.dart | 5 + lib/src/modal/cupertino.dart | 12 ++ lib/src/modal/modal.dart | 1 + lib/src/modal/modal_sheet.dart | 22 ++- lib/src/modal/swipe_dismiss_sensitivity.dart | 40 +++++ pubspec.yaml | 2 +- test/modal/modal_sheet_test.dart | 150 ++++++++++++++++++ 11 files changed, 244 insertions(+), 6 deletions(-) create mode 100644 lib/src/modal/swipe_dismiss_sensitivity.dart create mode 100644 test/modal/modal_sheet_test.dart 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 extends Page { this.barrierColor = _cupertinoBarrierColor, this.transitionDuration = _cupertinoTransitionDuration, this.transitionCurve = _cupertinoTransitionCurve, + this.swipeDismissSensitivity = const SwipeDismissSensitivity(), required this.child, }); @@ -445,6 +447,8 @@ class CupertinoModalSheetPage extends Page { final Curve transitionCurve; + final SwipeDismissSensitivity swipeDismissSensitivity; + @override Route createRoute(BuildContext context) { return _PageBasedCupertinoModalSheetRoute( @@ -485,6 +489,10 @@ class _PageBasedCupertinoModalSheetRoute @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 extends _BaseCupertinoModalSheetRoute { this.barrierColor = _cupertinoBarrierColor, this.transitionDuration = _cupertinoTransitionDuration, this.transitionCurve = _cupertinoTransitionCurve, + this.swipeDismissSensitivity = const SwipeDismissSensitivity(), }); final WidgetBuilder builder; @@ -529,6 +538,9 @@ class CupertinoModalSheetRoute extends _BaseCupertinoModalSheetRoute { @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 extends Page { 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 extends Page { final Curve transitionCurve; + final SwipeDismissSensitivity swipeDismissSensitivity; + @override Route createRoute(BuildContext context) { return _PageBasedModalSheetRoute( @@ -88,6 +90,10 @@ class _PageBasedModalSheetRoute extends PageRoute @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 extends PageRoute with ModalSheetRouteMixin { 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 extends PageRoute with ModalSheetRouteMixin { @override final Curve transitionCurve; + @override + final SwipeDismissSensitivity swipeDismissSensitivity; + @override Widget buildContent(BuildContext context) { return builder(context); @@ -141,6 +151,7 @@ class ModalSheetRoute extends PageRoute with ModalSheetRouteMixin { mixin ModalSheetRouteMixin on ModalRoute { bool get swipeDismissible; Curve get transitionCurve; + SwipeDismissSensitivity get swipeDismissSensitivity; @override bool get opaque => false; @@ -149,6 +160,7 @@ mixin ModalSheetRouteMixin on ModalRoute { 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 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. 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( + 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); + }, + ); + }); +}