Refactors page API (#137792)
fixes https://github.com/flutter/flutter/issues/137458
Chagnes:
1. Navigator.pop will always pop page based route
2. add a onDidRemovePage callback to replace onPopPage
3. Page.canPop and Page.onPopInvoked mirrors the PopScope, but in Page class.
migration guide https://github.com/flutter/website/pull/10523
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 04fbded..e619276 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 @@
},
child: Navigator(
key: _navigatorKey,
- onPopPage: (Route<void> route, void result) {
- if (!route.didPop(null)) {
- return false;
- }
+ onDidRemovePage: (Page<Object?> 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 0000000..b100069
--- /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<PageApiExampleApp> createState() => _PageApiExampleAppState();
+}
+
+class _PageApiExampleAppState extends State<PageApiExampleApp> {
+ final RouterDelegate<Object> delegate = MyRouterDelegate();
+
+ @override
+ Widget build(BuildContext context) {
+ return MaterialApp.router(
+ routerDelegate: delegate,
+ );
+ }
+}
+
+class MyRouterDelegate extends RouterDelegate<Object> with PopNavigatorRouterDelegateMixin<Object>, ChangeNotifier {
+ // This example doesn't use RouteInformationProvider.
+ @override
+ Future<void> setNewRoutePath(Object configuration) async => throw UnimplementedError();
+
+ @override
+ final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
+
+ 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<bool> _showConfirmDialog() async {
+ return await showDialog<bool>(
+ context: navigatorKey.currentContext!,
+ barrierDismissible: false,
+ builder: (BuildContext context) {
+ return AlertDialog(
+ title: const Text('Are you sure?'),
+ actions: <Widget>[
+ TextButton(
+ child: const Text('Cancel'),
+ onPressed: () {
+ Navigator.of(context).pop(false);
+ },
+ ),
+ TextButton(
+ child: const Text('Confirm'),
+ onPressed: () {
+ Navigator.of(context).pop(true);
+ },
+ ),
+ ],
+ );
+ },
+ ) ?? false;
+ }
+
+ Future<void> _handlePopDetails(bool didPop, void result) async {
+ if (didPop) {
+ showDetailPage = false;
+ return;
+ }
+ final bool confirmed = await _showConfirmDialog();
+ if (confirmed) {
+ showDetailPage = false;
+ }
+ }
+
+ List<Page<Object?>> _getPages() {
+ return <Page<Object?>>[
+ const MaterialPage<void>(key: ValueKey<String>('home'), child: _HomePage()),
+ if (showDetailPage)
+ MaterialPage<void>(
+ key: const ValueKey<String>('details'),
+ child: const _DetailsPage(),
+ canPop: false,
+ onPopInvoked: _handlePopDetails,
+ ),
+ ];
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Navigator(
+ key: navigatorKey,
+ pages: _getPages(),
+ onDidRemovePage: (Page<Object?> page) {
+ assert(page.key == const ValueKey<String>('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 0000000..c78205e
--- /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 e139953..a5c23cf 100644
--- a/packages/flutter/lib/src/cupertino/route.dart
+++ b/packages/flutter/lib/src/cupertino/route.dart
@@ -347,6 +347,8 @@
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 db781f5..a2a23a4 100644
--- a/packages/flutter/lib/src/material/page.dart
+++ b/packages/flutter/lib/src/material/page.dart
@@ -150,6 +150,8 @@
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 b5f9c48..8f539f3 100644
--- a/packages/flutter/lib/src/widgets/navigator.dart
+++ b/packages/flutter/lib/src/widgets/navigator.dart
@@ -86,6 +86,14 @@
/// [Navigator.pages] list is next updated.)
typedef PopPageCallback = bool Function(Route<dynamic> 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<Object?> page);
+
/// Indicates whether the current route should be popped.
///
/// Used as the return value for [Route.willPop].
@@ -173,6 +181,8 @@
RouteSettings get settings => _settings;
RouteSettings _settings;
+ bool get _isPageBased => settings is Page<Object?>;
+
/// The restoration scope ID to be used for the [RestorationScope] surrounding
/// this route.
///
@@ -344,7 +354,14 @@
///
/// * [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<Object?> page = settings as Page<Object?>;
+ if (!page.canPop) {
+ return RoutePopDisposition.doNotPop;
+ }
+ }
return isFirst ? RoutePopDisposition.bubble : RoutePopDisposition.pop;
}
@@ -366,7 +383,13 @@
/// 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<Object?> page = settings as Page<Object?>;
+ page.onPopInvoked(didPop, result);
+ }
+ }
/// Whether calling [didPop] would return false.
bool get willHandlePopInternally => false;
@@ -621,6 +644,15 @@
/// 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 @@
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 @@
/// 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<T> 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 @@
const Navigator({
super.key,
this.pages = const <Page<dynamic>>[],
+ @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 @@
this.requestFocus = true,
this.restorationScopeId,
this.routeTraversalEdgeBehavior = kDefaultRouteTraversalEdgeBehavior,
+ this.onDidRemovePage,
});
/// The list of pages with which to populate the history.
@@ -1509,6 +1572,8 @@
/// corresponding to [pages] in the initial history.
final List<Page<dynamic>> 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 @@
/// 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 @@
currentState = _RouteLifecycle.idle;
return false;
}
+ route.onPopInvokedWithResult(true, pendingResult);
+ if (pageBased) {
+ final Page<Object?> page = route.settings as Page<Object?>;
+ navigator.widget.onDidRemovePage?.call(page);
+ }
pendingResult = null;
return true;
}
@@ -3120,7 +3209,6 @@
assert(isPresent);
pendingResult = result;
currentState = _RouteLifecycle.pop;
- route.onPopInvokedWithResult(true, result);
}
bool _reportRemovalToObserver = true;
@@ -3553,37 +3641,40 @@
}
}
+ 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 @@
@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 @@
// We aren't allowed to remove this route yet.
break;
}
+ if (entry.pageBased) {
+ widget.onDidRemovePage?.call(entry.route.settings as Page<Object?>);
+ }
entry.currentState = _RouteLifecycle.dispose;
continue;
case _RouteLifecycle.dispose:
@@ -5229,14 +5295,14 @@
// 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 @@
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 2df89b9..d8338dd 100644
--- a/packages/flutter/lib/src/widgets/routes.dart
+++ b/packages/flutter/lib/src/widgets/routes.dart
@@ -1740,6 +1740,7 @@
for (final PopEntry<Object?> 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 @@
/// * [unregisterPopEntry], which performs the opposite operation.
void registerPopEntry(PopEntry<Object?> 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 @@
/// * [registerPopEntry], which performs the opposite operation.
void unregisterPopEntry(PopEntry<Object?> 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 @@
void didPopNext(Route<dynamic> 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 3407f61..b58ff84 100644
--- a/packages/flutter/test/widgets/navigator_test.dart
+++ b/packages/flutter/test/widgets/navigator_test.dart
@@ -2894,8 +2894,8 @@
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',
),
);
});