From 90c8f10b89db5b18c1fd382692cfba3a09be67f1 Mon Sep 17 00:00:00 2001 From: Daichi Fujita <68946713+fujidaiti@users.noreply.github.com> Date: Sat, 24 Feb 2024 12:41:10 +0900 Subject: [PATCH] Add conditional modal sheet popping feature (#41) Closes #18. # New Feature - Add `SheetDismissible`, which enables the enclosed sheet to be dismissed by a drag-down gesture. It accepts an `onDismiss` callback, which will be invoked when the user tries to dismiss the sheet by dragging it down, providing an opportunity to determine if the sheet should be dismissed. # Changes - Add tutorial code for `SheetDismissible`. - Update the Safari and the AI playlist generator example to use `SheetDismissible` to show a confirmation dialog to discard changes. # Breaking Changes - Remove `ModalSheetRouteMixin.enablePullToDismiss`. --- .github/workflows/code_check.yaml | 14 +++ .../lib/showcase/ai_playlist_generator.dart | 63 ++++++++-- cookbook/lib/showcase/safari/actions.dart | 6 +- cookbook/lib/showcase/safari/menu.dart | 6 +- .../lib/showcase/todo_list/todo_editor.dart | 54 ++++++-- .../lib/tutorial/declarative_modal_sheet.dart | 6 +- .../lib/tutorial/imperative_modal_sheet.dart | 54 ++++++-- package/lib/src/modal/cupertino.dart | 7 -- package/lib/src/modal/modal_sheet.dart | 117 +++++++----------- 9 files changed, 213 insertions(+), 114 deletions(-) diff --git a/.github/workflows/code_check.yaml b/.github/workflows/code_check.yaml index eea62afa..eec32757 100644 --- a/.github/workflows/code_check.yaml +++ b/.github/workflows/code_check.yaml @@ -83,3 +83,17 @@ jobs: name: Test Report path: ${{ env.FLUTTER_TEST_REPORT }} reporter: flutter-json + + # Final results (Used for status checks) + code-check: + if: ${{ always() }} + runs-on: ubuntu-latest + needs: [analysis, testing] + steps: + # Fails if any of the previous jobs failed. + - run: exit 1 + if: >- + ${{ + contains(needs.*.result, 'failure') + || contains(needs.*.result, 'cancelled') + }} diff --git a/cookbook/lib/showcase/ai_playlist_generator.dart b/cookbook/lib/showcase/ai_playlist_generator.dart index 803fd02a..9198b6a6 100644 --- a/cookbook/lib/showcase/ai_playlist_generator.dart +++ b/cookbook/lib/showcase/ai_playlist_generator.dart @@ -56,12 +56,9 @@ final _sheetShellRoute = ShellRoute( pageBuilder: (context, state, navigator) { // Use ModalSheetPage to show a modal sheet. return ModalSheetPage( - child: SafeArea( - bottom: false, - child: NavigationSheet( - transitionObserver: sheetTransitionObserver, - child: _SheetShell(navigator: navigator), - ), + child: _SheetShell( + navigator: navigator, + transitionObserver: sheetTransitionObserver, ), ); }, @@ -143,19 +140,61 @@ class _Root extends StatelessWidget { class _SheetShell extends StatelessWidget { const _SheetShell({ + required this.transitionObserver, required this.navigator, }); + final NavigationSheetTransitionObserver transitionObserver; final Widget navigator; @override Widget build(BuildContext context) { - return Material( - // Add circular corners to the sheet. - borderRadius: BorderRadius.circular(16), - clipBehavior: Clip.antiAlias, - color: Theme.of(context).colorScheme.surface, - child: navigator, + void showCancelDialog() { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Are you sure?'), + content: + const Text('Do you want to cancel the playlist generation?'), + actions: [ + TextButton( + onPressed: () => context.go('/'), + child: const Text('Yes'), + ), + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('No'), + ), + ], + ); + }, + ); + } + + return SafeArea( + bottom: false, + // Wrap the sheet in a SheetDismissible to enable pull-to-dismiss action. + child: SheetDismissible( + // This callback is invoked when the user tries + // to dismiss the sheet by dragging it down. + onDismiss: () { + // Prompt the user to confirm if they want to dismiss the sheet. + showCancelDialog(); + // Returns false to disable automatic modal sheet popping. + return false; + }, + child: NavigationSheet( + transitionObserver: sheetTransitionObserver, + child: Material( + // Add circular corners to the sheet. + borderRadius: BorderRadius.circular(16), + clipBehavior: Clip.antiAlias, + color: Theme.of(context).colorScheme.surface, + child: navigator, + ), + ), + ), ); } } diff --git a/cookbook/lib/showcase/safari/actions.dart b/cookbook/lib/showcase/safari/actions.dart index f4d63bb4..9dc4e077 100644 --- a/cookbook/lib/showcase/safari/actions.dart +++ b/cookbook/lib/showcase/safari/actions.dart @@ -6,7 +6,11 @@ void showEditActionsSheet(BuildContext context) { Navigator.push( context, CupertinoModalSheetRoute( - builder: (context) => const EditActionsSheet(), + builder: (context) { + return const SheetDismissible( + child: EditActionsSheet(), + ); + }, ), ); } diff --git a/cookbook/lib/showcase/safari/menu.dart b/cookbook/lib/showcase/safari/menu.dart index e0007d19..d07bb436 100644 --- a/cookbook/lib/showcase/safari/menu.dart +++ b/cookbook/lib/showcase/safari/menu.dart @@ -9,7 +9,11 @@ void showMenuSheet(BuildContext context) { Navigator.push( context, CupertinoModalSheetRoute( - builder: (context) => const MenuSheet(), + builder: (context) { + return const SheetDismissible( + child: MenuSheet(), + ); + }, ), ); } diff --git a/cookbook/lib/showcase/todo_list/todo_editor.dart b/cookbook/lib/showcase/todo_list/todo_editor.dart index 5924c29d..540d62cd 100644 --- a/cookbook/lib/showcase/todo_list/todo_editor.dart +++ b/cookbook/lib/showcase/todo_list/todo_editor.dart @@ -34,6 +34,36 @@ class _TodoEditorState extends State { super.dispose(); } + bool onDismiss() { + if (!controller.canCompose.value) { + // Dismiss immediately if there are no unsaved changes. + return true; + } + + // Show a confirmation dialog. + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Discard changes?'), + actions: [ + TextButton( + onPressed: () => + Navigator.popUntil(context, (route) => route.isFirst), + child: const Text('Discard'), + ), + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ], + ); + }, + ); + + return false; + } + @override Widget build(BuildContext context) { final titleInput = _MultiLineInput( @@ -103,16 +133,20 @@ class _TodoEditorState extends State { return SafeArea( bottom: false, - child: ScrollableSheet( - keyboardDismissBehavior: const SheetKeyboardDismissBehavior.onDragDown( - isContentScrollAware: true, - ), - child: Container( - clipBehavior: Clip.antiAlias, - decoration: sheetShape, - child: SheetContentScaffold( - body: body, - bottomBar: bottomBar, + child: SheetDismissible( + onDismiss: onDismiss, + child: ScrollableSheet( + keyboardDismissBehavior: + const SheetKeyboardDismissBehavior.onDragDown( + isContentScrollAware: true, + ), + child: Container( + clipBehavior: Clip.antiAlias, + decoration: sheetShape, + child: SheetContentScaffold( + body: body, + bottomBar: bottomBar, + ), ), ), ), diff --git a/cookbook/lib/tutorial/declarative_modal_sheet.dart b/cookbook/lib/tutorial/declarative_modal_sheet.dart index 6347a7a4..0fc8a892 100644 --- a/cookbook/lib/tutorial/declarative_modal_sheet.dart +++ b/cookbook/lib/tutorial/declarative_modal_sheet.dart @@ -24,7 +24,11 @@ final _router = GoRouter( // It works with any *Sheet provided by this package! return ModalSheetPage( key: state.pageKey, - child: const _ExampleSheet(), + // Wrap your sheet with a SheetDismissible to make it + // dismissible by dragging it down. + child: const SheetDismissible( + child: _ExampleSheet(), + ), ); }, ), diff --git a/cookbook/lib/tutorial/imperative_modal_sheet.dart b/cookbook/lib/tutorial/imperative_modal_sheet.dart index a2a7386b..42d69552 100644 --- a/cookbook/lib/tutorial/imperative_modal_sheet.dart +++ b/cookbook/lib/tutorial/imperative_modal_sheet.dart @@ -47,18 +47,52 @@ class _ExampleSheet extends StatelessWidget { @override Widget build(BuildContext context) { - return DraggableSheet( - child: Card( - color: Theme.of(context).colorScheme.secondaryContainer, - margin: EdgeInsets.zero, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - child: const SizedBox( - height: 500, - width: double.infinity, + // Wrap your sheet with a SheetDismissible to make it + // dismissible by dragging it down. + return SheetDismissible( + // This callback is called when the user tries to dismiss the sheet + // by dragging it down. Return true to dismiss the sheet immediately, + // or false otherwise. This is useful when, for example, you want to + // show a confirmation dialog before dismissing the sheet. + onDismiss: () { + showConfirmDialog(context); + return false; + }, + child: DraggableSheet( + child: Card( + color: Theme.of(context).colorScheme.secondaryContainer, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + child: const SizedBox( + height: 500, + width: double.infinity, + ), ), ), ); } + + void showConfirmDialog(BuildContext context) { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Are you sure?'), + actions: [ + TextButton( + onPressed: () => + Navigator.popUntil(context, (route) => route.isFirst), + child: const Text('Yes'), + ), + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('No'), + ), + ], + ); + }, + ); + } } diff --git a/package/lib/src/modal/cupertino.dart b/package/lib/src/modal/cupertino.dart index e7758542..d6b29480 100644 --- a/package/lib/src/modal/cupertino.dart +++ b/package/lib/src/modal/cupertino.dart @@ -469,9 +469,6 @@ class _PageBasedCupertinoModalSheetRoute @override bool get barrierDismissible => _page.barrierDismissible; - @override - bool get enablePullToDismiss => _page.enablePullToDismiss; - @override Curve get transitionCurve => _page.transitionCurve; @@ -490,7 +487,6 @@ class CupertinoModalSheetRoute extends _BaseCupertinoModalSheetRoute { super.settings, super.fullscreenDialog, required this.builder, - this.enablePullToDismiss = true, this.maintainState = true, this.barrierDismissible = true, this.barrierLabel, @@ -510,9 +506,6 @@ class CupertinoModalSheetRoute extends _BaseCupertinoModalSheetRoute { @override final String? barrierLabel; - @override - final bool enablePullToDismiss; - @override final bool maintainState; diff --git a/package/lib/src/modal/modal_sheet.dart b/package/lib/src/modal/modal_sheet.dart index 3827dfd4..cbdc2d0d 100644 --- a/package/lib/src/modal/modal_sheet.dart +++ b/package/lib/src/modal/modal_sheet.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:math'; import 'dart:ui'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:smooth_sheets/src/foundation/sheet_controller.dart'; import 'package:smooth_sheets/src/internal/double_utils.dart'; @@ -20,7 +19,6 @@ class ModalSheetPage extends Page { super.arguments, super.restorationId, this.maintainState = true, - this.enablePullToDismiss = true, this.barrierDismissible = true, this.fullscreenDialog = false, this.barrierLabel, @@ -44,8 +42,6 @@ class ModalSheetPage extends Page { final String? barrierLabel; - final bool enablePullToDismiss; - final Duration transitionDuration; final Curve transitionCurve; @@ -80,9 +76,6 @@ class _PageBasedModalSheetRoute extends PageRoute @override bool get barrierDismissible => _page.barrierDismissible; - @override - bool get enablePullToDismiss => _page.enablePullToDismiss; - @override Curve get transitionCurve => _page.transitionCurve; @@ -101,7 +94,6 @@ class ModalSheetRoute extends PageRoute with ModalSheetRouteMixin { super.settings, super.fullscreenDialog, required this.builder, - this.enablePullToDismiss = true, this.maintainState = true, this.barrierDismissible = true, this.barrierLabel, @@ -121,9 +113,6 @@ class ModalSheetRoute extends PageRoute with ModalSheetRouteMixin { @override final String? barrierLabel; - @override - final bool enablePullToDismiss; - @override final bool maintainState; @@ -140,14 +129,17 @@ class ModalSheetRoute extends PageRoute with ModalSheetRouteMixin { } mixin ModalSheetRouteMixin on ModalRoute { - bool get enablePullToDismiss; Curve get transitionCurve; @override bool get opaque => false; + @protected late final SheetController sheetController; + /// Re-exposed [ModalRoute.controller] for use in [SheetDismissible]. + AnimationController get _transitionController => controller!; + @override void install() { super.install(); @@ -168,20 +160,9 @@ mixin ModalSheetRouteMixin on ModalRoute { Animation animation, Animation secondaryAnimation, ) { - var content = buildContent(context); - - if (enablePullToDismiss) { - content = _SheetDismissible( - transitionAnimation: controller!, - transitionDuration: transitionDuration, - navigator: navigator!, - child: content, - ); - } - return SheetControllerScope( controller: sheetController, - child: content, + child: buildContent(context), ); } @@ -206,44 +187,28 @@ mixin ModalSheetRouteMixin on ModalRoute { } } -// TODO: Implement this. -// class PopSheetScope extends InheritedWidget { -// const PopSheetScope({ -// super.key, -// required this.onWillPop, -// required super.child, -// }); - -// final AsyncValueGetter onWillPop; - -// @override -// bool updateShouldNotify(PopSheetScope oldWidget) { -// return onWillPop != oldWidget.onWillPop; -// } -// } - -class _SheetDismissible extends StatefulWidget { - const _SheetDismissible({ - required this.transitionAnimation, - required this.transitionDuration, - required this.navigator, +class SheetDismissible extends StatefulWidget { + const SheetDismissible({ + super.key, + this.onDismiss, required this.child, }); - final AnimationController transitionAnimation; - final Duration transitionDuration; - final NavigatorState navigator; + final ValueGetter? onDismiss; final Widget child; @override - State<_SheetDismissible> createState() => _SheetDismissibleState(); + State createState() => _SheetDismissibleState(); } -class _SheetDismissibleState extends State<_SheetDismissible> { +class _SheetDismissibleState extends State { late SheetController _sheetController; + late ModalSheetRouteMixin _parentRoute; late final _PullToDismissGestureRecognizer _gestureRecognizer; ScrollMetrics? _lastReportedScrollMetrics; - AsyncValueGetter? _shouldDismissCallback; + + AnimationController get _transitionController => + _parentRoute._transitionController; @override void initState() { @@ -267,72 +232,80 @@ class _SheetDismissibleState extends State<_SheetDismissible> { _gestureRecognizer.gestureSettings = MediaQuery.maybeGestureSettingsOf(context); _sheetController = DefaultSheetController.of(context); + + assert( + ModalRoute.of(context) is ModalSheetRouteMixin, + '$SheetDismissible must be a descendant of a $ModalRoute ' + 'that mixins $ModalSheetRouteMixin.', + ); + + _parentRoute = ModalRoute.of(context)! as ModalSheetRouteMixin; } double _draggedDistance = 0; void _handleDragStart(DragStartDetails details) { _draggedDistance = 0; - widget.navigator.didStartUserGesture(); + _parentRoute.navigator!.didStartUserGesture(); } void handleDragUpdate(DragUpdateDetails details) { _draggedDistance += details.delta.dy; final animationDelta = details.delta.dy / context.size!.height; - widget.transitionAnimation.value = - (widget.transitionAnimation.value - animationDelta).clamp(0, 1); + _transitionController.value = + (_parentRoute.animation!.value - animationDelta).clamp(0, 1); } Future handleDragEnd(DragEndDetails details) async { final velocity = details.velocity.pixelsPerSecond.dy / context.size!.height; + final shouldDismissCallback = widget.onDismiss ?? () => true; final bool willPop; if (velocity > 0) { // Flings down. willPop = velocity.abs() > _minFlingVelocityToDismiss && - !widget.transitionAnimation.isAnimating && - (_shouldDismissCallback == null || await _shouldDismissCallback!()); + !_transitionController.isAnimating && + shouldDismissCallback(); } else if (velocity.isApprox(0)) { willPop = _draggedDistance.abs() > _minDragDistanceToDismiss && - !widget.transitionAnimation.isAnimating && - (_shouldDismissCallback == null || await _shouldDismissCallback!()); + !_transitionController.isAnimating && + shouldDismissCallback(); } else { // Flings up. willPop = false; } if (willPop) { - widget.navigator.pop(); - } else if (!widget.transitionAnimation.isCompleted) { + _parentRoute.navigator!.pop(); + } else if (!_transitionController.isCompleted) { // The route won't be popped, so animate the transition // back to the origin. - final fraction = 1.0 - widget.transitionAnimation.value; + final fraction = 1.0 - _transitionController.value; final animationTime = max( - (widget.transitionDuration.inMilliseconds * fraction).floor(), + (_parentRoute.transitionDuration.inMilliseconds * fraction).floor(), _minReleasedPageForwardAnimationTime, ); const completedAnimationValue = 1.0; - unawaited(widget.transitionAnimation.animateTo( + unawaited(_transitionController.animateTo( completedAnimationValue, duration: Duration(milliseconds: animationTime), curve: _releasedPageForwardAnimationCurve, )); } - if (widget.transitionAnimation.isAnimating) { + if (_transitionController.isAnimating) { // Keep the userGestureInProgress in true state so we don't change the // curve of the page transition mid-flight since the route's transition // depends on userGestureInProgress. late final AnimationStatusListener animationStatusCallback; animationStatusCallback = (AnimationStatus status) { - widget.navigator.didStopUserGesture(); - widget.transitionAnimation - .removeStatusListener(animationStatusCallback); + _parentRoute.navigator!.didStopUserGesture(); + _transitionController.removeStatusListener(animationStatusCallback); }; - widget.transitionAnimation.addStatusListener(animationStatusCallback); + _transitionController.addStatusListener(animationStatusCallback); } else { - widget.navigator.didStopUserGesture(); + _parentRoute.navigator!.didStopUserGesture(); } _draggedDistance = 0; @@ -340,8 +313,8 @@ class _SheetDismissibleState extends State<_SheetDismissible> { void handleDragCancel() { _draggedDistance = 0; - if (widget.navigator.userGestureInProgress) { - widget.navigator.didStopUserGesture(); + if (_parentRoute.navigator!.userGestureInProgress) { + _parentRoute.navigator!.didStopUserGesture(); } } @@ -410,7 +383,7 @@ class _PullToDismissGestureRecognizer extends VerticalDragGestureRecognizer { } bool _shouldStartDismissGesture() { - if (target.widget.transitionAnimation.isAnimating) { + if (target._transitionController.isAnimating) { return false; }