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
This commit is contained in:
chunhtai 2024-05-13 15:45:51 -07:00 committed by GitHub
parent 27f683d6c3
commit a36ff80cf6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 358 additions and 76 deletions

View file

@ -168,14 +168,10 @@ class _BottomNavTabState extends State<_BottomNavTab> {
},
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) {

View file

@ -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'),
),
),
);
}
}

View file

@ -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);
});
}

View file

@ -347,6 +347,8 @@ class CupertinoPage<T> extends Page<T> {
this.title,
this.fullscreenDialog = false,
this.allowSnapshotting = true,
super.canPop,
super.onPopInvoked,
super.key,
super.name,
super.arguments,

View file

@ -150,6 +150,8 @@ class MaterialPage<T> extends Page<T> {
this.fullscreenDialog = false,
this.allowSnapshotting = true,
super.key,
super.canPop,
super.onPopInvoked,
super.name,
super.arguments,
super.restorationId,

View file

@ -86,6 +86,14 @@ typedef WillPopCallback = Future<bool> Function();
/// [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 @@ abstract class Route<T> extends _RoutePlaceholder {
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 @@ abstract class Route<T> 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<Object?> page = settings as Page<Object?>;
if (!page.canPop) {
return RoutePopDisposition.doNotPop;
}
}
return isFirst ? RoutePopDisposition.bubble : RoutePopDisposition.pop;
}
@ -366,7 +383,13 @@ abstract class Route<T> 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<Object?> page = settings as Page<Object?>;
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<T> 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<T> 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<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 @@ class Navigator extends StatefulWidget {
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 @@ 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<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 @@ 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<Object?> page = route.settings as Page<Object?>;
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<Navigator> 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<Navigator> 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<Navigator> with TickerProviderStateMixin, Res
// 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 @@ class NavigatorState extends State<Navigator> 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<Navigator> 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);

View file

@ -1740,6 +1740,7 @@ abstract class ModalRoute<T> extends TransitionRoute<T> with LocalHistoryRoute<T
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 @@ abstract class ModalRoute<T> extends TransitionRoute<T> with LocalHistoryRoute<T
/// * [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 @@ abstract class ModalRoute<T> extends TransitionRoute<T> with LocalHistoryRoute<T
/// * [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 @@ abstract class ModalRoute<T> extends TransitionRoute<T> with LocalHistoryRoute<T
void didPopNext(Route<dynamic> nextRoute) {
super.didPopNext(nextRoute);
changedInternalState();
_maybeDispatchNavigationNotification();
}
@override

View file

@ -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',
),
);
});