[go_router] Refactored RouteMatchList and imperative APIs (#5497)

This pr refactor RouteMatchList to be a tree structure.

Added a common base class RouteMatchBase. It is extended by both RouteMatch and ShellRouteMatch.

The RouteMatch is for GoRoute, and is always a leaf node

The ShellRouteMatch is for ShellRouteBase, and is always and intermediate node with a list of child RouteMatchBase[s].

This pr also redo how push is processed. Will add a doc explain this shortly.

This is a breaking change, will write a migration guide soon.

fixes https://github.com/flutter/flutter/issues/134524
fixes https://github.com/flutter/flutter/issues/130406
fixes https://github.com/flutter/flutter/issues/126365
fixes https://github.com/flutter/flutter/issues/125752
fixes https://github.com/flutter/flutter/issues/120791
fixes https://github.com/flutter/flutter/issues/120665
fixes https://github.com/flutter/flutter/issues/113001
fixes https://github.com/flutter/flutter/issues/110512
This commit is contained in:
chunhtai
2023-12-21 11:44:10 -08:00
committed by GitHub
parent 19384b8c07
commit 6660212f22
23 changed files with 1707 additions and 911 deletions

View File

@ -1,3 +1,10 @@
## 13.0.0
- Refactors `RouteMatchList` and imperative APIs.
- **BREAKING CHANGE**:
- RouteMatchList structure changed.
- Matching logic updated.
## 12.1.3
* Fixes a typo in `navigation.md`.

View File

@ -37,6 +37,7 @@ See the API documentation for details on the following topics:
- [Error handling](https://pub.dev/documentation/go_router/latest/topics/Error%20handling-topic.html)
## Migration Guides
- [Migrating to 13.0.0](https://flutter.dev/go/go-router-v13-breaking-changes).
- [Migrating to 12.0.0](https://flutter.dev/go/go-router-v12-breaking-changes).
- [Migrating to 11.0.0](https://flutter.dev/go/go-router-v11-breaking-changes).
- [Migrating to 10.0.0](https://flutter.dev/go/go-router-v10-breaking-changes).
@ -67,4 +68,3 @@ The project follows the same priority system as flutter framework.
[P3](https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-asc+label%3Ateam-go_router+label%3AP3+)
[Package PRs](https://github.com/flutter/packages/pulls?q=is%3Apr+is%3Aopen+label%3A%22p%3A+go_router%22%2C%22p%3A+go_router_builder%22)

View File

@ -68,6 +68,27 @@ Navigator.of(context).push(
);
```
The behavior may change depends on the shell route in current screen and the new screen.
If pushing a new screen without any shell route onto the current screen with shell route, the new
screen is placed entirely on top of the current screen.
![An animation shows a new screen push on top of current screen](https://flutter.github.io/assets-for-api-docs/assets/go_router/push_regular_route.gif)
If pushing a new screen with the same shell route as the current screen, the new
screen is placed inside of the shell.
![An animation shows pushing a new screen with the same shell as current screen](https://flutter.github.io/assets-for-api-docs/assets/go_router/push_same_shell.gif)
If pushing a new screen with the different shell route as the current screen, the new
screen along with the shell is placed entirely on top of the current screen.
![An animation shows pushing a new screen with the different shell as current screen](https://flutter.github.io/assets-for-api-docs/assets/go_router/push_different_shell.gif)
To try out the behavior yourself, see
[push_with_shell_route.dart](https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/extra_codec.dart).
## Returning values
Waiting for a value to be returned:

View File

@ -0,0 +1,160 @@
// Copyright 2013 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/material.dart';
import 'package:go_router/go_router.dart';
// This scenario demonstrates the behavior when pushing ShellRoute in various
// scenario.
//
// This example have three routes, /shell1, /shell2, and /regular-route. The
// /shell1 and /shell2 are nested in different ShellRoutes. The /regular-route
// is a simple GoRoute.
void main() {
runApp(PushWithShellRouteExampleApp());
}
/// An example demonstrating how to use [ShellRoute]
class PushWithShellRouteExampleApp extends StatelessWidget {
/// Creates a [PushWithShellRouteExampleApp]
PushWithShellRouteExampleApp({super.key});
final GoRouter _router = GoRouter(
initialLocation: '/home',
debugLogDiagnostics: true,
routes: <RouteBase>[
ShellRoute(
builder: (BuildContext context, GoRouterState state, Widget child) {
return ScaffoldForShell1(child: child);
},
routes: <RouteBase>[
GoRoute(
path: '/home',
builder: (BuildContext context, GoRouterState state) {
return const Home();
},
),
GoRoute(
path: '/shell1',
pageBuilder: (_, __) => const NoTransitionPage<void>(
child: Center(
child: Text('shell1 body'),
),
),
),
],
),
ShellRoute(
builder: (BuildContext context, GoRouterState state, Widget child) {
return ScaffoldForShell2(child: child);
},
routes: <RouteBase>[
GoRoute(
path: '/shell2',
builder: (BuildContext context, GoRouterState state) {
return const Center(child: Text('shell2 body'));
},
),
],
),
GoRoute(
path: '/regular-route',
builder: (BuildContext context, GoRouterState state) {
return const Scaffold(
body: Center(child: Text('regular route')),
);
},
),
],
);
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
routerConfig: _router,
);
}
}
/// Builds the "shell" for /shell1
class ScaffoldForShell1 extends StatelessWidget {
/// Constructs an [ScaffoldForShell1].
const ScaffoldForShell1({
required this.child,
super.key,
});
/// The widget to display in the body of the Scaffold.
/// In this sample, it is a Navigator.
final Widget child;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('shell1')),
body: child,
);
}
}
/// Builds the "shell" for /shell1
class ScaffoldForShell2 extends StatelessWidget {
/// Constructs an [ScaffoldForShell1].
const ScaffoldForShell2({
required this.child,
super.key,
});
/// The widget to display in the body of the Scaffold.
/// In this sample, it is a Navigator.
final Widget child;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('shell2')),
body: child,
);
}
}
/// The screen for /home
class Home extends StatelessWidget {
/// Constructs a [Home] widget.
const Home({super.key});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
TextButton(
onPressed: () {
GoRouter.of(context).push('/shell1');
},
child: const Text('push the same shell route /shell1'),
),
TextButton(
onPressed: () {
GoRouter.of(context).push('/shell2');
},
child: const Text('push the different shell route /shell2'),
),
TextButton(
onPressed: () {
GoRouter.of(context).push('/regular-route');
},
child: const Text('push the regular route /regular-route'),
),
],
),
);
}
}

View File

@ -0,0 +1,45 @@
// Copyright 2013 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_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:go_router_examples/push_with_shell_route.dart' as example;
void main() {
testWidgets('example works', (WidgetTester tester) async {
await tester.pumpWidget(example.PushWithShellRouteExampleApp());
expect(find.text('shell1'), findsOneWidget);
await tester.tap(find.text('push the same shell route /shell1'));
await tester.pumpAndSettle();
expect(find.text('shell1'), findsOneWidget);
expect(find.text('shell1 body'), findsOneWidget);
find.text('shell1 body').evaluate().first.pop();
await tester.pumpAndSettle();
expect(find.text('shell1'), findsOneWidget);
expect(find.text('shell1 body'), findsNothing);
await tester.tap(find.text('push the different shell route /shell2'));
await tester.pumpAndSettle();
expect(find.text('shell1'), findsNothing);
expect(find.text('shell2'), findsOneWidget);
expect(find.text('shell2 body'), findsOneWidget);
find.text('shell2 body').evaluate().first.pop();
await tester.pumpAndSettle();
expect(find.text('shell1'), findsOneWidget);
expect(find.text('shell2'), findsNothing);
await tester.tap(find.text('push the regular route /regular-route'));
await tester.pumpAndSettle();
expect(find.text('shell1'), findsNothing);
expect(find.text('regular route'), findsOneWidget);
find.text('regular route').evaluate().first.pop();
await tester.pumpAndSettle();
expect(find.text('shell1'), findsOneWidget);
expect(find.text('regular route'), findsNothing);
});
}

View File

@ -2,7 +2,6 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart';
import 'configuration.dart';
@ -23,6 +22,19 @@ typedef GoRouterBuilderWithNav = Widget Function(
Widget child,
);
typedef _PageBuilderForAppType = Page<void> Function({
required LocalKey key,
required String? name,
required Object? arguments,
required String restorationId,
required Widget child,
});
typedef _ErrorBuilderForAppType = Widget Function(
BuildContext context,
GoRouterState state,
);
/// Signature for a function that takes in a `route` to be popped with
/// the `result` and returns a boolean decision on whether the pop
/// is successful.
@ -32,7 +44,7 @@ typedef GoRouterBuilderWithNav = Widget Function(
///
/// Used by of [RouteBuilder.onPopPageWithRouteMatch].
typedef PopPageWithRouteMatchCallback = bool Function(
Route<dynamic> route, dynamic result, RouteMatch? match);
Route<dynamic> route, dynamic result, RouteMatchBase match);
/// Builds the top-level Navigator for GoRouter.
class RouteBuilder {
@ -74,8 +86,6 @@ class RouteBuilder {
/// changes.
final List<NavigatorObserver> observers;
final GoRouterStateRegistry _registry = GoRouterStateRegistry();
/// A callback called when a `route` produced by `match` is about to be popped
/// with the `result`.
///
@ -84,13 +94,6 @@ class RouteBuilder {
/// If this method returns false, this builder aborts the pop.
final PopPageWithRouteMatchCallback onPopPageWithRouteMatch;
/// Caches a HeroController for the nested Navigator, which solves cases where the
/// Hero Widget animation stops working when navigating.
// TODO(chunhtai): Remove _goHeroCache once below issue is fixed:
// https://github.com/flutter/flutter/issues/54200
final Map<GlobalKey<NavigatorState>, HeroController> _goHeroCache =
<GlobalKey<NavigatorState>, HeroController>{};
/// Builds the top-level Navigator for the given [RouteMatchList].
Widget build(
BuildContext context,
@ -102,339 +105,204 @@ class RouteBuilder {
// empty box until then.
return const SizedBox.shrink();
}
assert(
matchList.isError || !(matchList.last.route as GoRoute).redirectOnly);
assert(matchList.isError || !matchList.last.route.redirectOnly);
return builderWithNav(
context,
Builder(
builder: (BuildContext context) {
final Map<Page<Object?>, GoRouterState> newRegistry =
<Page<Object?>, GoRouterState>{};
final Widget result = tryBuild(context, matchList, routerNeglect,
configuration.navigatorKey, newRegistry);
_registry.updateRegistry(newRegistry);
return GoRouterStateRegistryScope(registry: _registry, child: result);
},
),
);
}
/// Builds the top-level Navigator by invoking the build method on each
/// matching route.
///
/// Throws a [_RouteBuilderError].
@visibleForTesting
Widget tryBuild(
BuildContext context,
RouteMatchList matchList,
bool routerNeglect,
GlobalKey<NavigatorState> navigatorKey,
Map<Page<Object?>, GoRouterState> registry,
) {
// TODO(chunhtai): move the state from local scope to a central place.
// https://github.com/flutter/flutter/issues/126365
final _PagePopContext pagePopContext =
_PagePopContext._(onPopPageWithRouteMatch);
return builderWithNav(
context,
_buildNavigator(
pagePopContext.onPopPage,
_buildPages(context, matchList, pagePopContext, routerNeglect,
navigatorKey, registry),
navigatorKey,
_CustomNavigator(
navigatorKey: configuration.navigatorKey,
observers: observers,
restorationScopeId: restorationScopeId,
requestFocus: requestFocus,
navigatorRestorationId: restorationScopeId,
onPopPageWithRouteMatch: onPopPageWithRouteMatch,
matchList: matchList,
matches: matchList.matches,
configuration: configuration,
errorBuilder: errorBuilder,
errorPageBuilder: errorPageBuilder,
),
);
}
}
/// Returns the top-level pages instead of the root navigator. Used for
/// testing.
List<Page<Object?>> _buildPages(
BuildContext context,
RouteMatchList matchList,
_PagePopContext pagePopContext,
bool routerNeglect,
GlobalKey<NavigatorState> navigatorKey,
Map<Page<Object?>, GoRouterState> registry) {
final Map<GlobalKey<NavigatorState>, List<Page<Object?>>> keyToPage;
if (matchList.isError) {
keyToPage = <GlobalKey<NavigatorState>, List<Page<Object?>>>{
navigatorKey: <Page<Object?>>[
_buildErrorPage(context, _buildErrorState(matchList)),
]
};
} else {
keyToPage = <GlobalKey<NavigatorState>, List<Page<Object?>>>{};
_buildRecursive(context, matchList, 0, pagePopContext, routerNeglect,
keyToPage, navigatorKey, registry);
class _CustomNavigator extends StatefulWidget {
const _CustomNavigator({
super.key,
required this.navigatorKey,
required this.observers,
required this.navigatorRestorationId,
required this.onPopPageWithRouteMatch,
required this.matchList,
required this.matches,
required this.configuration,
required this.errorBuilder,
required this.errorPageBuilder,
});
// Every Page should have a corresponding RouteMatch.
assert(keyToPage.values.flattened.every((Page<Object?> page) =>
pagePopContext.getRouteMatchesForPage(page) != null));
final GlobalKey<NavigatorState> navigatorKey;
final List<NavigatorObserver> observers;
/// The actual [RouteMatchBase]s to be built.
///
/// This can be different from matches in [matchList] if this widget is used
/// to build navigator in shell route. In this case, these matches come from
/// the [ShellRouteMatch.matches].
final List<RouteMatchBase> matches;
final RouteMatchList matchList;
final RouteConfiguration configuration;
final PopPageWithRouteMatchCallback onPopPageWithRouteMatch;
final String? navigatorRestorationId;
final GoRouterWidgetBuilder? errorBuilder;
final GoRouterPageBuilder? errorPageBuilder;
@override
State<StatefulWidget> createState() => _CustomNavigatorState();
}
class _CustomNavigatorState extends State<_CustomNavigator> {
HeroController? _controller;
late Map<Page<Object?>, RouteMatchBase> _pageToRouteMatchBase;
final GoRouterStateRegistry _registry = GoRouterStateRegistry();
List<Page<Object?>>? _pages;
@override
void didUpdateWidget(_CustomNavigator oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.matchList != oldWidget.matchList) {
_pages = null;
}
/// Clean up previous cache to prevent memory leak, making sure any nested
/// stateful shell routes for the current match list are kept.
final Set<Key> activeKeys = keyToPage.keys.toSet()
..addAll(_nestedStatefulNavigatorKeys(matchList));
_goHeroCache.removeWhere(
(GlobalKey<NavigatorState> key, _) => !activeKeys.contains(key));
return keyToPage[navigatorKey]!;
}
static Set<GlobalKey<NavigatorState>> _nestedStatefulNavigatorKeys(
RouteMatchList matchList) {
final StatefulShellRoute? shellRoute =
matchList.routes.whereType<StatefulShellRoute>().firstOrNull;
if (shellRoute == null) {
return <GlobalKey<NavigatorState>>{};
}
return RouteBase.routesRecursively(<RouteBase>[shellRoute])
.whereType<StatefulShellRoute>()
.expand((StatefulShellRoute e) =>
e.branches.map((StatefulShellBranch b) => b.navigatorKey))
.toSet();
}
void _buildRecursive(
BuildContext context,
RouteMatchList matchList,
int startIndex,
_PagePopContext pagePopContext,
bool routerNeglect,
Map<GlobalKey<NavigatorState>, List<Page<Object?>>> keyToPages,
GlobalKey<NavigatorState> navigatorKey,
Map<Page<Object?>, GoRouterState> registry,
) {
if (startIndex >= matchList.matches.length) {
return;
}
final RouteMatch match = matchList.matches[startIndex];
final RouteBase route = match.route;
final GoRouterState state = buildState(matchList, match);
Page<Object?>? page;
if (state.error != null) {
page = _buildErrorPage(context, state);
keyToPages.putIfAbsent(navigatorKey, () => <Page<Object?>>[]).add(page);
_buildRecursive(context, matchList, startIndex + 1, pagePopContext,
routerNeglect, keyToPages, navigatorKey, registry);
} else {
// If this RouteBase is for a different Navigator, add it to the
// list of out of scope pages
final GlobalKey<NavigatorState> routeNavKey =
route.parentNavigatorKey ?? navigatorKey;
if (route is GoRoute) {
page =
_buildPageForGoRoute(context, state, match, route, pagePopContext);
assert(page != null || route.redirectOnly);
if (page != null) {
keyToPages
.putIfAbsent(routeNavKey, () => <Page<Object?>>[])
.add(page);
}
_buildRecursive(context, matchList, startIndex + 1, pagePopContext,
routerNeglect, keyToPages, navigatorKey, registry);
} else if (route is ShellRouteBase) {
assert(startIndex + 1 < matchList.matches.length,
'Shell routes must always have child routes');
// Add an entry for the parent navigator if none exists.
//
// Calling _buildRecursive can result in adding pages to the
// parentNavigatorKey entry's list. Store the current length so
// that the page for this ShellRoute is placed at the right index.
final int shellPageIdx =
keyToPages.putIfAbsent(routeNavKey, () => <Page<Object?>>[]).length;
// Find the the navigator key for the sub-route of this shell route.
final RouteBase subRoute = matchList.matches[startIndex + 1].route;
final GlobalKey<NavigatorState> shellNavigatorKey =
route.navigatorKeyForSubRoute(subRoute);
keyToPages.putIfAbsent(shellNavigatorKey, () => <Page<Object?>>[]);
// Build the remaining pages
_buildRecursive(context, matchList, startIndex + 1, pagePopContext,
routerNeglect, keyToPages, shellNavigatorKey, registry);
final HeroController heroController = _goHeroCache.putIfAbsent(
shellNavigatorKey, () => _getHeroController(context));
// Build the Navigator for this shell route
Widget buildShellNavigator(
List<NavigatorObserver>? observers,
String? restorationScopeId, {
bool requestFocus = true,
}) {
return _buildNavigator(
pagePopContext.onPopPage,
keyToPages[shellNavigatorKey]!,
shellNavigatorKey,
observers: observers ?? const <NavigatorObserver>[],
restorationScopeId: restorationScopeId,
heroController: heroController,
requestFocus: requestFocus,
);
}
// Call the ShellRouteBase to create/update the shell route state
final ShellRouteContext shellRouteContext = ShellRouteContext(
route: route,
routerState: state,
navigatorKey: shellNavigatorKey,
routeMatchList: matchList,
navigatorBuilder: buildShellNavigator,
);
// Build the Page for this route
page = _buildPageForShellRoute(
context, state, match, route, pagePopContext, shellRouteContext);
// Place the ShellRoute's Page onto the list for the parent navigator.
keyToPages[routeNavKey]!.insert(shellPageIdx, page);
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Create a HeroController based on the app type.
if (_controller == null) {
if (isMaterialApp(context)) {
_controller = createMaterialHeroController();
} else if (isCupertinoApp(context)) {
_controller = createCupertinoHeroController();
} else {
_controller = HeroController();
}
}
if (page != null) {
registry[page] = state;
// Insert the route match in reverse order.
pagePopContext._insertRouteMatchAtStartForPage(page, match);
}
// This method can also be called if any of the page builders depend on
// the context. In this case, make sure _pages are rebuilt.
_pages = null;
}
static Widget _buildNavigator(
PopPageCallback onPopPage,
List<Page<Object?>> pages,
Key? navigatorKey, {
List<NavigatorObserver> observers = const <NavigatorObserver>[],
String? restorationScopeId,
HeroController? heroController,
bool requestFocus = true,
}) {
final Widget navigator = Navigator(
key: navigatorKey,
restorationScopeId: restorationScopeId,
pages: pages,
observers: observers,
onPopPage: onPopPage,
requestFocus: requestFocus,
);
if (heroController != null) {
return HeroControllerScope(
controller: heroController,
child: navigator,
);
void _updatePages(BuildContext context) {
assert(_pages == null);
final List<Page<Object?>> pages = <Page<Object?>>[];
final Map<Page<Object?>, RouteMatchBase> pageToRouteMatchBase =
<Page<Object?>, RouteMatchBase>{};
final Map<Page<Object?>, GoRouterState> registry =
<Page<Object?>, GoRouterState>{};
if (widget.matchList.isError) {
pages.add(_buildErrorPage(context, widget.matchList));
} else {
return navigator;
}
}
/// Helper method that builds a [GoRouterState] object for the given [match]
/// and [pathParameters].
@visibleForTesting
GoRouterState buildState(RouteMatchList matchList, RouteMatch match) {
final RouteBase route = match.route;
String? name;
String path = '';
if (route is GoRoute) {
name = route.name;
path = route.path;
}
final RouteMatchList effectiveMatchList;
if (match is ImperativeRouteMatch) {
effectiveMatchList = match.matches;
if (effectiveMatchList.isError) {
return _buildErrorState(effectiveMatchList);
for (final RouteMatchBase match in widget.matches) {
final Page<Object?>? page = _buildPage(context, match);
if (page == null) {
continue;
}
pages.add(page);
pageToRouteMatchBase[page] = match;
registry[page] =
match.buildState(widget.configuration, widget.matchList);
}
} else {
effectiveMatchList = matchList;
assert(!effectiveMatchList.isError);
}
return GoRouterState(
configuration,
uri: effectiveMatchList.uri,
matchedLocation: match.matchedLocation,
name: name,
path: path,
fullPath: effectiveMatchList.fullPath,
pathParameters:
Map<String, String>.from(effectiveMatchList.pathParameters),
error: effectiveMatchList.error,
extra: effectiveMatchList.extra,
pageKey: match.pageKey,
);
_pages = pages;
_registry.updateRegistry(registry);
_pageToRouteMatchBase = pageToRouteMatchBase;
}
/// Builds a [Page] for [GoRoute]
Page<Object?>? _buildPageForGoRoute(BuildContext context, GoRouterState state,
RouteMatch match, GoRoute route, _PagePopContext pagePopContext) {
// Call the pageBuilder if it's non-null
final GoRouterPageBuilder? pageBuilder = route.pageBuilder;
Page<Object?>? _buildPage(BuildContext context, RouteMatchBase match) {
if (match is RouteMatch) {
if (match is ImperativeRouteMatch && match.matches.isError) {
return _buildErrorPage(context, match.matches);
}
return _buildPageForGoRoute(context, match);
}
if (match is ShellRouteMatch) {
return _buildPageForShellRoute(context, match);
}
throw GoError('unknown match type ${match.runtimeType}');
}
/// Builds a [Page] for a [RouteMatch]
Page<Object?>? _buildPageForGoRoute(BuildContext context, RouteMatch match) {
final GoRouterPageBuilder? pageBuilder = match.route.pageBuilder;
final GoRouterState state =
match.buildState(widget.configuration, widget.matchList);
if (pageBuilder != null) {
final Page<Object?> page = pageBuilder(context, state);
if (page is! NoOpPage) {
return page;
}
}
return _callGoRouteBuilder(context, state, route);
}
/// Calls the user-provided route builder from the [GoRoute].
Page<Object?>? _callGoRouteBuilder(
BuildContext context, GoRouterState state, GoRoute route) {
final GoRouterWidgetBuilder? builder = route.builder;
final GoRouterWidgetBuilder? builder = match.route.builder;
if (builder == null) {
return null;
}
return buildPage(context, state, Builder(builder: (BuildContext context) {
return _buildPlatformAdapterPage(context, state,
Builder(builder: (BuildContext context) {
return builder(context, state);
}));
}
/// Builds a [Page] for [ShellRouteBase]
/// Builds a [Page] for a [ShellRouteMatch]
Page<Object?> _buildPageForShellRoute(
BuildContext context,
GoRouterState state,
RouteMatch match,
ShellRouteBase route,
_PagePopContext pagePopContext,
ShellRouteContext shellRouteContext) {
Page<Object?>? page = route.buildPage(context, state, shellRouteContext);
if (page is NoOpPage) {
page = null;
BuildContext context,
ShellRouteMatch match,
) {
final GoRouterState state =
match.buildState(widget.configuration, widget.matchList);
final GlobalKey<NavigatorState> navigatorKey = match.navigatorKey;
final ShellRouteContext shellRouteContext = ShellRouteContext(
route: match.route,
routerState: state,
navigatorKey: navigatorKey,
routeMatchList: widget.matchList,
navigatorBuilder:
(List<NavigatorObserver>? observers, String? restorationScopeId) {
return _CustomNavigator(
// The state needs to persist across rebuild.
key: GlobalObjectKey(navigatorKey.hashCode),
navigatorRestorationId: restorationScopeId,
navigatorKey: navigatorKey,
matches: match.matches,
matchList: widget.matchList,
configuration: widget.configuration,
observers: observers ?? const <NavigatorObserver>[],
onPopPageWithRouteMatch: widget.onPopPageWithRouteMatch,
// This is used to recursively build pages under this shell route.
errorBuilder: widget.errorBuilder,
errorPageBuilder: widget.errorPageBuilder,
);
},
);
final Page<Object?>? page =
match.route.buildPage(context, state, shellRouteContext);
if (page != null && page is! NoOpPage) {
return page;
}
// Return the result of the route's builder() or pageBuilder()
return page ??
buildPage(context, state, Builder(builder: (BuildContext context) {
return _callShellRouteBaseBuilder(
context, state, route, shellRouteContext);
}));
}
/// Calls the user-provided route builder from the [ShellRouteBase].
Widget _callShellRouteBaseBuilder(BuildContext context, GoRouterState state,
ShellRouteBase route, ShellRouteContext? shellRouteContext) {
assert(shellRouteContext != null,
'ShellRouteContext must be provided for ${route.runtimeType}');
final Widget? widget =
route.buildWidget(context, state, shellRouteContext!);
if (widget == null) {
throw GoError('No builder provided to ShellRoute: $route');
}
return widget;
return _buildPlatformAdapterPage(
context,
state,
Builder(
builder: (BuildContext context) {
return match.route.buildWidget(context, state, shellRouteContext)!;
},
),
);
}
_PageBuilderForAppType? _pageBuilderForAppType;
Widget Function(
BuildContext context,
GoRouterState state,
)? _errorBuilderForAppType;
_ErrorBuilderForAppType? _errorBuilderForAppType;
void _cacheAppType(BuildContext context) {
// cache app type-specific page and error builders
@ -456,7 +324,20 @@ class RouteBuilder {
(BuildContext c, GoRouterState s) => CupertinoErrorScreen(s.error);
} else {
log('Using WidgetsApp configuration');
_pageBuilderForAppType = pageBuilderForWidgetApp;
_pageBuilderForAppType = ({
required LocalKey key,
required String? name,
required Object? arguments,
required String restorationId,
required Widget child,
}) =>
NoTransitionPage<void>(
name: name,
arguments: arguments,
key: key,
restorationId: restorationId,
child: child,
);
_errorBuilderForAppType =
(BuildContext c, GoRouterState s) => ErrorScreen(s.error);
}
@ -467,8 +348,7 @@ class RouteBuilder {
}
/// builds the page based on app type, i.e. MaterialApp vs. CupertinoApp
@visibleForTesting
Page<Object?> buildPage(
Page<Object?> _buildPlatformAdapterPage(
BuildContext context,
GoRouterState state,
Widget child,
@ -487,26 +367,10 @@ class RouteBuilder {
);
}
/// Builds a page without any transitions.
Page<void> pageBuilderForWidgetApp({
required LocalKey key,
required String? name,
required Object? arguments,
required String restorationId,
required Widget child,
}) =>
NoTransitionPage<void>(
name: name,
arguments: arguments,
key: key,
restorationId: restorationId,
child: child,
);
GoRouterState _buildErrorState(RouteMatchList matchList) {
assert(matchList.isError);
return GoRouterState(
configuration,
widget.configuration,
uri: matchList.uri,
matchedLocation: matchList.uri.path,
fullPath: matchList.fullPath,
@ -517,7 +381,8 @@ class RouteBuilder {
}
/// Builds a an error page.
Page<void> _buildErrorPage(BuildContext context, GoRouterState state) {
Page<void> _buildErrorPage(BuildContext context, RouteMatchList matchList) {
final GoRouterState state = _buildErrorState(matchList);
assert(state.error != null);
// If the error page builder is provided, use that, otherwise, if the error
@ -525,10 +390,10 @@ class RouteBuilder {
// MaterialPage). Finally, if nothing is provided, use a default error page
// wrapped in the app-specific page.
_cacheAppType(context);
final GoRouterWidgetBuilder? errorBuilder = this.errorBuilder;
return errorPageBuilder != null
? errorPageBuilder!(context, state)
: buildPage(
final GoRouterWidgetBuilder? errorBuilder = widget.errorBuilder;
return widget.errorPageBuilder != null
? widget.errorPageBuilder!(context, state)
: _buildPlatformAdapterPage(
context,
state,
errorBuilder != null
@ -537,66 +402,30 @@ class RouteBuilder {
);
}
/// Return a HeroController based on the app type.
HeroController _getHeroController(BuildContext context) {
if (context is Element) {
if (isMaterialApp(context)) {
return createMaterialHeroController();
} else if (isCupertinoApp(context)) {
return createCupertinoHeroController();
}
}
return HeroController();
}
}
typedef _PageBuilderForAppType = Page<void> Function({
required LocalKey key,
required String? name,
required Object? arguments,
required String restorationId,
required Widget child,
});
/// Context used to provide a route to page association when popping routes.
class _PagePopContext {
_PagePopContext._(this.onPopPageWithRouteMatch);
/// A page can be mapped to a RouteMatch list, such as a const page being
/// pushed multiple times.
final Map<Page<dynamic>, List<RouteMatch>> _routeMatchesLookUp =
<Page<Object?>, List<RouteMatch>>{};
/// On pop page callback that includes the associated [RouteMatch].
final PopPageWithRouteMatchCallback onPopPageWithRouteMatch;
/// Looks for the [RouteMatch] for a given [Page].
///
/// The [Page] must have been previously built via the [RouteBuilder] that
/// created this [PagePopContext]; otherwise, this method returns null.
List<RouteMatch>? getRouteMatchesForPage(Page<Object?> page) =>
_routeMatchesLookUp[page];
/// This is called in _buildRecursive to insert route matches in reverse order.
void _insertRouteMatchAtStartForPage(Page<Object?> page, RouteMatch match) {
_routeMatchesLookUp
.putIfAbsent(page, () => <RouteMatch>[])
.insert(0, match);
}
/// Function used as [Navigator.onPopPage] callback when creating Navigators.
///
/// This function forwards to [onPopPageWithRouteMatch], including the
/// [RouteMatch] associated with the popped route.
///
/// This assumes always pop the last route match for the page.
bool onPopPage(Route<dynamic> route, dynamic result) {
bool _handlePopPage(Route<Object?> route, Object? result) {
final Page<Object?> page = route.settings as Page<Object?>;
final RouteMatch match = _routeMatchesLookUp[page]!.last;
if (onPopPageWithRouteMatch(route, result, match)) {
_routeMatchesLookUp[page]!.removeLast();
return true;
final RouteMatchBase match = _pageToRouteMatchBase[page]!;
return widget.onPopPageWithRouteMatch(route, result, match);
}
@override
Widget build(BuildContext context) {
if (_pages == null) {
_updatePages(context);
}
return false;
assert(_pages != null);
return GoRouterStateRegistryScope(
registry: _registry,
child: HeroControllerScope(
controller: _controller!,
child: Navigator(
key: widget.navigatorKey,
restorationScopeId: widget.navigatorRestorationId,
pages: _pages!,
observers: widget.observers,
onPopPage: _handlePopPage,
),
),
);
}
}

View File

@ -295,9 +295,10 @@ class RouteConfiguration {
final Uri uri = Uri.parse(canonicalUri(location));
final Map<String, String> pathParameters = <String, String>{};
final List<RouteMatch>? matches = _getLocRouteMatches(uri, pathParameters);
final List<RouteMatchBase> matches =
_getLocRouteMatches(uri, pathParameters);
if (matches == null) {
if (matches.isEmpty) {
return _errorRouteMatchList(
uri,
GoException('no routes for location: $uri'),
@ -328,96 +329,20 @@ class RouteConfiguration {
return result;
}
List<RouteMatch>? _getLocRouteMatches(
List<RouteMatchBase> _getLocRouteMatches(
Uri uri, Map<String, String> pathParameters) {
final List<RouteMatch>? result = _getLocRouteRecursively(
location: uri.path,
remainingLocation: uri.path,
matchedLocation: '',
matchedPath: '',
pathParameters: pathParameters,
routes: _routingConfig.value.routes,
);
return result;
}
List<RouteMatch>? _getLocRouteRecursively({
required String location,
required String remainingLocation,
required String matchedLocation,
required String matchedPath,
required Map<String, String> pathParameters,
required List<RouteBase> routes,
}) {
List<RouteMatch>? result;
late Map<String, String> subPathParameters;
// find the set of matches at this level of the tree
for (final RouteBase route in routes) {
subPathParameters = <String, String>{};
final RouteMatch? match = RouteMatch.match(
for (final RouteBase route in _routingConfig.value.routes) {
final List<RouteMatchBase> result = RouteMatchBase.match(
rootNavigatorKey: navigatorKey,
route: route,
remainingLocation: remainingLocation,
matchedLocation: matchedLocation,
matchedPath: matchedPath,
pathParameters: subPathParameters,
uri: uri,
pathParameters: pathParameters,
);
if (match == null) {
continue;
if (result.isNotEmpty) {
return result;
}
if (match.route is GoRoute &&
match.matchedLocation.toLowerCase() == location.toLowerCase()) {
// If it is a complete match, then return the matched route
// NOTE: need a lower case match because matchedLocation is canonicalized to match
// the path case whereas the location can be of any case and still match
result = <RouteMatch>[match];
} else if (route.routes.isEmpty) {
// If it is partial match but no sub-routes, bail.
continue;
} else {
// Otherwise, recurse
final String childRestLoc;
final String newParentSubLoc;
final String newParentPath;
if (match.route is ShellRouteBase) {
childRestLoc = remainingLocation;
newParentSubLoc = matchedLocation;
newParentPath = matchedPath;
} else {
assert(location.startsWith(match.matchedLocation));
assert(remainingLocation.isNotEmpty);
childRestLoc = location.substring(match.matchedLocation.length +
(match.matchedLocation == '/' ? 0 : 1));
newParentSubLoc = match.matchedLocation;
newParentPath =
concatenatePaths(matchedPath, (match.route as GoRoute).path);
}
final List<RouteMatch>? subRouteMatch = _getLocRouteRecursively(
location: location,
remainingLocation: childRestLoc,
matchedLocation: newParentSubLoc,
matchedPath: newParentPath,
pathParameters: subPathParameters,
routes: route.routes,
);
// If there's no sub-route matches, there is no match for this location
if (subRouteMatch == null) {
continue;
}
result = <RouteMatch>[match, ...subRouteMatch];
}
// Should only reach here if there is a match.
break;
}
if (result != null) {
pathParameters.addAll(subPathParameters);
}
return result;
return const <RouteMatchBase>[];
}
/// Processes redirects by returning a new [RouteMatchList] representing the new
@ -468,8 +393,17 @@ class RouteConfiguration {
return prevMatchList;
}
final List<RouteMatch> routeMatches = <RouteMatch>[];
prevMatchList.visitRouteMatches((RouteMatchBase match) {
if (match is RouteMatch) {
routeMatches.add(match);
}
return true;
});
final FutureOr<String?> routeLevelRedirectResult =
_getRouteLevelRedirect(context, prevMatchList, 0);
_getRouteLevelRedirect(context, prevMatchList, routeMatches, 0);
if (routeLevelRedirectResult is String?) {
return processRouteLevelRedirect(routeLevelRedirectResult);
}
@ -499,33 +433,23 @@ class RouteConfiguration {
FutureOr<String?> _getRouteLevelRedirect(
BuildContext context,
RouteMatchList matchList,
List<RouteMatch> routeMatches,
int currentCheckIndex,
) {
if (currentCheckIndex >= matchList.matches.length) {
if (currentCheckIndex >= routeMatches.length) {
return null;
}
final RouteMatch match = matchList.matches[currentCheckIndex];
final RouteMatch match = routeMatches[currentCheckIndex];
FutureOr<String?> processRouteRedirect(String? newLocation) =>
newLocation ??
_getRouteLevelRedirect(context, matchList, currentCheckIndex + 1);
final RouteBase route = match.route;
_getRouteLevelRedirect(
context, matchList, routeMatches, currentCheckIndex + 1);
final GoRoute route = match.route;
FutureOr<String?> routeRedirectResult;
if (route is GoRoute && route.redirect != null) {
final RouteMatchList effectiveMatchList =
match is ImperativeRouteMatch ? match.matches : matchList;
if (route.redirect != null) {
routeRedirectResult = route.redirect!(
context,
GoRouterState(
this,
uri: effectiveMatchList.uri,
matchedLocation: match.matchedLocation,
name: route.name,
path: route.path,
fullPath: effectiveMatchList.fullPath,
extra: effectiveMatchList.extra,
pathParameters: effectiveMatchList.pathParameters,
pageKey: match.pageKey,
),
match.buildState(this, matchList),
);
}
if (routeRedirectResult is String?) {

View File

@ -50,21 +50,27 @@ class GoRouterDelegate extends RouterDelegate<RouteMatchList>
final RouteConfiguration _configuration;
_NavigatorStateIterator _createNavigatorStateIterator() =>
_NavigatorStateIterator(currentConfiguration, navigatorKey.currentState!);
@override
Future<bool> popRoute() async {
final _NavigatorStateIterator iterator = _createNavigatorStateIterator();
while (iterator.moveNext()) {
final bool didPop = await iterator.current.maybePop();
if (didPop) {
return true;
NavigatorState? state = navigatorKey.currentState;
if (state == null) {
return false;
}
if (!state.canPop()) {
state = null;
}
RouteMatchBase walker = currentConfiguration.matches.last;
while (walker is ShellRouteMatch) {
if (walker.navigatorKey.currentState?.canPop() ?? false) {
state = walker.navigatorKey.currentState;
}
walker = walker.matches.last;
}
if (state != null) {
return state.maybePop();
}
// This should be the only place where the last GoRoute exit the screen.
final GoRoute lastRoute =
currentConfiguration.matches.last.route as GoRoute;
final GoRoute lastRoute = currentConfiguration.last.route;
if (lastRoute.onExit != null && navigatorKey.currentContext != null) {
return !(await lastRoute.onExit!(navigatorKey.currentContext!));
}
@ -73,25 +79,36 @@ class GoRouterDelegate extends RouterDelegate<RouteMatchList>
/// Returns `true` if the active Navigator can pop.
bool canPop() {
final _NavigatorStateIterator iterator = _createNavigatorStateIterator();
while (iterator.moveNext()) {
if (iterator.current.canPop()) {
if (navigatorKey.currentState?.canPop() ?? false) {
return true;
}
RouteMatchBase walker = currentConfiguration.matches.last;
while (walker is ShellRouteMatch) {
if (walker.navigatorKey.currentState?.canPop() ?? false) {
return true;
}
walker = walker.matches.last;
}
return false;
}
/// Pops the top-most route.
void pop<T extends Object?>([T? result]) {
final _NavigatorStateIterator iterator = _createNavigatorStateIterator();
while (iterator.moveNext()) {
if (iterator.current.canPop()) {
iterator.current.pop<T>(result);
return;
}
NavigatorState? state;
if (navigatorKey.currentState?.canPop() ?? false) {
state = navigatorKey.currentState;
}
throw GoError('There is nothing to pop');
RouteMatchBase walker = currentConfiguration.matches.last;
while (walker is ShellRouteMatch) {
if (walker.navigatorKey.currentState?.canPop() ?? false) {
state = walker.navigatorKey.currentState;
}
walker = walker.matches.last;
}
if (state == null) {
throw GoError('There is nothing to pop');
}
state.pop(result);
}
void _debugAssertMatchListNotEmpty() {
@ -103,14 +120,13 @@ class GoRouterDelegate extends RouterDelegate<RouteMatchList>
}
bool _handlePopPageWithRouteMatch(
Route<Object?> route, Object? result, RouteMatch? match) {
Route<Object?> route, Object? result, RouteMatchBase match) {
if (route.willHandlePopInternally) {
final bool popped = route.didPop(result);
assert(!popped);
return popped;
}
assert(match != null);
final RouteBase routeBase = match!.route;
final RouteBase routeBase = match.route;
if (routeBase is! GoRoute || routeBase.onExit == null) {
route.didPop(result);
_completeRouteMatch(result, match);
@ -130,7 +146,7 @@ class GoRouterDelegate extends RouterDelegate<RouteMatchList>
return false;
}
void _completeRouteMatch(Object? result, RouteMatch match) {
void _completeRouteMatch(Object? result, RouteMatchBase match) {
if (match is ImperativeRouteMatch) {
match.complete(result);
}
@ -173,25 +189,41 @@ class GoRouterDelegate extends RouterDelegate<RouteMatchList>
final BuildContext? navigatorContext = navigatorKey.currentContext;
// If navigator is not built or disposed, the GoRoute.onExit is irrelevant.
if (navigatorContext != null) {
final List<RouteMatch> currentGoRouteMatches = <RouteMatch>[];
currentConfiguration.visitRouteMatches((RouteMatchBase match) {
if (match is RouteMatch) {
currentGoRouteMatches.add(match);
}
return true;
});
final List<RouteMatch> newGoRouteMatches = <RouteMatch>[];
configuration.visitRouteMatches((RouteMatchBase match) {
if (match is RouteMatch) {
newGoRouteMatches.add(match);
}
return true;
});
final int compareUntil = math.min(
currentConfiguration.matches.length,
configuration.matches.length,
currentGoRouteMatches.length,
newGoRouteMatches.length,
);
int indexOfFirstDiff = 0;
for (; indexOfFirstDiff < compareUntil; indexOfFirstDiff++) {
if (currentConfiguration.matches[indexOfFirstDiff] !=
configuration.matches[indexOfFirstDiff]) {
if (currentGoRouteMatches[indexOfFirstDiff] !=
newGoRouteMatches[indexOfFirstDiff]) {
break;
}
}
if (indexOfFirstDiff < currentConfiguration.matches.length) {
final List<GoRoute> exitingGoRoutes = currentConfiguration.matches
if (indexOfFirstDiff < currentGoRouteMatches.length) {
final List<GoRoute> exitingGoRoutes = currentGoRouteMatches
.sublist(indexOfFirstDiff)
.map<RouteBase>((RouteMatch match) => match.route)
.whereType<GoRoute>()
.toList();
return _callOnExitStartsAt(exitingGoRoutes.length - 1,
navigatorContext: navigatorContext, routes: exitingGoRoutes)
context: navigatorContext, routes: exitingGoRoutes)
.then<void>((bool exit) {
if (!exit) {
return SynchronousFuture<void>(null);
@ -209,25 +241,23 @@ class GoRouterDelegate extends RouterDelegate<RouteMatchList>
/// The returned future resolves to true if all routes below the index all
/// return true. Otherwise, the returned future resolves to false.
static Future<bool> _callOnExitStartsAt(int index,
{required BuildContext navigatorContext, required List<GoRoute> routes}) {
{required BuildContext context, required List<GoRoute> routes}) {
if (index < 0) {
return SynchronousFuture<bool>(true);
}
final GoRoute goRoute = routes[index];
if (goRoute.onExit == null) {
return _callOnExitStartsAt(index - 1,
navigatorContext: navigatorContext, routes: routes);
return _callOnExitStartsAt(index - 1, context: context, routes: routes);
}
Future<bool> handleOnExitResult(bool exit) {
if (exit) {
return _callOnExitStartsAt(index - 1,
navigatorContext: navigatorContext, routes: routes);
return _callOnExitStartsAt(index - 1, context: context, routes: routes);
}
return SynchronousFuture<bool>(false);
}
final FutureOr<bool> exitFuture = goRoute.onExit!(navigatorContext);
final FutureOr<bool> exitFuture = goRoute.onExit!(context);
if (exitFuture is bool) {
return handleOnExitResult(exitFuture);
}
@ -240,73 +270,3 @@ class GoRouterDelegate extends RouterDelegate<RouteMatchList>
return SynchronousFuture<void>(null);
}
}
/// An iterator that iterates through navigators that [GoRouterDelegate]
/// created from the inner to outer.
///
/// The iterator starts with the navigator that hosts the top-most route. This
/// navigator may not be the inner-most navigator if the top-most route is a
/// pageless route, such as a dialog or bottom sheet.
class _NavigatorStateIterator implements Iterator<NavigatorState> {
_NavigatorStateIterator(this.matchList, this.root)
: index = matchList.matches.length - 1;
final RouteMatchList matchList;
int index;
final NavigatorState root;
@override
late NavigatorState current;
RouteBase _getRouteAtIndex(int index) => matchList.matches[index].route;
void _findsNextIndex() {
final GlobalKey<NavigatorState>? parentNavigatorKey =
_getRouteAtIndex(index).parentNavigatorKey;
if (parentNavigatorKey == null) {
index -= 1;
return;
}
for (index -= 1; index >= 0; index -= 1) {
final RouteBase route = _getRouteAtIndex(index);
if (route is ShellRouteBase) {
if (route.navigatorKeyForSubRoute(_getRouteAtIndex(index + 1)) ==
parentNavigatorKey) {
return;
}
}
}
assert(root == parentNavigatorKey.currentState);
}
@override
bool moveNext() {
if (index < 0) {
return false;
}
_findsNextIndex();
while (index >= 0) {
final RouteBase route = _getRouteAtIndex(index);
if (route is ShellRouteBase) {
final GlobalKey<NavigatorState> navigatorKey =
route.navigatorKeyForSubRoute(_getRouteAtIndex(index + 1));
// Must have a ModalRoute parent because the navigator ShellRoute
// created must not be the root navigator.
final ModalRoute<Object?> parentModalRoute =
ModalRoute.of(navigatorKey.currentContext!)!;
// There may be pageless route on top of ModalRoute that the
// parentNavigatorKey is in. For example an open dialog.
if (parentModalRoute.isCurrent) {
current = navigatorKey.currentState!;
return true;
}
}
_findsNextIndex();
}
assert(index == -1);
current = root;
return true;
}
}

View File

@ -16,65 +16,23 @@ import 'logging.dart';
import 'misc/errors.dart';
import 'path_utils.dart';
import 'route.dart';
import 'state.dart';
/// An matched result by matching a [RouteBase] against a location.
/// The function signature for [RouteMatchList.visitRouteMatches]
///
/// This is typically created by calling [RouteMatch.match].
@immutable
class RouteMatch {
/// Constructor for [RouteMatch].
const RouteMatch({
required this.route,
required this.matchedLocation,
required this.pageKey,
});
/// Return false to stop the walk.
typedef RouteMatchVisitor = bool Function(RouteMatchBase);
/// Generate a [RouteMatch] object by matching the `route` with
/// `remainingLocation`.
///
/// The extracted path parameters, as the result of the matching, are stored
/// into `pathParameters`.
static RouteMatch? match({
required RouteBase route,
required String matchedPath, // e.g. /family/:fid
required String remainingLocation, // e.g. person/p1
required String matchedLocation, // e.g. /family/f2
required Map<String, String> pathParameters,
}) {
if (route is ShellRouteBase) {
return RouteMatch(
route: route,
matchedLocation: remainingLocation,
pageKey: ValueKey<String>(route.hashCode.toString()),
);
} else if (route is GoRoute) {
assert(!route.path.contains('//'));
final RegExpMatch? match = route.matchPatternAsPrefix(remainingLocation);
if (match == null) {
return null;
}
final Map<String, String> encodedParams = route.extractPathParams(match);
for (final MapEntry<String, String> param in encodedParams.entries) {
pathParameters[param.key] = Uri.decodeComponent(param.value);
}
final String pathLoc = patternToPath(route.path, encodedParams);
final String newMatchedLocation =
concatenatePaths(matchedLocation, pathLoc);
final String newMatchedPath = concatenatePaths(matchedPath, route.path);
return RouteMatch(
route: route,
matchedLocation: newMatchedLocation,
pageKey: ValueKey<String>(newMatchedPath),
);
}
assert(false, 'Unexpected route type: $route');
return null;
}
/// The base class for various route matches.
abstract class RouteMatchBase with Diagnosticable {
/// An abstract route match base
const RouteMatchBase();
/// The matched route.
final RouteBase route;
RouteBase get route;
/// The page key.
ValueKey<String> get pageKey;
/// The location string that matches the [route].
///
@ -84,9 +42,260 @@ class RouteMatch {
/// route = GoRoute('/family/:id')
///
/// matchedLocation = '/family/f2'
String get matchedLocation;
/// Gets the state that represent this route match.
GoRouterState buildState(
RouteConfiguration configuration, RouteMatchList matches);
/// Generates a list of [RouteMatchBase] objects by matching the `route` and
/// its sub-routes with `uri`.
///
/// This method returns empty list if it can't find a complete match in the
/// `route`.
///
/// The `rootNavigatorKey` is required to match routes with
/// parentNavigatorKey.
///
/// The extracted path parameters, as the result of the matching, are stored
/// into `pathParameters`.
static List<RouteMatchBase> match({
required RouteBase route,
required Map<String, String> pathParameters,
required GlobalKey<NavigatorState> rootNavigatorKey,
required Uri uri,
}) {
return _matchByNavigatorKey(
route: route,
matchedPath: '',
remainingLocation: uri.path,
matchedLocation: '',
pathParameters: pathParameters,
scopedNavigatorKey: rootNavigatorKey,
uri: uri,
)[null] ??
const <RouteMatchBase>[];
}
static const Map<GlobalKey<NavigatorState>?, List<RouteMatchBase>> _empty =
<GlobalKey<NavigatorState>?, List<RouteMatchBase>>{};
/// Returns a navigator key to route matches maps.
///
/// The null key corresponds to the route matches of `scopedNavigatorKey`.
/// The scopedNavigatorKey must not be part of the returned map; otherwise,
/// it is impossible to order the matches.
static Map<GlobalKey<NavigatorState>?, List<RouteMatchBase>>
_matchByNavigatorKey({
required RouteBase route,
required String matchedPath, // e.g. /family/:fid
required String remainingLocation, // e.g. person/p1
required String matchedLocation, // e.g. /family/f2
required Map<String, String> pathParameters,
required GlobalKey<NavigatorState> scopedNavigatorKey,
required Uri uri,
}) {
final Map<GlobalKey<NavigatorState>?, List<RouteMatchBase>> result;
if (route is ShellRouteBase) {
result = _matchByNavigatorKeyForShellRoute(
route: route,
matchedPath: matchedPath,
remainingLocation: remainingLocation,
matchedLocation: matchedLocation,
pathParameters: pathParameters,
scopedNavigatorKey: scopedNavigatorKey,
uri: uri,
);
} else if (route is GoRoute) {
result = _matchByNavigatorKeyForGoRoute(
route: route,
matchedPath: matchedPath,
remainingLocation: remainingLocation,
matchedLocation: matchedLocation,
pathParameters: pathParameters,
scopedNavigatorKey: scopedNavigatorKey,
uri: uri,
);
} else {
assert(false, 'Unexpected route type: $route');
return _empty;
}
// Grab the route matches for the scope navigator key and put it into the
// matches for `null`.
if (result.containsKey(scopedNavigatorKey)) {
final List<RouteMatchBase> matchesForScopedNavigator =
result.remove(scopedNavigatorKey)!;
assert(matchesForScopedNavigator.isNotEmpty);
result
.putIfAbsent(null, () => <RouteMatchBase>[])
.addAll(matchesForScopedNavigator);
}
return result;
}
static Map<GlobalKey<NavigatorState>?, List<RouteMatchBase>>
_matchByNavigatorKeyForShellRoute({
required ShellRouteBase route,
required String matchedPath, // e.g. /family/:fid
required String remainingLocation, // e.g. person/p1
required String matchedLocation, // e.g. /family/f2
required Map<String, String> pathParameters,
required GlobalKey<NavigatorState> scopedNavigatorKey,
required Uri uri,
}) {
final GlobalKey<NavigatorState>? parentKey =
route.parentNavigatorKey == scopedNavigatorKey
? null
: route.parentNavigatorKey;
Map<GlobalKey<NavigatorState>?, List<RouteMatchBase>>? subRouteMatches;
late GlobalKey<NavigatorState> navigatorKeyUsed;
for (final RouteBase subRoute in route.routes) {
navigatorKeyUsed = route.navigatorKeyForSubRoute(subRoute);
subRouteMatches = _matchByNavigatorKey(
route: subRoute,
matchedPath: matchedPath,
remainingLocation: remainingLocation,
matchedLocation: matchedLocation,
pathParameters: pathParameters,
uri: uri,
scopedNavigatorKey: navigatorKeyUsed,
);
assert(!subRouteMatches
.containsKey(route.navigatorKeyForSubRoute(subRoute)));
if (subRouteMatches.isNotEmpty) {
break;
}
}
if (subRouteMatches?.isEmpty ?? true) {
return _empty;
}
final RouteMatchBase result = ShellRouteMatch(
route: route,
// The RouteConfiguration should have asserted the subRouteMatches must
// have at least one match for this ShellRouteBase.
matches: subRouteMatches!.remove(null)!,
matchedLocation: remainingLocation,
pageKey: ValueKey<String>(route.hashCode.toString()),
navigatorKey: navigatorKeyUsed,
);
subRouteMatches.putIfAbsent(parentKey, () => <RouteMatchBase>[]).insert(
0,
result,
);
return subRouteMatches;
}
static Map<GlobalKey<NavigatorState>?, List<RouteMatchBase>>
_matchByNavigatorKeyForGoRoute({
required GoRoute route,
required String matchedPath, // e.g. /family/:fid
required String remainingLocation, // e.g. person/p1
required String matchedLocation, // e.g. /family/f2
required Map<String, String> pathParameters,
required GlobalKey<NavigatorState> scopedNavigatorKey,
required Uri uri,
}) {
final GlobalKey<NavigatorState>? parentKey =
route.parentNavigatorKey == scopedNavigatorKey
? null
: route.parentNavigatorKey;
final RegExpMatch? regExpMatch =
route.matchPatternAsPrefix(remainingLocation);
if (regExpMatch == null) {
return _empty;
}
final Map<String, String> encodedParams =
route.extractPathParams(regExpMatch);
// A temporary map to hold path parameters. This map is merged into
// pathParameters only when this route is part of the returned result.
final Map<String, String> currentPathParameter =
encodedParams.map<String, String>((String key, String value) =>
MapEntry<String, String>(key, Uri.decodeComponent(value)));
final String pathLoc = patternToPath(route.path, encodedParams);
final String newMatchedLocation =
concatenatePaths(matchedLocation, pathLoc);
final String newMatchedPath = concatenatePaths(matchedPath, route.path);
if (newMatchedLocation.toLowerCase() == uri.path.toLowerCase()) {
// A complete match.
pathParameters.addAll(currentPathParameter);
return <GlobalKey<NavigatorState>?, List<RouteMatchBase>>{
parentKey: <RouteMatchBase>[
RouteMatch(
route: route,
matchedLocation: newMatchedLocation,
pageKey: ValueKey<String>(newMatchedPath),
),
],
};
}
assert(uri.path.startsWith(newMatchedLocation));
assert(remainingLocation.isNotEmpty);
final String childRestLoc = uri.path.substring(
newMatchedLocation.length + (newMatchedLocation == '/' ? 0 : 1));
Map<GlobalKey<NavigatorState>?, List<RouteMatchBase>>? subRouteMatches;
for (final RouteBase subRoute in route.routes) {
subRouteMatches = _matchByNavigatorKey(
route: subRoute,
matchedPath: newMatchedPath,
remainingLocation: childRestLoc,
matchedLocation: newMatchedLocation,
pathParameters: pathParameters,
uri: uri,
scopedNavigatorKey: scopedNavigatorKey,
);
if (subRouteMatches.isNotEmpty) {
break;
}
}
if (subRouteMatches?.isEmpty ?? true) {
// If not finding a sub route match, it is considered not matched for this
// route even if this route match part of the `remainingLocation`.
return _empty;
}
pathParameters.addAll(currentPathParameter);
subRouteMatches!.putIfAbsent(parentKey, () => <RouteMatchBase>[]).insert(
0,
RouteMatch(
route: route,
matchedLocation: newMatchedLocation,
pageKey: ValueKey<String>(newMatchedPath),
));
return subRouteMatches;
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<RouteBase>('route', route));
}
}
/// An matched result by matching a [GoRoute] against a location.
///
/// This is typically created by calling [RouteMatchBase.match].
@immutable
class RouteMatch extends RouteMatchBase {
/// Constructor for [RouteMatch].
const RouteMatch({
required this.route,
required this.matchedLocation,
required this.pageKey,
});
/// The matched route.
@override
final GoRoute route;
@override
final String matchedLocation;
/// Value key of type string, to hold a unique reference to a page.
@override
final ValueKey<String> pageKey;
@override
@ -102,6 +311,109 @@ class RouteMatch {
@override
int get hashCode => Object.hash(route, matchedLocation, pageKey);
@override
GoRouterState buildState(
RouteConfiguration configuration, RouteMatchList matches) {
return GoRouterState(
configuration,
uri: matches.uri,
matchedLocation: matchedLocation,
fullPath: matches.fullPath,
pathParameters: matches.pathParameters,
pageKey: pageKey,
name: route.name,
path: route.path,
extra: matches.extra,
);
}
}
/// An matched result by matching a [ShellRoute] against a location.
///
/// This is typically created by calling [RouteMatchBase.match].
@immutable
class ShellRouteMatch extends RouteMatchBase {
/// Create a match.
ShellRouteMatch({
required this.route,
required this.matches,
required this.matchedLocation,
required this.pageKey,
required this.navigatorKey,
}) : assert(matches.isNotEmpty);
@override
final ShellRouteBase route;
RouteMatch get _lastLeaf {
RouteMatchBase currentMatch = matches.last;
while (currentMatch is ShellRouteMatch) {
currentMatch = currentMatch.matches.last;
}
return currentMatch as RouteMatch;
}
/// The navigator key used for this match.
final GlobalKey<NavigatorState> navigatorKey;
@override
final String matchedLocation;
/// The matches that will be built under this shell route.
final List<RouteMatchBase> matches;
@override
final ValueKey<String> pageKey;
@override
GoRouterState buildState(
RouteConfiguration configuration, RouteMatchList matches) {
// The route related data is stored in the leaf route match.
final RouteMatch leafMatch = _lastLeaf;
if (leafMatch is ImperativeRouteMatch) {
matches = leafMatch.matches;
}
return GoRouterState(
configuration,
uri: matches.uri,
matchedLocation: matchedLocation,
fullPath: matches.fullPath,
pathParameters: matches.pathParameters,
pageKey: pageKey,
extra: matches.extra,
);
}
/// Creates a new shell route match with the given matches.
///
/// This is typically used when pushing or popping [RouteMatchBase] from
/// [RouteMatchList].
@internal
ShellRouteMatch copyWith({
required List<RouteMatchBase>? matches,
}) {
return ShellRouteMatch(
matches: matches ?? this.matches,
route: route,
matchedLocation: matchedLocation,
pageKey: pageKey,
navigatorKey: navigatorKey,
);
}
@override
bool operator ==(Object other) {
return other is ShellRouteMatch &&
route == other.route &&
matchedLocation == other.matchedLocation &&
const ListEquality<RouteMatchBase>().equals(matches, other.matches) &&
pageKey == other.pageKey;
}
@override
int get hashCode =>
Object.hash(route, matchedLocation, Object.hashAll(matches), pageKey);
}
/// The route match that represent route pushed through [GoRouter.push].
@ -113,7 +425,8 @@ class ImperativeRouteMatch extends RouteMatch {
route: _getsLastRouteFromMatches(matches),
matchedLocation: _getsMatchedLocationFromMatches(matches),
);
static RouteBase _getsLastRouteFromMatches(RouteMatchList matchList) {
static GoRoute _getsLastRouteFromMatches(RouteMatchList matchList) {
if (matchList.isError) {
return GoRoute(
path: 'error', builder: (_, __) => throw UnimplementedError());
@ -140,6 +453,12 @@ class ImperativeRouteMatch extends RouteMatch {
completer.complete(value);
}
@override
GoRouterState buildState(
RouteConfiguration configuration, RouteMatchList matches) {
return super.buildState(configuration, this.matches);
}
@override
bool operator ==(Object other) {
return other is ImperativeRouteMatch &&
@ -152,11 +471,13 @@ class ImperativeRouteMatch extends RouteMatch {
int get hashCode => Object.hash(super.hashCode, completer, matches.hashCode);
}
/// The list of [RouteMatch] objects.
/// The list of [RouteMatchBase] objects.
///
/// This can contains tree structure if there are [ShellRouteMatch] in the list.
///
/// This corresponds to the GoRouter's history.
@immutable
class RouteMatchList {
class RouteMatchList with Diagnosticable {
/// RouteMatchList constructor.
RouteMatchList({
required this.matches,
@ -168,12 +489,12 @@ class RouteMatchList {
/// Constructs an empty matches object.
static RouteMatchList empty = RouteMatchList(
matches: const <RouteMatch>[],
matches: const <RouteMatchBase>[],
uri: Uri(),
pathParameters: const <String, String>{});
/// The route matches.
final List<RouteMatch> matches;
final List<RouteMatchBase> matches;
/// Parameters for the matched route, URI-encoded.
///
@ -231,19 +552,25 @@ class RouteMatchList {
/// ```dart
/// [RouteMatchA(), RouteMatchB(), RouteMatchC()]
/// ```
static String _generateFullPath(Iterable<RouteMatch> matches) {
static String _generateFullPath(Iterable<RouteMatchBase> matches) {
final StringBuffer buffer = StringBuffer();
bool addsSlash = false;
for (final RouteMatch match in matches
.where((RouteMatch match) => match is! ImperativeRouteMatch)) {
final RouteBase route = match.route;
if (route is GoRoute) {
if (addsSlash) {
buffer.write('/');
}
buffer.write(route.path);
addsSlash = addsSlash || route.path != '/';
for (final RouteMatchBase match in matches
.where((RouteMatchBase match) => match is! ImperativeRouteMatch)) {
if (addsSlash) {
buffer.write('/');
}
final String pathSegment;
if (match is RouteMatch) {
pathSegment = match.route.path;
} else if (match is ShellRouteMatch) {
pathSegment = _generateFullPath(match.matches);
} else {
assert(false, 'Unexpected match type: $match');
continue;
}
buffer.write(pathSegment);
addsSlash = pathSegment.isNotEmpty && (addsSlash || pathSegment != '/');
}
return buffer.toString();
}
@ -257,32 +584,74 @@ class RouteMatchList {
/// Returns a new instance of RouteMatchList with the input `match` pushed
/// onto the current instance.
RouteMatchList push(ImperativeRouteMatch match) {
// Imperative route match doesn't change the uri and path parameters.
return _copyWith(matches: <RouteMatch>[...matches, match]);
if (match.matches.isError) {
return copyWith(matches: <RouteMatchBase>[...matches, match]);
}
return copyWith(
matches: _createNewMatchUntilIncompatible(
matches,
match.matches.matches,
match,
),
);
}
static List<RouteMatchBase> _createNewMatchUntilIncompatible(
List<RouteMatchBase> currentMatches,
List<RouteMatchBase> otherMatches,
ImperativeRouteMatch match,
) {
final List<RouteMatchBase> newMatches = currentMatches.toList();
if (otherMatches.last is ShellRouteMatch &&
newMatches.isNotEmpty &&
otherMatches.last.route == newMatches.last.route) {
assert(newMatches.last is ShellRouteMatch);
final ShellRouteMatch lastShellRouteMatch =
newMatches.removeLast() as ShellRouteMatch;
newMatches.add(
// Create a new copy of the `lastShellRouteMatch`.
lastShellRouteMatch.copyWith(
matches: _createNewMatchUntilIncompatible(lastShellRouteMatch.matches,
(otherMatches.last as ShellRouteMatch).matches, match),
),
);
return newMatches;
}
newMatches
.add(_cloneBranchAndInsertImperativeMatch(otherMatches.last, match));
return newMatches;
}
static RouteMatchBase _cloneBranchAndInsertImperativeMatch(
RouteMatchBase branch, ImperativeRouteMatch match) {
if (branch is ShellRouteMatch) {
return branch.copyWith(
matches: <RouteMatchBase>[
_cloneBranchAndInsertImperativeMatch(branch.matches.last, match),
],
);
}
// Add the input `match` instead of the incompatibleMatch since it contains
// page key and push future.
assert(branch.route == match.route);
return match;
}
/// Returns a new instance of RouteMatchList with the input `match` removed
/// from the current instance.
RouteMatchList remove(RouteMatch match) {
final List<RouteMatch> newMatches = matches.toList();
final int index = newMatches.indexOf(match);
assert(index != -1);
newMatches.removeRange(index, newMatches.length);
// Also pop ShellRoutes that have no subsequent route matches and GoRoutes
// that only have redirect.
while (newMatches.isNotEmpty &&
(newMatches.last.route is ShellRouteBase ||
(newMatches.last.route as GoRoute).redirectOnly)) {
newMatches.removeLast();
}
// Removing ImperativeRouteMatch should not change uri and pathParameters.
if (match is ImperativeRouteMatch) {
return _copyWith(matches: newMatches);
RouteMatchList remove(RouteMatchBase match) {
final List<RouteMatchBase> newMatches =
_removeRouteMatchFromList(matches, match);
if (newMatches == matches) {
return this;
}
final String fullPath = _generateFullPath(
newMatches.where((RouteMatch match) => match is! ImperativeRouteMatch));
final String fullPath = _generateFullPath(newMatches);
if (this.fullPath == fullPath) {
return copyWith(
matches: newMatches,
);
}
// Need to remove path parameters that are no longer in the fullPath.
final List<String> newParameters = <String>[];
patternToRegExp(fullPath, newParameters);
@ -294,24 +663,110 @@ class RouteMatchList {
);
final Uri newUri =
uri.replace(path: patternToPath(fullPath, newPathParameters));
return _copyWith(
return copyWith(
matches: newMatches,
uri: newUri,
pathParameters: newPathParameters,
);
}
/// The last matching route.
RouteMatch get last => matches.last;
/// Returns a new List from the input matches with target removed.
///
/// This method recursively looks into any ShellRouteMatch in matches and
/// removes target if it found a match in the match list nested in
/// ShellRouteMatch.
///
/// This method returns a new list as long as the target is found in the
/// matches' subtree.
///
/// If a target is found, the target and every node after the target in tree
/// order is removed.
static List<RouteMatchBase> _removeRouteMatchFromList(
List<RouteMatchBase> matches, RouteMatchBase target) {
// Remove is caused by pop; therefore, start searching from the end.
for (int index = matches.length - 1; index >= 0; index -= 1) {
final RouteMatchBase match = matches[index];
if (match == target) {
// Remove any redirect only route immediately before the target.
while (index > 0) {
final RouteMatchBase lookBefore = matches[index - 1];
if (lookBefore is! RouteMatch || !lookBefore.route.redirectOnly) {
break;
}
index -= 1;
}
return matches.sublist(0, index);
}
if (match is ShellRouteMatch) {
final List<RouteMatchBase> newSubMatches =
_removeRouteMatchFromList(match.matches, target);
if (newSubMatches == match.matches) {
// Didn't find target in the newSubMatches.
continue;
}
// Removes `match` if its sub match list become empty after the remove.
return <RouteMatchBase>[
...matches.sublist(0, index),
if (newSubMatches.isNotEmpty) match.copyWith(matches: newSubMatches),
];
}
}
// Target is not in the match subtree.
return matches;
}
/// The last leaf route.
///
/// If the last RouteMatchBase from [matches] is a ShellRouteMatch, it
/// recursively goes into its [ShellRouteMatch.matches] until it reach the leaf
/// [RouteMatch].
RouteMatch get last {
if (matches.last is RouteMatch) {
return matches.last as RouteMatch;
}
return (matches.last as ShellRouteMatch)._lastLeaf;
}
/// Returns true if the current match intends to display an error screen.
bool get isError => error != null;
/// The routes for each of the matches.
List<RouteBase> get routes => matches.map((RouteMatch e) => e.route).toList();
List<RouteBase> get routes {
final List<RouteBase> result = <RouteBase>[];
visitRouteMatches((RouteMatchBase match) {
result.add(match.route);
return true;
});
return result;
}
RouteMatchList _copyWith({
List<RouteMatch>? matches,
/// Traverse route matches in this match list in preorder until visitor
/// returns false.
///
/// This method visit recursively into shell route matches.
@internal
void visitRouteMatches(RouteMatchVisitor visitor) {
_visitRouteMatches(matches, visitor);
}
static bool _visitRouteMatches(
List<RouteMatchBase> matches, RouteMatchVisitor visitor) {
for (final RouteMatchBase routeMatch in matches) {
if (!visitor(routeMatch)) {
return false;
}
if (routeMatch is ShellRouteMatch &&
!_visitRouteMatches(routeMatch.matches, visitor)) {
return false;
}
}
return true;
}
/// Create a new [RouteMatchList] with given parameter replaced.
@internal
RouteMatchList copyWith({
List<RouteMatchBase>? matches,
Uri? uri,
Map<String, String>? pathParameters,
}) {
@ -332,7 +787,7 @@ class RouteMatchList {
uri == other.uri &&
extra == other.extra &&
error == other.error &&
const ListEquality<RouteMatch>().equals(matches, other.matches) &&
const ListEquality<RouteMatchBase>().equals(matches, other.matches) &&
const MapEquality<String, String>()
.equals(pathParameters, other.pathParameters);
}
@ -352,8 +807,11 @@ class RouteMatchList {
}
@override
String toString() {
return '${objectRuntimeType(this, 'RouteMatchList')}($fullPath)';
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<Uri>('uri', uri));
properties
.add(DiagnosticsProperty<List<RouteMatchBase>>('matches', matches));
}
}
@ -391,15 +849,23 @@ class _RouteMatchListEncoder
final RouteConfiguration configuration;
@override
Map<Object?, Object?> convert(RouteMatchList input) {
final List<Map<Object?, Object?>> imperativeMatches = input.matches
.whereType<ImperativeRouteMatch>()
.map((ImperativeRouteMatch e) => _toPrimitives(
e.matches.uri.toString(), e.matches.extra,
pageKey: e.pageKey.value))
.toList();
final List<ImperativeRouteMatch> imperativeMatches =
<ImperativeRouteMatch>[];
input.visitRouteMatches((RouteMatchBase match) {
if (match is ImperativeRouteMatch) {
imperativeMatches.add(match);
}
return true;
});
final List<Map<Object?, Object?>> encodedImperativeMatches =
imperativeMatches
.map((ImperativeRouteMatch e) => _toPrimitives(
e.matches.uri.toString(), e.matches.extra,
pageKey: e.pageKey.value))
.toList();
return _toPrimitives(input.uri.toString(), input.extra,
imperativeMatches: imperativeMatches);
imperativeMatches: encodedImperativeMatches);
}
Map<Object?, Object?> _toPrimitives(String location, Object? extra,

View File

@ -8,8 +8,8 @@ import 'package:flutter/cupertino.dart';
import '../misc/extensions.dart';
/// Checks for CupertinoApp in the widget tree.
bool isCupertinoApp(Element elem) =>
elem.findAncestorWidgetOfExactType<CupertinoApp>() != null;
bool isCupertinoApp(BuildContext context) =>
context.findAncestorWidgetOfExactType<CupertinoApp>() != null;
/// Creates a Cupertino HeroController.
HeroController createCupertinoHeroController() =>

View File

@ -9,8 +9,8 @@ import 'package:flutter/material.dart';
import '../misc/extensions.dart';
/// Checks for MaterialApp in the widget tree.
bool isMaterialApp(Element elem) =>
elem.findAncestorWidgetOfExactType<MaterialApp>() != null;
bool isMaterialApp(BuildContext context) =>
context.findAncestorWidgetOfExactType<MaterialApp>() != null;
/// Creates a Material HeroController.
HeroController createMaterialHeroController() =>

View File

@ -12,7 +12,6 @@ import 'configuration.dart';
import 'information_provider.dart';
import 'logging.dart';
import 'match.dart';
import 'route.dart';
import 'router.dart';
/// The function signature of [GoRouteInformationParser.onParserException].
@ -105,7 +104,7 @@ class GoRouteInformationParser extends RouteInformationParser<RouteMatchList> {
assert(() {
if (matchList.isNotEmpty) {
assert(!(matchList.last.route as GoRoute).redirectOnly,
assert(!matchList.last.route.redirectOnly,
'A redirect-only route must redirect to location different from itself.\n The offending route: ${matchList.last.route}');
}
return true;

View File

@ -493,18 +493,6 @@ abstract class ShellRouteBase extends RouteBase {
/// Returns the key for the [Navigator] that is to be used for the specified
/// immediate sub-route of this shell route.
GlobalKey<NavigatorState> navigatorKeyForSubRoute(RouteBase subRoute);
/// Returns the keys for the [Navigator]s associated with this shell route.
Iterable<GlobalKey<NavigatorState>> get _navigatorKeys =>
<GlobalKey<NavigatorState>>[];
/// Returns all the Navigator keys of this shell route as well as those of any
/// descendant shell routes.
Iterable<GlobalKey<NavigatorState>> _navigatorKeysRecursively() {
return RouteBase.routesRecursively(<ShellRouteBase>[this])
.whereType<ShellRouteBase>()
.expand((ShellRouteBase e) => e._navigatorKeys);
}
}
/// Context object used when building the shell and Navigator for a shell route.
@ -710,10 +698,6 @@ class ShellRoute extends ShellRouteBase {
return navigatorKey;
}
@override
Iterable<GlobalKey<NavigatorState>> get _navigatorKeys =>
<GlobalKey<NavigatorState>>[navigatorKey];
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
@ -920,7 +904,6 @@ class StatefulShellRoute extends ShellRouteBase {
return branch!.navigatorKey;
}
@override
Iterable<GlobalKey<NavigatorState>> get _navigatorKeys =>
branches.map((StatefulShellBranch b) => b.navigatorKey);
@ -1222,26 +1205,24 @@ class StatefulNavigationShellState extends State<StatefulNavigationShell>
/// trailing imperative matches from the RouterMatchList that are targeted at
/// any other (often top-level) Navigator.
RouteMatchList _scopedMatchList(RouteMatchList matchList) {
final Iterable<GlobalKey<NavigatorState>> validKeys =
route._navigatorKeysRecursively();
final int index = matchList.matches.indexWhere((RouteMatch e) {
final RouteBase route = e.route;
if (e is ImperativeRouteMatch && route is GoRoute) {
return route.parentNavigatorKey != null &&
!validKeys.contains(route.parentNavigatorKey);
return matchList.copyWith(matches: _scopeMatches(matchList.matches));
}
List<RouteMatchBase> _scopeMatches(List<RouteMatchBase> matches) {
final List<RouteMatchBase> result = <RouteMatchBase>[];
for (final RouteMatchBase match in matches) {
if (match is ShellRouteMatch) {
if (match.route == route) {
result.add(match);
// Discard any other route match after current shell route.
break;
}
result.add(match.copyWith(matches: _scopeMatches(match.matches)));
continue;
}
return false;
});
if (index > 0) {
final List<RouteMatch> matches = matchList.matches.sublist(0, index);
return RouteMatchList(
extra: matchList.extra,
matches: matches,
uri: Uri.parse(matches.last.matchedLocation),
pathParameters: matchList.pathParameters,
);
result.add(match);
}
return matchList;
return result;
}
void _updateCurrentBranchStateFromWidget() {

View File

@ -1,7 +1,7 @@
name: go_router
description: A declarative router for Flutter based on Navigation 2 supporting
deep linking, data-driven routes and more
version: 12.1.3
version: 13.0.0
repository: https://github.com/flutter/packages/tree/main/packages/go_router
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+go_router%22

View File

@ -49,21 +49,24 @@ void main() {
});
testWidgets('Builds ShellRoute', (WidgetTester tester) async {
final GlobalKey<NavigatorState> shellNavigatorKey =
GlobalKey<NavigatorState>();
final RouteConfiguration config = createRouteConfiguration(
routes: <RouteBase>[
ShellRoute(
builder:
(BuildContext context, GoRouterState state, Widget child) {
return _DetailsScreen();
},
routes: <GoRoute>[
GoRoute(
path: '/',
builder: (BuildContext context, GoRouterState state) {
return _DetailsScreen();
},
),
]),
navigatorKey: shellNavigatorKey,
builder: (BuildContext context, GoRouterState state, Widget child) {
return _DetailsScreen();
},
routes: <GoRoute>[
GoRoute(
path: '/',
builder: (BuildContext context, GoRouterState state) {
return _DetailsScreen();
},
),
],
),
],
redirectLimit: 10,
topRedirect: (BuildContext context, GoRouterState state) {
@ -73,9 +76,20 @@ void main() {
);
final RouteMatchList matches = RouteMatchList(
matches: <RouteMatch>[
createRouteMatch(config.routes.first, '/'),
createRouteMatch(config.routes.first.routes.first, '/'),
matches: <RouteMatchBase>[
ShellRouteMatch(
route: config.routes.first as ShellRouteBase,
matchedLocation: '',
pageKey: const ValueKey<String>(''),
navigatorKey: shellNavigatorKey,
matches: <RouteMatchBase>[
RouteMatch(
route: config.routes.first.routes.first as GoRoute,
matchedLocation: '/',
pageKey: const ValueKey<String>('/'),
),
],
),
],
uri: Uri.parse('/'),
pathParameters: const <String, String>{},
@ -164,17 +178,19 @@ void main() {
);
final RouteMatchList matches = RouteMatchList(
matches: <RouteMatch>[
RouteMatch(
route: config.routes.first,
matchedLocation: '',
pageKey: const ValueKey<String>(''),
),
RouteMatch(
route: config.routes.first.routes.first,
matchedLocation: '/details',
pageKey: const ValueKey<String>('/details'),
),
matches: <RouteMatchBase>[
ShellRouteMatch(
route: config.routes.first as ShellRouteBase,
matchedLocation: '',
pageKey: const ValueKey<String>(''),
navigatorKey: shellNavigatorKey,
matches: <RouteMatchBase>[
RouteMatch(
route: config.routes.first.routes.first as GoRoute,
matchedLocation: '/details',
pageKey: const ValueKey<String>('/details'),
),
]),
],
uri: Uri.parse('/details'),
pathParameters: const <String, String>{});
@ -290,9 +306,20 @@ void main() {
);
final RouteMatchList matches = RouteMatchList(
matches: <RouteMatch>[
createRouteMatch(config.routes.first, ''),
createRouteMatch(config.routes.first.routes.first, '/a'),
matches: <RouteMatchBase>[
ShellRouteMatch(
route: config.routes.first as ShellRouteBase,
matchedLocation: '',
pageKey: const ValueKey<String>(''),
navigatorKey: shellNavigatorKey,
matches: <RouteMatchBase>[
RouteMatch(
route: config.routes.first.routes.first as GoRoute,
matchedLocation: '/a',
pageKey: const ValueKey<String>('/a'),
),
],
),
],
uri: Uri.parse('/b'),
pathParameters: const <String, String>{},
@ -430,8 +457,7 @@ class _BuilderTestWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: builder.tryBuild(context, matches, false,
routeConfiguration.navigatorKey, <Page<Object?>, GoRouterState>{}),
home: builder.build(context, matches, false),
);
}
}

View File

@ -77,8 +77,8 @@ void main() {
final GoRouter goRouter = await createGoRouter(tester)
..push('/error');
await tester.pumpAndSettle();
final RouteMatch last =
expect(find.byType(ErrorScreen), findsOneWidget);
final RouteMatchBase last =
goRouter.routerDelegate.currentConfiguration.matches.last;
await goRouter.routerDelegate.popRoute();
expect(goRouter.routerDelegate.currentConfiguration.matches.length, 1);
@ -194,10 +194,8 @@ void main() {
await createGoRouterWithStatefulShellRoute(tester);
goRouter.push('/c/c1');
await tester.pumpAndSettle();
goRouter.push('/a');
await tester.pumpAndSettle();
expect(goRouter.routerDelegate.currentConfiguration.matches.length, 3);
expect(
goRouter.routerDelegate.currentConfiguration.matches[1].pageKey,
@ -219,11 +217,13 @@ void main() {
goRouter.push('/c/c2');
await tester.pumpAndSettle();
expect(goRouter.routerDelegate.currentConfiguration.matches.length, 3);
expect(goRouter.routerDelegate.currentConfiguration.matches.length, 2);
final ShellRouteMatch shellRouteMatch = goRouter.routerDelegate
.currentConfiguration.matches.last as ShellRouteMatch;
expect(shellRouteMatch.matches.length, 2);
expect(
goRouter.routerDelegate.currentConfiguration.matches[1].pageKey,
isNot(equals(
goRouter.routerDelegate.currentConfiguration.matches[2].pageKey)),
shellRouteMatch.matches[0].pageKey,
isNot(equals(shellRouteMatch.matches[1].pageKey)),
);
},
);
@ -240,11 +240,13 @@ void main() {
goRouter.push('/c');
await tester.pumpAndSettle();
expect(goRouter.routerDelegate.currentConfiguration.matches.length, 3);
expect(goRouter.routerDelegate.currentConfiguration.matches.length, 2);
final ShellRouteMatch shellRouteMatch = goRouter.routerDelegate
.currentConfiguration.matches.last as ShellRouteMatch;
expect(shellRouteMatch.matches.length, 2);
expect(
goRouter.routerDelegate.currentConfiguration.matches[1].pageKey,
isNot(equals(
goRouter.routerDelegate.currentConfiguration.matches[2].pageKey)),
shellRouteMatch.matches[0].pageKey,
isNot(equals(shellRouteMatch.matches[1].pageKey)),
);
},
);
@ -294,7 +296,7 @@ void main() {
goRouter.push('/page-0');
goRouter.routerDelegate.addListener(expectAsync0(() {}));
final RouteMatch first =
final RouteMatchBase first =
goRouter.routerDelegate.currentConfiguration.matches.first;
final RouteMatch last = goRouter.routerDelegate.currentConfiguration.last;
goRouter.pushReplacement('/page-1');
@ -376,7 +378,7 @@ void main() {
goRouter.pushNamed('page0');
goRouter.routerDelegate.addListener(expectAsync0(() {}));
final RouteMatch first =
final RouteMatchBase first =
goRouter.routerDelegate.currentConfiguration.matches.first;
final RouteMatch last =
goRouter.routerDelegate.currentConfiguration.last;
@ -395,7 +397,7 @@ void main() {
expect(
goRouter.routerDelegate.currentConfiguration.last,
isA<RouteMatch>().having(
(RouteMatch match) => (match.route as GoRoute).name,
(RouteMatch match) => match.route.name,
'match.route.name',
'page1',
),
@ -425,7 +427,7 @@ void main() {
goRouter.push('/page-0');
goRouter.routerDelegate.addListener(expectAsync0(() {}));
final RouteMatch first =
final RouteMatchBase first =
goRouter.routerDelegate.currentConfiguration.matches.first;
final RouteMatch last = goRouter.routerDelegate.currentConfiguration.last;
goRouter.replace('/page-1');
@ -546,7 +548,7 @@ void main() {
goRouter.pushNamed('page0');
goRouter.routerDelegate.addListener(expectAsync0(() {}));
final RouteMatch first =
final RouteMatchBase first =
goRouter.routerDelegate.currentConfiguration.matches.first;
final RouteMatch last = goRouter.routerDelegate.currentConfiguration.last;
goRouter.replaceNamed('page1');

View File

@ -61,7 +61,7 @@ void main() {
final GoRouter router = await createRouter(routes, tester);
router.go('/');
final List<RouteMatch> matches =
final List<RouteMatchBase> matches =
router.routerDelegate.currentConfiguration.matches;
expect(matches, hasLength(1));
expect((matches.first.route as GoRoute).name, '1');
@ -135,7 +135,7 @@ void main() {
);
router.go('/foo');
await tester.pumpAndSettle();
final List<RouteMatch> matches =
final List<RouteMatchBase> matches =
router.routerDelegate.currentConfiguration.matches;
expect(matches, hasLength(0));
expect(find.byType(TestErrorScreen), findsOneWidget);
@ -156,7 +156,7 @@ void main() {
final GoRouter router = await createRouter(routes, tester);
router.go('/login');
await tester.pumpAndSettle();
final List<RouteMatch> matches =
final List<RouteMatchBase> matches =
router.routerDelegate.currentConfiguration.matches;
expect(matches, hasLength(1));
expect(matches.first.matchedLocation, '/login');
@ -186,7 +186,7 @@ void main() {
final GoRouter router = await createRouter(routes, tester);
router.go('/login');
await tester.pumpAndSettle();
final List<RouteMatch> matches =
final List<RouteMatchBase> matches =
router.routerDelegate.currentConfiguration.matches;
expect(matches, hasLength(1));
expect(matches.first.matchedLocation, '/login');
@ -211,7 +211,7 @@ void main() {
final GoRouter router = await createRouter(routes, tester);
router.go('/login/');
await tester.pumpAndSettle();
final List<RouteMatch> matches =
final List<RouteMatchBase> matches =
router.routerDelegate.currentConfiguration.matches;
expect(matches, hasLength(1));
expect(matches.first.matchedLocation, '/login');
@ -231,7 +231,7 @@ void main() {
final GoRouter router = await createRouter(routes, tester);
router.go('/profile/');
await tester.pumpAndSettle();
final List<RouteMatch> matches =
final List<RouteMatchBase> matches =
router.routerDelegate.currentConfiguration.matches;
expect(matches, hasLength(1));
expect(matches.first.matchedLocation, '/profile/foo');
@ -251,7 +251,7 @@ void main() {
final GoRouter router = await createRouter(routes, tester);
router.go('/profile/?bar=baz');
await tester.pumpAndSettle();
final List<RouteMatch> matches =
final List<RouteMatchBase> matches =
router.routerDelegate.currentConfiguration.matches;
expect(matches, hasLength(1));
expect(matches.first.matchedLocation, '/profile/foo');
@ -348,7 +348,7 @@ void main() {
router.pop();
await tester.pumpAndSettle();
final List<RouteMatch> matches =
final List<RouteMatchBase> matches =
router.routerDelegate.currentConfiguration.matches;
expect(matches.length, 4);
expect(find.byType(HomeScreen), findsNothing);
@ -375,7 +375,7 @@ void main() {
final GoRouter router = await createRouter(routes, tester);
router.go('/login');
await tester.pumpAndSettle();
final List<RouteMatch> matches =
final List<RouteMatchBase> matches =
router.routerDelegate.currentConfiguration.matches;
expect(matches.length, 2);
expect(matches.first.matchedLocation, '/');
@ -497,7 +497,7 @@ void main() {
final GoRouter router = await createRouter(routes, tester);
router.go('/bar');
await tester.pumpAndSettle();
List<RouteMatch> matches =
List<RouteMatchBase> matches =
router.routerDelegate.currentConfiguration.matches;
expect(matches, hasLength(2));
expect(find.byType(Page1Screen), findsOneWidget);
@ -621,7 +621,7 @@ void main() {
const String loc = '/FaMiLy/f2';
router.go(loc);
await tester.pumpAndSettle();
final List<RouteMatch> matches =
final List<RouteMatchBase> matches =
router.routerDelegate.currentConfiguration.matches;
// NOTE: match the lower case, since location is canonicalized to match the
@ -650,7 +650,7 @@ void main() {
final GoRouter router = await createRouter(routes, tester);
router.go('/user');
await tester.pumpAndSettle();
final List<RouteMatch> matches =
final List<RouteMatchBase> matches =
router.routerDelegate.currentConfiguration.matches;
expect(matches, hasLength(1));
expect(find.byType(DummyScreen), findsOneWidget);
@ -908,7 +908,6 @@ void main() {
);
expect(find.text(notifier.value), findsOneWidget);
notifier.value = 'updated';
await tester.pump();
expect(find.text(notifier.value), findsOneWidget);
@ -1927,7 +1926,7 @@ void main() {
errorBuilder: (BuildContext context, GoRouterState state) =>
TestErrorScreen(state.error!),
);
final List<RouteMatch> matches =
final List<RouteMatchBase> matches =
router.routerDelegate.currentConfiguration.matches;
expect(matches, hasLength(0));
expect(find.byType(TestErrorScreen), findsOneWidget);
@ -1955,7 +1954,7 @@ void main() {
TestErrorScreen(state.error!),
);
final List<RouteMatch> matches =
final List<RouteMatchBase> matches =
router.routerDelegate.currentConfiguration.matches;
expect(matches, hasLength(0));
expect(find.byType(TestErrorScreen), findsOneWidget);
@ -1980,7 +1979,7 @@ void main() {
TestErrorScreen(state.error!),
);
final List<RouteMatch> matches =
final List<RouteMatchBase> matches =
router.routerDelegate.currentConfiguration.matches;
expect(matches, hasLength(0));
expect(find.byType(TestErrorScreen), findsOneWidget);
@ -2004,7 +2003,7 @@ void main() {
TestErrorScreen(state.error!),
);
final List<RouteMatch> matches =
final List<RouteMatchBase> matches =
router.routerDelegate.currentConfiguration.matches;
expect(matches, hasLength(0));
expect(find.byType(TestErrorScreen), findsOneWidget);
@ -2066,7 +2065,7 @@ void main() {
},
);
final List<RouteMatch> matches =
final List<RouteMatchBase> matches =
router.routerDelegate.currentConfiguration.matches;
expect(matches, hasLength(1));
expect(find.byType(LoginScreen), findsOneWidget);
@ -2101,7 +2100,7 @@ void main() {
},
);
final List<RouteMatch> matches =
final List<RouteMatchBase> matches =
router.routerDelegate.currentConfiguration.matches;
expect(matches, hasLength(2));
});
@ -2130,7 +2129,7 @@ void main() {
initialLocation: loc,
);
final List<RouteMatch> matches =
final List<RouteMatchBase> matches =
router.routerDelegate.currentConfiguration.matches;
expect(matches, hasLength(1));
expect(find.byType(HomeScreen), findsOneWidget);
@ -2172,7 +2171,7 @@ void main() {
initialLocation: '/family/f2/person/p1',
);
final List<RouteMatch> matches =
final List<RouteMatchBase> matches =
router.routerDelegate.currentConfiguration.matches;
expect(matches.length, 3);
expect(find.byType(HomeScreen, skipOffstage: false), findsOneWidget);
@ -2194,7 +2193,7 @@ void main() {
redirectLimit: 10,
);
final List<RouteMatch> matches =
final List<RouteMatchBase> matches =
router.routerDelegate.currentConfiguration.matches;
expect(matches, hasLength(0));
expect(find.byType(TestErrorScreen), findsOneWidget);
@ -2878,7 +2877,10 @@ void main() {
RouteMatchList matches = router.routerDelegate.currentConfiguration;
expect(router.routerDelegate.currentConfiguration.uri.toString(), loc);
expect(matches.matches, hasLength(4));
expect(matches.matches, hasLength(1));
final ShellRouteMatch shellRouteMatch =
matches.matches.first as ShellRouteMatch;
expect(shellRouteMatch.matches, hasLength(3));
expect(find.byType(PersonScreen), findsOneWidget);
expect(matches.pathParameters['fid'], fid);
expect(matches.pathParameters['pid'], pid);
@ -2981,7 +2983,7 @@ void main() {
'q2': <String>['v2', 'v3'],
});
await tester.pumpAndSettle();
final List<RouteMatch> matches =
final List<RouteMatchBase> matches =
router.routerDelegate.currentConfiguration.matches;
expect(matches, hasLength(1));
@ -3033,7 +3035,7 @@ void main() {
router.go('/page?q1=v1&q2=v2&q2=v3');
await tester.pumpAndSettle();
final List<RouteMatch> matches =
final List<RouteMatchBase> matches =
router.routerDelegate.currentConfiguration.matches;
expect(matches, hasLength(1));
@ -3084,7 +3086,7 @@ void main() {
router.go('/page?q1=v1&q2=v2&q2=v3');
await tester.pumpAndSettle();
final List<RouteMatch> matches =
final List<RouteMatchBase> matches =
router.routerDelegate.currentConfiguration.matches;
expect(matches, hasLength(1));
@ -3974,6 +3976,7 @@ void main() {
expect(find.text('Screen A'), findsNothing);
expect(find.text('Screen B'), findsOneWidget);
// This push '/common' on top of entire stateful shell route page.
router.push('/common', extra: 'X');
await tester.pumpAndSettle();
expect(find.text('Screen A'), findsNothing);
@ -3987,8 +3990,7 @@ void main() {
routeState!.goBranch(1);
await tester.pumpAndSettle();
expect(find.text('Screen A'), findsNothing);
expect(find.text('Screen B'), findsNothing);
expect(find.text('Common - X'), findsOneWidget);
expect(find.text('Screen B'), findsOneWidget);
});
testWidgets(

View File

@ -0,0 +1,297 @@
// Copyright 2013 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/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'test_helpers.dart';
void main() {
testWidgets('replace inside shell route', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/134524.
final UniqueKey a = UniqueKey();
final UniqueKey b = UniqueKey();
final List<RouteBase> routes = <RouteBase>[
ShellRoute(
builder: (_, __, Widget child) {
return Scaffold(
appBar: AppBar(title: const Text('shell')),
body: child,
);
},
routes: <RouteBase>[
GoRoute(
path: '/a',
builder: (_, __) => DummyScreen(key: a),
),
GoRoute(
path: '/b',
builder: (_, __) => DummyScreen(key: b),
)
],
),
];
final GoRouter router =
await createRouter(routes, tester, initialLocation: '/a');
expect(find.text('shell'), findsOneWidget);
expect(find.byKey(a), findsOneWidget);
router.replace('/b');
await tester.pumpAndSettle();
expect(find.text('shell'), findsOneWidget);
expect(find.byKey(a), findsNothing);
expect(find.byKey(b), findsOneWidget);
});
testWidgets('push from outside of shell route', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/130406.
final UniqueKey a = UniqueKey();
final UniqueKey b = UniqueKey();
final List<RouteBase> routes = <RouteBase>[
GoRoute(
path: '/a',
builder: (_, __) => DummyScreen(key: a),
),
ShellRoute(
builder: (_, __, Widget child) {
return Scaffold(
appBar: AppBar(title: const Text('shell')),
body: child,
);
},
routes: <RouteBase>[
GoRoute(
path: '/b',
builder: (_, __) => DummyScreen(key: b),
),
],
),
];
final GoRouter router =
await createRouter(routes, tester, initialLocation: '/a');
expect(find.text('shell'), findsNothing);
expect(find.byKey(a), findsOneWidget);
router.push('/b');
await tester.pumpAndSettle();
expect(find.text('shell'), findsOneWidget);
expect(find.byKey(a), findsNothing);
expect(find.byKey(b), findsOneWidget);
});
testWidgets('shell route reflect imperative push',
(WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/125752.
final UniqueKey home = UniqueKey();
final UniqueKey a = UniqueKey();
final List<RouteBase> routes = <RouteBase>[
ShellRoute(
builder: (_, GoRouterState state, Widget child) {
return Scaffold(
appBar: AppBar(title: Text('location: ${state.uri.path}')),
body: child,
);
},
routes: <RouteBase>[
GoRoute(
path: '/',
builder: (_, __) => DummyScreen(key: home),
routes: <RouteBase>[
GoRoute(
path: 'a',
builder: (_, __) => DummyScreen(key: a),
),
]),
],
),
];
final GoRouter router =
await createRouter(routes, tester, initialLocation: '/a');
expect(find.text('location: /a'), findsOneWidget);
expect(find.byKey(a), findsOneWidget);
router.pop();
await tester.pumpAndSettle();
expect(find.text('location: /'), findsOneWidget);
expect(find.byKey(a), findsNothing);
expect(find.byKey(home), findsOneWidget);
router.push('/a');
await tester.pumpAndSettle();
expect(find.text('location: /a'), findsOneWidget);
expect(find.byKey(a), findsOneWidget);
expect(find.byKey(home), findsNothing);
});
testWidgets('push shell route in another shell route',
(WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/120791.
final UniqueKey b = UniqueKey();
final UniqueKey a = UniqueKey();
final List<RouteBase> routes = <RouteBase>[
ShellRoute(
builder: (_, __, Widget child) {
return Scaffold(
appBar: AppBar(title: const Text('shell1')),
body: child,
);
},
routes: <RouteBase>[
GoRoute(
path: '/a',
builder: (_, __) => DummyScreen(key: a),
),
],
),
ShellRoute(
builder: (_, __, Widget child) {
return Scaffold(
appBar: AppBar(title: const Text('shell2')),
body: child,
);
},
routes: <RouteBase>[
GoRoute(
path: '/b',
builder: (_, __) => DummyScreen(key: b),
),
],
),
];
final GoRouter router =
await createRouter(routes, tester, initialLocation: '/a');
expect(find.text('shell1'), findsOneWidget);
expect(find.byKey(a), findsOneWidget);
router.push('/b');
await tester.pumpAndSettle();
expect(find.text('shell1'), findsNothing);
expect(find.byKey(a), findsNothing);
expect(find.text('shell2'), findsOneWidget);
expect(find.byKey(b), findsOneWidget);
});
testWidgets('push inside or outside shell route',
(WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/120665.
final UniqueKey inside = UniqueKey();
final UniqueKey outside = UniqueKey();
final List<RouteBase> routes = <RouteBase>[
ShellRoute(
builder: (_, __, Widget child) {
return Scaffold(
appBar: AppBar(title: const Text('shell')),
body: child,
);
},
routes: <RouteBase>[
GoRoute(
path: '/in',
builder: (_, __) => DummyScreen(key: inside),
),
],
),
GoRoute(
path: '/out',
builder: (_, __) => DummyScreen(key: outside),
),
];
final GoRouter router =
await createRouter(routes, tester, initialLocation: '/out');
expect(find.text('shell'), findsNothing);
expect(find.byKey(outside), findsOneWidget);
router.push('/in');
await tester.pumpAndSettle();
expect(find.text('shell'), findsOneWidget);
expect(find.byKey(outside), findsNothing);
expect(find.byKey(inside), findsOneWidget);
router.push('/out');
await tester.pumpAndSettle();
expect(find.text('shell'), findsNothing);
expect(find.byKey(outside), findsOneWidget);
expect(find.byKey(inside), findsNothing);
});
testWidgets('complex case 1', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/113001.
final UniqueKey a = UniqueKey();
final UniqueKey b = UniqueKey();
final UniqueKey c = UniqueKey();
final UniqueKey d = UniqueKey();
final UniqueKey e = UniqueKey();
final List<RouteBase> routes = <RouteBase>[
ShellRoute(
builder: (_, __, Widget child) {
return Scaffold(
appBar: AppBar(title: const Text('shell')),
body: child,
);
},
routes: <RouteBase>[
GoRoute(
path: '/a',
builder: (_, __) => DummyScreen(key: a),
),
GoRoute(
path: '/c',
builder: (_, __) => DummyScreen(key: c),
),
],
),
GoRoute(
path: '/d',
builder: (_, __) => DummyScreen(key: d),
routes: <RouteBase>[
GoRoute(
path: 'e',
builder: (_, __) => DummyScreen(key: e),
),
],
),
GoRoute(
path: '/b',
builder: (_, __) => DummyScreen(key: b),
),
];
final GoRouter router =
await createRouter(routes, tester, initialLocation: '/a');
expect(find.text('shell'), findsOneWidget);
expect(find.byKey(a), findsOneWidget);
router.push('/b');
await tester.pumpAndSettle();
expect(find.text('shell'), findsNothing);
expect(find.byKey(a), findsNothing);
expect(find.byKey(b), findsOneWidget);
router.pop();
await tester.pumpAndSettle();
expect(find.text('shell'), findsOneWidget);
expect(find.byKey(a), findsOneWidget);
router.go('/c');
await tester.pumpAndSettle();
expect(find.text('shell'), findsOneWidget);
expect(find.byKey(c), findsOneWidget);
router.push('/d');
await tester.pumpAndSettle();
expect(find.text('shell'), findsNothing);
expect(find.byKey(d), findsOneWidget);
router.push('/d/e');
await tester.pumpAndSettle();
expect(find.text('shell'), findsNothing);
expect(find.byKey(e), findsOneWidget);
});
}

View File

@ -81,6 +81,33 @@ void main() {
await tester.tap(find.text('My Page'));
expect(router.latestPushedName, 'my_page');
});
testWidgets('builder can access GoRouter', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/110512.
late final GoRouter buildContextRouter;
final GoRouter router = GoRouter(
initialLocation: '/',
routes: <GoRoute>[
GoRoute(
path: '/',
builder: (BuildContext context, __) {
buildContextRouter = GoRouter.of(context);
return const DummyScreen();
},
)
],
);
await tester.pumpWidget(
MaterialApp.router(
routeInformationProvider: router.routeInformationProvider,
routeInformationParser: router.routeInformationParser,
routerDelegate: router.routerDelegate),
);
expect(buildContextRouter, isNotNull);
expect(buildContextRouter, equals(router));
});
}
bool setupInheritedGoRouterChange({

View File

@ -16,44 +16,20 @@ void main() {
builder: _builder,
);
final Map<String, String> pathParameters = <String, String>{};
final RouteMatch? match = RouteMatch.match(
final List<RouteMatchBase> matches = RouteMatchBase.match(
route: route,
remainingLocation: '/users/123',
matchedLocation: '',
matchedPath: '',
pathParameters: pathParameters,
uri: Uri.parse('/users/123'),
rootNavigatorKey: GlobalKey<NavigatorState>(),
);
if (match == null) {
fail('Null match');
}
expect(matches.length, 1);
final RouteMatchBase match = matches.first;
expect(match.route, route);
expect(match.matchedLocation, '/users/123');
expect(pathParameters['userId'], '123');
expect(match.pageKey, isNotNull);
});
test('matchedLocation', () {
final GoRoute route = GoRoute(
path: 'users/:userId',
builder: _builder,
);
final Map<String, String> pathParameters = <String, String>{};
final RouteMatch? match = RouteMatch.match(
route: route,
remainingLocation: 'users/123',
matchedLocation: '/home',
matchedPath: '/home',
pathParameters: pathParameters,
);
if (match == null) {
fail('Null match');
}
expect(match.route, route);
expect(match.matchedLocation, '/home/users/123');
expect(pathParameters['userId'], '123');
expect(match.pageKey, isNotNull);
});
test('ShellRoute has a unique pageKey', () {
final ShellRoute route = ShellRoute(
builder: _shellBuilder,
@ -65,17 +41,14 @@ void main() {
],
);
final Map<String, String> pathParameters = <String, String>{};
final RouteMatch? match = RouteMatch.match(
final List<RouteMatchBase> matches = RouteMatchBase.match(
route: route,
remainingLocation: 'users/123',
matchedLocation: '/home',
matchedPath: '/home',
uri: Uri.parse('/users/123'),
rootNavigatorKey: GlobalKey<NavigatorState>(),
pathParameters: pathParameters,
);
if (match == null) {
fail('Null match');
}
expect(match.pageKey, isNotNull);
expect(matches.length, 1);
expect(matches.first.pageKey, isNotNull);
});
test('ShellRoute Match has stable unique key', () {
@ -89,51 +62,136 @@ void main() {
],
);
final Map<String, String> pathParameters = <String, String>{};
final RouteMatch? match1 = RouteMatch.match(
final List<RouteMatchBase> matches1 = RouteMatchBase.match(
route: route,
remainingLocation: 'users/123',
matchedLocation: '/home',
matchedPath: '/home',
pathParameters: pathParameters,
uri: Uri.parse('/users/123'),
rootNavigatorKey: GlobalKey<NavigatorState>(),
);
final RouteMatch? match2 = RouteMatch.match(
final List<RouteMatchBase> matches2 = RouteMatchBase.match(
route: route,
remainingLocation: 'users/1234',
matchedLocation: '/home',
matchedPath: '/home',
pathParameters: pathParameters,
uri: Uri.parse('/users/1234'),
rootNavigatorKey: GlobalKey<NavigatorState>(),
);
expect(match1!.pageKey, match2!.pageKey);
expect(matches1.length, 1);
expect(matches2.length, 1);
expect(matches1.first.pageKey, matches2.first.pageKey);
});
test('GoRoute Match has stable unique key', () {
final GoRoute route = GoRoute(
path: 'users/:userId',
path: '/users/:userId',
builder: _builder,
);
final Map<String, String> pathParameters = <String, String>{};
final RouteMatch? match1 = RouteMatch.match(
final List<RouteMatchBase> matches1 = RouteMatchBase.match(
route: route,
remainingLocation: 'users/123',
matchedLocation: '/home',
matchedPath: '/home',
uri: Uri.parse('/users/123'),
rootNavigatorKey: GlobalKey<NavigatorState>(),
pathParameters: pathParameters,
);
final RouteMatch? match2 = RouteMatch.match(
final List<RouteMatchBase> matches2 = RouteMatchBase.match(
route: route,
remainingLocation: 'users/1234',
matchedLocation: '/home',
matchedPath: '/home',
uri: Uri.parse('/users/1234'),
rootNavigatorKey: GlobalKey<NavigatorState>(),
pathParameters: pathParameters,
);
expect(match1!.pageKey, match2!.pageKey);
expect(matches1.length, 1);
expect(matches2.length, 1);
expect(matches1.first.pageKey, matches2.first.pageKey);
});
});
test('complex parentNavigatorKey works', () {
final GlobalKey<NavigatorState> root = GlobalKey<NavigatorState>();
final GlobalKey<NavigatorState> shell1 = GlobalKey<NavigatorState>();
final GlobalKey<NavigatorState> shell2 = GlobalKey<NavigatorState>();
final GoRoute route = GoRoute(
path: '/',
builder: _builder,
routes: <RouteBase>[
ShellRoute(
navigatorKey: shell1,
builder: _shellBuilder,
routes: <RouteBase>[
GoRoute(
path: 'a',
builder: _builder,
routes: <RouteBase>[
GoRoute(
parentNavigatorKey: root,
path: 'b',
builder: _builder,
routes: <RouteBase>[
ShellRoute(
navigatorKey: shell2,
builder: _shellBuilder,
routes: <RouteBase>[
GoRoute(
path: 'c',
builder: _builder,
routes: <RouteBase>[
GoRoute(
parentNavigatorKey: root,
path: 'd',
builder: _builder,
),
],
),
],
),
],
),
],
),
],
),
],
);
final Map<String, String> pathParameters = <String, String>{};
final List<RouteMatchBase> matches = RouteMatchBase.match(
route: route,
pathParameters: pathParameters,
uri: Uri.parse('/a/b/c/d'),
rootNavigatorKey: root,
);
expect(matches.length, 4);
expect(
matches[0].route,
isA<GoRoute>().having(
(GoRoute route) => route.path,
'path',
'/',
),
);
expect(
matches[1].route,
isA<ShellRoute>().having(
(ShellRoute route) => route.navigatorKey,
'navigator key',
shell1,
),
);
expect(
matches[2].route,
isA<GoRoute>().having(
(GoRoute route) => route.path,
'path',
'b',
),
);
expect(
matches[3].route,
isA<GoRoute>().having(
(GoRoute route) => route.path,
'path',
'd',
),
);
});
group('ImperativeRouteMatch', () {
final RouteMatchList matchList1 = RouteMatchList(
matches: <RouteMatch>[

View File

@ -35,37 +35,35 @@ void main() {
const Placeholder(),
);
final Map<String, String> params1 = <String, String>{};
final RouteMatch match1 = RouteMatch.match(
final List<RouteMatchBase> match1 = RouteMatchBase.match(
route: route,
remainingLocation: '/page-0',
matchedLocation: '',
matchedPath: '',
uri: Uri.parse('/page-0'),
rootNavigatorKey: GlobalKey<NavigatorState>(),
pathParameters: params1,
)!;
);
final Map<String, String> params2 = <String, String>{};
final RouteMatch match2 = RouteMatch.match(
final List<RouteMatchBase> match2 = RouteMatchBase.match(
route: route,
remainingLocation: '/page-0',
matchedLocation: '',
matchedPath: '',
uri: Uri.parse('/page-0'),
rootNavigatorKey: GlobalKey<NavigatorState>(),
pathParameters: params2,
)!;
);
final RouteMatchList matches1 = RouteMatchList(
matches: <RouteMatch>[match1],
matches: match1,
uri: Uri.parse(''),
pathParameters: params1,
);
final RouteMatchList matches2 = RouteMatchList(
matches: <RouteMatch>[match2],
matches: match2,
uri: Uri.parse(''),
pathParameters: params2,
);
final RouteMatchList matches3 = RouteMatchList(
matches: <RouteMatch>[match2],
matches: match2,
uri: Uri.parse('/page-0'),
pathParameters: params2,
);

View File

@ -62,7 +62,7 @@ void main() {
RouteMatchList matchesObj =
await parser.parseRouteInformationWithDependencies(
createRouteInformation('/'), context);
List<RouteMatch> matches = matchesObj.matches;
List<RouteMatchBase> matches = matchesObj.matches;
expect(matches.length, 1);
expect(matchesObj.uri.toString(), '/');
expect(matchesObj.extra, isNull);
@ -238,7 +238,7 @@ void main() {
final RouteMatchList matchesObj =
await parser.parseRouteInformationWithDependencies(
createRouteInformation('/def'), context);
final List<RouteMatch> matches = matchesObj.matches;
final List<RouteMatchBase> matches = matchesObj.matches;
expect(matches.length, 0);
expect(matchesObj.uri.toString(), '/def');
expect(matchesObj.extra, isNull);
@ -303,7 +303,7 @@ void main() {
final RouteMatchList matchesObj =
await parser.parseRouteInformationWithDependencies(
createRouteInformation('/123/family/456'), context);
final List<RouteMatch> matches = matchesObj.matches;
final List<RouteMatchBase> matches = matchesObj.matches;
expect(matches.length, 2);
expect(matchesObj.uri.toString(), '/123/family/456');
@ -347,7 +347,7 @@ void main() {
final RouteMatchList matchesObj =
await parser.parseRouteInformationWithDependencies(
createRouteInformation('/random/uri'), context);
final List<RouteMatch> matches = matchesObj.matches;
final List<RouteMatchBase> matches = matchesObj.matches;
expect(matches.length, 2);
expect(matchesObj.uri.toString(), '/123/family/345');
@ -387,7 +387,7 @@ void main() {
final RouteMatchList matchesObj =
await parser.parseRouteInformationWithDependencies(
createRouteInformation('/redirect'), context);
final List<RouteMatch> matches = matchesObj.matches;
final List<RouteMatchBase> matches = matchesObj.matches;
expect(matches.length, 2);
expect(matchesObj.uri.toString(), '/123/family/345');
@ -440,7 +440,7 @@ void main() {
final RouteMatchList matchesObj =
await parser.parseRouteInformationWithDependencies(
createRouteInformation('/abd'), context);
final List<RouteMatch> matches = matchesObj.matches;
final List<RouteMatchBase> matches = matchesObj.matches;
expect(matches, hasLength(0));
expect(matchesObj.error, isNotNull);
@ -484,9 +484,11 @@ void main() {
final RouteMatchList matchesObj =
await parser.parseRouteInformationWithDependencies(
createRouteInformation('/a'), context);
final List<RouteMatch> matches = matchesObj.matches;
final List<RouteMatchBase> matches = matchesObj.matches;
expect(matches, hasLength(2));
expect(matches, hasLength(1));
final ShellRouteMatch match = matches.first as ShellRouteMatch;
expect(match.matches, hasLength(1));
expect(matchesObj.error, isNull);
});
}

View File

@ -356,14 +356,6 @@ StatefulShellRouteBuilder mockStackedShellBuilder = (BuildContext context,
return navigationShell;
};
RouteMatch createRouteMatch(RouteBase route, String location) {
return RouteMatch(
route: route,
matchedLocation: location,
pageKey: ValueKey<String>(location),
);
}
/// A routing config that is never going to change.
class ConstantRoutingConfig extends ValueListenable<RoutingConfig> {
const ConstantRoutingConfig(this.value);