diff --git a/examples/api/lib/widgets/navigator_pop_handler/navigator_pop_handler.1.dart b/examples/api/lib/widgets/navigator_pop_handler/navigator_pop_handler.1.dart index 04fbdedd34e..e6192769450 100644 --- a/examples/api/lib/widgets/navigator_pop_handler/navigator_pop_handler.1.dart +++ b/examples/api/lib/widgets/navigator_pop_handler/navigator_pop_handler.1.dart @@ -168,14 +168,10 @@ class _BottomNavTabState extends State<_BottomNavTab> { }, child: Navigator( key: _navigatorKey, - onPopPage: (Route route, void result) { - if (!route.didPop(null)) { - return false; - } + onDidRemovePage: (Page page) { widget.onChangedPages(<_TabPage>[ ...widget.pages, ]..removeLast()); - return true; }, pages: widget.pages.map((_TabPage page) { switch (page) { diff --git a/examples/api/lib/widgets/page/page_can_pop.0.dart b/examples/api/lib/widgets/page/page_can_pop.0.dart new file mode 100644 index 00000000000..b100069070d --- /dev/null +++ b/examples/api/lib/widgets/page/page_can_pop.0.dart @@ -0,0 +1,160 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// This sample demonstrates showing a confirmation dialog before navigating +// away from a page. + +import 'package:flutter/material.dart'; + +void main() => runApp(const PageApiExampleApp()); + +class PageApiExampleApp extends StatefulWidget { + const PageApiExampleApp({super.key}); + + @override + State createState() => _PageApiExampleAppState(); +} + +class _PageApiExampleAppState extends State { + final RouterDelegate delegate = MyRouterDelegate(); + + @override + Widget build(BuildContext context) { + return MaterialApp.router( + routerDelegate: delegate, + ); + } +} + +class MyRouterDelegate extends RouterDelegate with PopNavigatorRouterDelegateMixin, ChangeNotifier { + // This example doesn't use RouteInformationProvider. + @override + Future setNewRoutePath(Object configuration) async => throw UnimplementedError(); + + @override + final GlobalKey navigatorKey = GlobalKey(); + + static MyRouterDelegate of(BuildContext context) => Router.of(context).routerDelegate as MyRouterDelegate; + + bool get showDetailPage => _showDetailPage; + bool _showDetailPage = false; + set showDetailPage(bool value) { + if (_showDetailPage == value) { + return; + } + _showDetailPage = value; + notifyListeners(); + } + + Future _showConfirmDialog() async { + return await showDialog( + context: navigatorKey.currentContext!, + barrierDismissible: false, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Are you sure?'), + actions: [ + TextButton( + child: const Text('Cancel'), + onPressed: () { + Navigator.of(context).pop(false); + }, + ), + TextButton( + child: const Text('Confirm'), + onPressed: () { + Navigator.of(context).pop(true); + }, + ), + ], + ); + }, + ) ?? false; + } + + Future _handlePopDetails(bool didPop, void result) async { + if (didPop) { + showDetailPage = false; + return; + } + final bool confirmed = await _showConfirmDialog(); + if (confirmed) { + showDetailPage = false; + } + } + + List> _getPages() { + return >[ + const MaterialPage(key: ValueKey('home'), child: _HomePage()), + if (showDetailPage) + MaterialPage( + key: const ValueKey('details'), + child: const _DetailsPage(), + canPop: false, + onPopInvoked: _handlePopDetails, + ), + ]; + } + + @override + Widget build(BuildContext context) { + return Navigator( + key: navigatorKey, + pages: _getPages(), + onDidRemovePage: (Page page) { + assert(page.key == const ValueKey('details')); + showDetailPage = false; + }, + ); + } +} + +class _HomePage extends StatefulWidget { + const _HomePage(); + + @override + State<_HomePage> createState() => _HomePageState(); +} + +class _HomePageState extends State<_HomePage> { + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Home')), + body: Center( + child: TextButton( + onPressed: () { + MyRouterDelegate.of(context).showDetailPage = true; + }, + child: const Text('Go to details'), + ), + ), + ); + } +} + +class _DetailsPage extends StatefulWidget { + const _DetailsPage(); + + @override + State<_DetailsPage> createState() => _DetailsPageState(); +} + +class _DetailsPageState extends State<_DetailsPage> { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Details')), + body: Center( + child: TextButton( + onPressed: () { + Navigator.of(context).maybePop(); + }, + child: const Text('Go back'), + ), + ), + ); + } +} diff --git a/examples/api/test/widgets/page/page_can_pop.0_test.dart b/examples/api/test/widgets/page/page_can_pop.0_test.dart new file mode 100644 index 00000000000..c78205e34e9 --- /dev/null +++ b/examples/api/test/widgets/page/page_can_pop.0_test.dart @@ -0,0 +1,54 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_api_samples/widgets/page/page_can_pop.0.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +import '../navigator_utils.dart'; + +void main() { + testWidgets('Can choose to stay on page', (WidgetTester tester) async { + await tester.pumpWidget( + const example.PageApiExampleApp(), + ); + + expect(find.text('Home'), findsOneWidget); + + await tester.tap(find.text('Go to details')); + await tester.pumpAndSettle(); + expect(find.text('Home'), findsNothing); + expect(find.text('Details'), findsOneWidget); + + await simulateSystemBack(); + await tester.pumpAndSettle(); + expect(find.text('Are you sure?'), findsOneWidget); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(find.text('Home'), findsNothing); + expect(find.text('Details'), findsOneWidget); + }); + + testWidgets('Can choose to go back', (WidgetTester tester) async { + await tester.pumpWidget( + const example.PageApiExampleApp(), + ); + + expect(find.text('Home'), findsOneWidget); + + await tester.tap(find.text('Go to details')); + await tester.pumpAndSettle(); + expect(find.text('Home'), findsNothing); + expect(find.text('Details'), findsOneWidget); + + await simulateSystemBack(); + await tester.pumpAndSettle(); + expect(find.text('Are you sure?'), findsOneWidget); + + await tester.tap(find.text('Confirm')); + await tester.pumpAndSettle(); + expect(find.text('Details'), findsNothing); + expect(find.text('Home'), findsOneWidget); + }); +} diff --git a/packages/flutter/lib/src/cupertino/route.dart b/packages/flutter/lib/src/cupertino/route.dart index e139953b324..a5c23cfc137 100644 --- a/packages/flutter/lib/src/cupertino/route.dart +++ b/packages/flutter/lib/src/cupertino/route.dart @@ -347,6 +347,8 @@ class CupertinoPage extends Page { this.title, this.fullscreenDialog = false, this.allowSnapshotting = true, + super.canPop, + super.onPopInvoked, super.key, super.name, super.arguments, diff --git a/packages/flutter/lib/src/material/page.dart b/packages/flutter/lib/src/material/page.dart index db781f53764..a2a23a42503 100644 --- a/packages/flutter/lib/src/material/page.dart +++ b/packages/flutter/lib/src/material/page.dart @@ -150,6 +150,8 @@ class MaterialPage extends Page { this.fullscreenDialog = false, this.allowSnapshotting = true, super.key, + super.canPop, + super.onPopInvoked, super.name, super.arguments, super.restorationId, diff --git a/packages/flutter/lib/src/widgets/navigator.dart b/packages/flutter/lib/src/widgets/navigator.dart index b5f9c48f250..8f539f3753c 100644 --- a/packages/flutter/lib/src/widgets/navigator.dart +++ b/packages/flutter/lib/src/widgets/navigator.dart @@ -86,6 +86,14 @@ typedef WillPopCallback = Future Function(); /// [Navigator.pages] list is next updated.) typedef PopPageCallback = bool Function(Route route, dynamic result); +/// Signature for the [Navigator.onDidRemovePage] callback. +/// +/// This must properly update the pages list the next time it is passed into +/// [Navigator.pages] so that it no longer includes the input `page`. +/// (Otherwise, the page will be interpreted as a new page to show when the +/// [Navigator.pages] list is next updated.) +typedef DidRemovePageCallback = void Function(Page page); + /// Indicates whether the current route should be popped. /// /// Used as the return value for [Route.willPop]. @@ -173,6 +181,8 @@ abstract class Route extends _RoutePlaceholder { RouteSettings get settings => _settings; RouteSettings _settings; + bool get _isPageBased => settings is Page; + /// The restoration scope ID to be used for the [RestorationScope] surrounding /// this route. /// @@ -344,7 +354,14 @@ abstract class Route extends _RoutePlaceholder { /// /// * [Form], which provides a [Form.canPop] boolean that is similar. /// * [PopScope], a widget that provides a way to intercept the back button. + /// * [Page.canPop], a way for [Page] to affect this property. RoutePopDisposition get popDisposition { + if (_isPageBased) { + final Page page = settings as Page; + if (!page.canPop) { + return RoutePopDisposition.doNotPop; + } + } return isFirst ? RoutePopDisposition.bubble : RoutePopDisposition.pop; } @@ -366,7 +383,13 @@ abstract class Route extends _RoutePlaceholder { /// will still be called. The `didPop` parameter indicates whether or not the /// back navigation actually happened successfully. /// {@endtemplate} - void onPopInvokedWithResult(bool didPop, T? result) => onPopInvoked(didPop); + @mustCallSuper + void onPopInvokedWithResult(bool didPop, T? result) { + if (_isPageBased) { + final Page page = settings as Page; + page.onPopInvoked(didPop, result); + } + } /// Whether calling [didPop] would return false. bool get willHandlePopInternally => false; @@ -621,6 +644,15 @@ class RouteSettings { /// The type argument `T` is the corresponding [Route]'s return type, as /// used by [Route.currentResult], [Route.popped], and [Route.didPop]. /// +/// The [canPop] and [onPopInvoked] are used for intercepting pops. +/// +/// {@tool dartpad} +/// This sample demonstrates how to use this [canPop] and [onPopInvoked] to +/// intercept pops. +/// +/// ** See code in examples/api/lib/widgets/page/page_can_pop.0.dart ** +/// {@end-tool} +/// /// See also: /// /// * [Navigator.pages], which accepts a list of [Page]s and updates its routes @@ -632,8 +664,12 @@ abstract class Page extends RouteSettings { super.name, super.arguments, this.restorationId, + this.canPop = true, + this.onPopInvoked = _defaultPopInvokedHandler, }); + static void _defaultPopInvokedHandler(bool didPop, Object? result) { } + /// The key associated with this page. /// /// This key will be used for comparing pages in [canUpdate]. @@ -650,6 +686,28 @@ abstract class Page extends RouteSettings { /// Flutter. final String? restorationId; + /// Called after a pop on the associated route was handled. + /// + /// It's not possible to prevent the pop from happening at the time that this + /// method is called; the pop has already happened. Use [canPop] to + /// disable pops in advance. + /// + /// This will still be called even when the pop is canceled. A pop is canceled + /// when the associated [Route.popDisposition] returns false, or when + /// [canPop] is set to false. The `didPop` parameter indicates whether or not + /// the back navigation actually happened successfully. + final PopInvokedWithResultCallback onPopInvoked; + + /// When false, blocks the associated route from being popped. + /// + /// If this is set to false for first page in the Navigator. It prevents + /// Flutter app from exiting. + /// + /// If there are any [PopScope] widgets in a route's widget subtree, + /// each of their `canPop` must be `true`, in addition to this canPop, in + /// order for the route to be able to pop. + final bool canPop; + /// Whether this page can be updated with the [other] page. /// /// Two pages are consider updatable if they have same the [runtimeType] and @@ -1465,6 +1523,10 @@ class Navigator extends StatefulWidget { const Navigator({ super.key, this.pages = const >[], + @Deprecated( + 'Use onDidRemovePage instead. ' + 'This feature was deprecated after v3.16.0-17.0.pre.', + ) this.onPopPage, this.initialRoute, this.onGenerateInitialRoutes = Navigator.defaultGenerateInitialRoutes, @@ -1477,6 +1539,7 @@ class Navigator extends StatefulWidget { this.requestFocus = true, this.restorationScopeId, this.routeTraversalEdgeBehavior = kDefaultRouteTraversalEdgeBehavior, + this.onDidRemovePage, }); /// The list of pages with which to populate the history. @@ -1509,6 +1572,8 @@ class Navigator extends StatefulWidget { /// corresponding to [pages] in the initial history. final List> pages; + /// This is deprecated and replaced by [onDidRemovePage]. + /// /// Called when [pop] is invoked but the current [Route] corresponds to a /// [Page] found in the [pages] list. /// @@ -1522,8 +1587,27 @@ class Navigator extends StatefulWidget { /// contain the [Page] for the given [Route]. The next time the [pages] list /// is updated, if the [Page] corresponding to this [Route] is still present, /// it will be interpreted as a new route to display. + @Deprecated( + 'Use onDidRemovePage instead. ' + 'This feature was deprecated after v3.16.0-17.0.pre.', + ) final PopPageCallback? onPopPage; + /// Called when the [Route] associated with the given [Page] has been removed + /// from the Navigator. + /// + /// This can happen when the route is removed or completed through + /// [Navigator.pop], [Navigator.pushReplacement], or its friends. + /// + /// This callback is responsible for removing the given page from the list of + /// [pages]. + /// + /// The [Navigator] widget should be rebuilt with a [pages] list that does not + /// contain the given page [Page]. The next time the [pages] list + /// is updated, if the given [Page] is still present, it will be interpreted + /// as a new page to display. + final DidRemovePageCallback? onDidRemovePage; + /// The delegate used for deciding how routes transition in or off the screen /// during the [pages] updates. /// @@ -3084,6 +3168,11 @@ class _RouteEntry extends RouteTransitionRecord { currentState = _RouteLifecycle.idle; return false; } + route.onPopInvokedWithResult(true, pendingResult); + if (pageBased) { + final Page page = route.settings as Page; + navigator.widget.onDidRemovePage?.call(page); + } pendingResult = null; return true; } @@ -3120,7 +3209,6 @@ class _RouteEntry extends RouteTransitionRecord { assert(isPresent); pendingResult = result; currentState = _RouteLifecycle.pop; - route.onPopInvokedWithResult(true, result); } bool _reportRemovalToObserver = true; @@ -3553,37 +3641,40 @@ class NavigatorState extends State with TickerProviderStateMixin, Res } } + bool _debugCheckPageApiParameters() { + if (!_usingPagesAPI) { + return true; + } + if (widget.pages.isEmpty) { + FlutterError.reportError( + FlutterErrorDetails( + exception: FlutterError( + 'The Navigator.pages must not be empty to use the ' + 'Navigator.pages API', + ), + library: 'widget library', + stack: StackTrace.current, + ), + ); + } else if ((widget.onDidRemovePage == null) == (widget.onPopPage == null)) { + FlutterError.reportError( + FlutterErrorDetails( + exception: FlutterError( + 'Either onDidRemovePage or onPopPage must be provided to use the ' + 'Navigator.pages API but not both.', + ), + library: 'widget library', + stack: StackTrace.current, + ), + ); + } + return true; + } + @override void initState() { super.initState(); - assert(() { - if (_usingPagesAPI) { - if (widget.pages.isEmpty) { - FlutterError.reportError( - FlutterErrorDetails( - exception: FlutterError( - 'The Navigator.pages must not be empty to use the ' - 'Navigator.pages API', - ), - library: 'widget library', - stack: StackTrace.current, - ), - ); - } else if (widget.onPopPage == null) { - FlutterError.reportError( - FlutterErrorDetails( - exception: FlutterError( - 'The Navigator.onPopPage must be provided to use the ' - 'Navigator.pages API', - ), - library: 'widget library', - stack: StackTrace.current, - ), - ); - } - } - return true; - }()); + assert(_debugCheckPageApiParameters()); for (final NavigatorObserver observer in widget.observers) { assert(observer.navigator == null); NavigatorObserver._navigators[observer] = this; @@ -3790,35 +3881,7 @@ class NavigatorState extends State with TickerProviderStateMixin, Res @override void didUpdateWidget(Navigator oldWidget) { super.didUpdateWidget(oldWidget); - assert(() { - if (_usingPagesAPI) { - // This navigator uses page API. - if (widget.pages.isEmpty) { - FlutterError.reportError( - FlutterErrorDetails( - exception: FlutterError( - 'The Navigator.pages must not be empty to use the ' - 'Navigator.pages API', - ), - library: 'widget library', - stack: StackTrace.current, - ), - ); - } else if (widget.onPopPage == null) { - FlutterError.reportError( - FlutterErrorDetails( - exception: FlutterError( - 'The Navigator.onPopPage must be provided to use the ' - 'Navigator.pages API', - ), - library: 'widget library', - stack: StackTrace.current, - ), - ); - } - } - return true; - }()); + assert(_debugCheckPageApiParameters()); if (oldWidget.observers != widget.observers) { for (final NavigatorObserver observer in oldWidget.observers) { NavigatorObserver._navigators[observer] = null; @@ -4321,6 +4384,9 @@ class NavigatorState extends State with TickerProviderStateMixin, Res // We aren't allowed to remove this route yet. break; } + if (entry.pageBased) { + widget.onDidRemovePage?.call(entry.route.settings as Page); + } entry.currentState = _RouteLifecycle.dispose; continue; case _RouteLifecycle.dispose: @@ -5229,14 +5295,14 @@ class NavigatorState extends State with TickerProviderStateMixin, Res // TODO(justinmc): When the deprecated willPop method is removed, delete // this code and use only popDisposition, below. - final RoutePopDisposition willPopDisposition = await lastEntry.route.willPop(); + if (await lastEntry.route.willPop() == RoutePopDisposition.doNotPop) { + return true; + } if (!mounted) { // Forget about this pop, we were disposed in the meantime. return true; } - if (willPopDisposition == RoutePopDisposition.doNotPop) { - return true; - } + final _RouteEntry? newLastEntry = _lastRouteEntryWhereOrNull(_RouteEntry.isPresentPredicate); if (lastEntry != newLastEntry) { // Forget about this pop, something happened to our history in the meantime. @@ -5287,7 +5353,7 @@ class NavigatorState extends State with TickerProviderStateMixin, Res return true; }()); final _RouteEntry entry = _history.lastWhere(_RouteEntry.isPresentPredicate); - if (entry.pageBased) { + if (entry.pageBased && widget.onPopPage != null) { if (widget.onPopPage!(entry.route, result) && entry.currentState == _RouteLifecycle.idle) { // The entry may have been disposed if the pop finishes synchronously. assert(entry.route._popCompleter.isCompleted); diff --git a/packages/flutter/lib/src/widgets/routes.dart b/packages/flutter/lib/src/widgets/routes.dart index 2df89b9c614..d8338dd9ff4 100644 --- a/packages/flutter/lib/src/widgets/routes.dart +++ b/packages/flutter/lib/src/widgets/routes.dart @@ -1740,6 +1740,7 @@ abstract class ModalRoute extends TransitionRoute with LocalHistoryRoute popEntry in _popEntries) { popEntry.onPopInvokedWithResult(didPop, result); } + super.onPopInvokedWithResult(didPop, result); } /// Enables this route to veto attempts by the user to dismiss it. @@ -1797,8 +1798,8 @@ abstract class ModalRoute extends TransitionRoute with LocalHistoryRoute popEntry) { _popEntries.add(popEntry); - popEntry.canPopNotifier.addListener(_handlePopEntryChange); - _handlePopEntryChange(); + popEntry.canPopNotifier.addListener(_maybeDispatchNavigationNotification); + _maybeDispatchNavigationNotification(); } /// Unregisters a [PopEntry] in the route's widget subtree. @@ -1808,11 +1809,11 @@ abstract class ModalRoute extends TransitionRoute with LocalHistoryRoute popEntry) { _popEntries.remove(popEntry); - popEntry.canPopNotifier.removeListener(_handlePopEntryChange); - _handlePopEntryChange(); + popEntry.canPopNotifier.removeListener(_maybeDispatchNavigationNotification); + _maybeDispatchNavigationNotification(); } - void _handlePopEntryChange() { + void _maybeDispatchNavigationNotification() { if (!isCurrent) { return; } @@ -1881,6 +1882,7 @@ abstract class ModalRoute extends TransitionRoute with LocalHistoryRoute nextRoute) { super.didPopNext(nextRoute); changedInternalState(); + _maybeDispatchNavigationNotification(); } @override diff --git a/packages/flutter/test/widgets/navigator_test.dart b/packages/flutter/test/widgets/navigator_test.dart index 3407f61dcf5..b58ff849555 100644 --- a/packages/flutter/test/widgets/navigator_test.dart +++ b/packages/flutter/test/widgets/navigator_test.dart @@ -2894,8 +2894,8 @@ void main() { error.toStringDeep(), equalsIgnoringHashCodes( 'FlutterError\n' - ' The Navigator.onPopPage must be provided to use the\n' - ' Navigator.pages API\n', + ' Either onDidRemovePage or onPopPage must be provided to use the\n' + ' Navigator.pages API but not both.\n', ), ); });