[go_router] Nested stateful navigation with ShellRoute (#2650)
Added functionality for building route configuration with support for preserving state in nested navigators. This change introduces a new shell route class called `StatefulShellRoute`, that uses separate navigators for its child routes as well as preserving state in each navigation branch. This is convenient when for instance implementing a UI with a `BottomNavigationBar`, with a persistent navigation state for each tab (i.e. building a `Navigator` for each tab).
An example showcasing a UI with BottomNavigationBar and StatefulShellRoute has also been added ([`stateful_shell_route.dart`](https://github.com/tolo/flutter_packages/blob/nested-persistent-navigation/packages/go_router/example/lib/stateful_shell_route.dart)).
Other examples of using `StatefulShellRoute` are also available in these repositories:
* [stateful_books](https://github.com/tolo/stateful_books) - A fork of the Books example of go_router.
* [stateful_navbar](https://github.com/tolo/stateful_navbar) - A clone of the Flutter Material 3 Navigation Bar example.
<br/>
Below is a short example of how a `StatefulShellRoute` can be setup:
```dart
StatefulShellRoute(
/// Each separate stateful navigation tree (i.e. Navigator) is represented by
/// a StatefulShellBranch, which defines the routes that will be placed on that
/// Navigator. StatefulShellBranch also makes it possible to configure
/// things like an (optional) Navigator key, the default location (i.e. the
/// location the branch will be navigated to when loading it for the first time) etc.
branches: <StatefulShellBranch>[
StatefulShellBranch(navigatorKey: optionalNavigatorKey, routes: <RouteBase>[
GoRoute(
path: '/a',
builder: (BuildContext context, GoRouterState state) =>
const RootScreen(label: 'A'),
routes: <RouteBase>[
GoRoute(
path: 'details',
builder: (BuildContext context, GoRouterState state) =>
const DetailsScreen(label: 'A'),
),
],
),
]),
/// The default location of a branch will by default be the first of the
/// configured routes. To configure a different route, provide the
/// defaultLocation parameter.
StatefulShellBranch(defaultLocation: '/b/detail', routes: <RouteBase>[
GoRoute(
path: '/b',
builder: (BuildContext context, GoRouterState state) =>
const RootScreen(label: 'B'),
routes: <RouteBase>[
GoRoute(
path: 'details',
builder: (BuildContext context, GoRouterState state) =>
const DetailsScreen(label: 'B'),
),
],
),
]),
],
/// Like ShellRoute, the builder builds the navigation shell around the
/// sub-routes, but with StatefulShellRoute, this navigation shell is able to
/// maintain the state of the Navigators for each branch. The navigation shell
/// could for instance use a BottomNavigationBar or similar.
builder: (BuildContext context, StatefulShellRouteState state, Widget child) =>
ScaffoldWithNavBar(shellState: state, body: child),
)
```
This fixes issue flutter/flutter#99124.
It also (at least partially) addresses flutter/flutter#112267.
diff --git a/packages/go_router/CHANGELOG.md b/packages/go_router/CHANGELOG.md
index 43869ef..dc2b5c0 100644
--- a/packages/go_router/CHANGELOG.md
+++ b/packages/go_router/CHANGELOG.md
@@ -1,3 +1,10 @@
+## 7.1.0
+
+- Introduces `StatefulShellRoute` to support using separate navigators for child routes as well as preserving state in each navigation tree (flutter/flutter#99124).
+- Updates documentation for `pageBuilder` and `builder` fields of `ShellRoute`, to more correctly
+ describe the meaning of the child argument in the builder functions.
+- Adds support for restorationId to ShellRoute (and StatefulShellRoute).
+
## 7.0.2
- Fixes `BuildContext` extension method `replaceNamed` to correctly pass `pathParameters` and `queryParameters`.
diff --git a/packages/go_router/example/README.md b/packages/go_router/example/README.md
index 90c7aad..f4c1c0f 100644
--- a/packages/go_router/example/README.md
+++ b/packages/go_router/example/README.md
@@ -30,6 +30,12 @@
An example to demonstrate how to use handle a sign-in flow with a stream authentication service.
+## [Stateful Nested Navigation](https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/stateful_shell_route.dart)
+`flutter run lib/stacked_shell_route.dart`
+
+An example to demonstrate how to use a `StatefulShellRoute` to create stateful nested navigation, with a
+`BottomNavigationBar`.
+
## [Books app](https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/books)
`flutter run lib/books/main.dart`
diff --git a/packages/go_router/example/ios/Runner/Base.lproj/Main.storyboard b/packages/go_router/example/ios/Runner/Base.lproj/Main.storyboard
index f3c2851..bb61264 100644
--- a/packages/go_router/example/ios/Runner/Base.lproj/Main.storyboard
+++ b/packages/go_router/example/ios/Runner/Base.lproj/Main.storyboard
@@ -8,7 +8,7 @@
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
- <viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
+ <viewController restorationIdentifier="sampleRestorationId" id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
diff --git a/packages/go_router/example/lib/others/custom_stateful_shell_route.dart b/packages/go_router/example/lib/others/custom_stateful_shell_route.dart
new file mode 100644
index 0000000..5fbe2b6
--- /dev/null
+++ b/packages/go_router/example/lib/others/custom_stateful_shell_route.dart
@@ -0,0 +1,460 @@
+// 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:collection/collection.dart';
+import 'package:flutter/material.dart';
+import 'package:go_router/go_router.dart';
+
+final GlobalKey<NavigatorState> _rootNavigatorKey =
+ GlobalKey<NavigatorState>(debugLabel: 'root');
+final GlobalKey<NavigatorState> _tabANavigatorKey =
+ GlobalKey<NavigatorState>(debugLabel: 'tabANav');
+
+// This example demonstrates how to setup nested navigation using a
+// BottomNavigationBar, where each bar item uses its own persistent navigator,
+// i.e. navigation state is maintained separately for each item. This setup also
+// enables deep linking into nested pages.
+//
+// This example also demonstrates how build a nested shell with a custom
+// container for the branch Navigators (in this case a TabBarView).
+
+void main() {
+ runApp(NestedTabNavigationExampleApp());
+}
+
+/// An example demonstrating how to use nested navigators
+class NestedTabNavigationExampleApp extends StatelessWidget {
+ /// Creates a NestedTabNavigationExampleApp
+ NestedTabNavigationExampleApp({super.key});
+
+ final GoRouter _router = GoRouter(
+ navigatorKey: _rootNavigatorKey,
+ initialLocation: '/a',
+ routes: <RouteBase>[
+ StatefulShellRoute(
+ builder: (BuildContext context, GoRouterState state,
+ StatefulNavigationShell navigationShell) {
+ // This nested StatefulShellRoute demonstrates the use of a
+ // custom container for the branch Navigators. In this implementation,
+ // no customization is done in the builder function (navigationShell
+ // itself is simply used as the Widget for the route). Instead, the
+ // navigatorContainerBuilder function below is provided to
+ // customize the container for the branch Navigators.
+ return navigationShell;
+ },
+ navigatorContainerBuilder: (BuildContext context,
+ StatefulNavigationShell navigationShell, List<Widget> children) {
+ // Returning a customized container for the branch
+ // Navigators (i.e. the `List<Widget> children` argument).
+ //
+ // See ScaffoldWithNavBar for more details on how the children
+ // are managed (using AnimatedBranchContainer).
+ return ScaffoldWithNavBar(
+ navigationShell: navigationShell, children: children);
+ },
+ branches: <StatefulShellBranch>[
+ // The route branch for the first tab of the bottom navigation bar.
+ StatefulShellBranch(
+ navigatorKey: _tabANavigatorKey,
+ routes: <RouteBase>[
+ GoRoute(
+ // The screen to display as the root in the first tab of the
+ // bottom navigation bar.
+ path: '/a',
+ builder: (BuildContext context, GoRouterState state) =>
+ const RootScreenA(),
+ routes: <RouteBase>[
+ // The details screen to display stacked on navigator of the
+ // first tab. This will cover screen A but not the application
+ // shell (bottom navigation bar).
+ GoRoute(
+ path: 'details',
+ builder: (BuildContext context, GoRouterState state) =>
+ const DetailsScreen(label: 'A'),
+ ),
+ ],
+ ),
+ ],
+ ),
+
+ // The route branch for the third tab of the bottom navigation bar.
+ StatefulShellBranch(
+ // StatefulShellBranch will automatically use the first descendant
+ // GoRoute as the initial location of the branch. If another route
+ // is desired, specify the location of it using the defaultLocation
+ // parameter.
+ // defaultLocation: '/c2',
+ routes: <RouteBase>[
+ StatefulShellRoute(
+ builder: (BuildContext context, GoRouterState state,
+ StatefulNavigationShell navigationShell) {
+ // Just like with the top level StatefulShellRoute, no
+ // customization is done in the builder function.
+ return navigationShell;
+ },
+ navigatorContainerBuilder: (BuildContext context,
+ StatefulNavigationShell navigationShell,
+ List<Widget> children) {
+ // Returning a customized container for the branch
+ // Navigators (i.e. the `List<Widget> children` argument).
+ //
+ // See TabbedRootScreen for more details on how the children
+ // are managed (in a TabBarView).
+ return TabbedRootScreen(
+ navigationShell: navigationShell, children: children);
+ },
+ // This bottom tab uses a nested shell, wrapping sub routes in a
+ // top TabBar.
+ branches: <StatefulShellBranch>[
+ StatefulShellBranch(routes: <GoRoute>[
+ GoRoute(
+ path: '/b1',
+ builder: (BuildContext context, GoRouterState state) =>
+ const TabScreen(
+ label: 'B1', detailsPath: '/b1/details'),
+ routes: <RouteBase>[
+ GoRoute(
+ path: 'details',
+ builder:
+ (BuildContext context, GoRouterState state) =>
+ const DetailsScreen(
+ label: 'B1',
+ withScaffold: false,
+ ),
+ ),
+ ],
+ ),
+ ]),
+ StatefulShellBranch(routes: <GoRoute>[
+ GoRoute(
+ path: '/b2',
+ builder: (BuildContext context, GoRouterState state) =>
+ const TabScreen(
+ label: 'B2', detailsPath: '/b2/details'),
+ routes: <RouteBase>[
+ GoRoute(
+ path: 'details',
+ builder:
+ (BuildContext context, GoRouterState state) =>
+ const DetailsScreen(
+ label: 'B2',
+ withScaffold: false,
+ ),
+ ),
+ ],
+ ),
+ ]),
+ ],
+ ),
+ ],
+ ),
+ ],
+ ),
+ ],
+ );
+
+ @override
+ Widget build(BuildContext context) {
+ return MaterialApp.router(
+ title: 'Flutter Demo',
+ theme: ThemeData(
+ primarySwatch: Colors.blue,
+ ),
+ routerConfig: _router,
+ );
+ }
+}
+
+/// Builds the "shell" for the app by building a Scaffold with a
+/// BottomNavigationBar, where [child] is placed in the body of the Scaffold.
+class ScaffoldWithNavBar extends StatelessWidget {
+ /// Constructs an [ScaffoldWithNavBar].
+ const ScaffoldWithNavBar({
+ required this.navigationShell,
+ required this.children,
+ Key? key,
+ }) : super(key: key ?? const ValueKey<String>('ScaffoldWithNavBar'));
+
+ /// The navigation shell and container for the branch Navigators.
+ final StatefulNavigationShell navigationShell;
+
+ /// The children (branch Navigators) to display in a custom container
+ /// ([AnimatedBranchContainer]).
+ final List<Widget> children;
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ body: AnimatedBranchContainer(
+ currentIndex: navigationShell.currentIndex,
+ children: children,
+ ),
+ bottomNavigationBar: BottomNavigationBar(
+ // Here, the items of BottomNavigationBar are hard coded. In a real
+ // world scenario, the items would most likely be generated from the
+ // branches of the shell route, which can be fetched using
+ // `navigationShell.route.branches`.
+ items: const <BottomNavigationBarItem>[
+ BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Section A'),
+ BottomNavigationBarItem(icon: Icon(Icons.work), label: 'Section B'),
+ ],
+ currentIndex: navigationShell.currentIndex,
+ onTap: (int index) => _onTap(context, index),
+ ),
+ );
+ }
+
+ /// Navigate to the current location of the branch at the provided index when
+ /// tapping an item in the BottomNavigationBar.
+ void _onTap(BuildContext context, int index) {
+ // When navigating to a new branch, it's recommended to use the goBranch
+ // method, as doing so makes sure the last navigation state of the
+ // Navigator for the branch is restored.
+ navigationShell.goBranch(
+ index,
+ // A common pattern when using bottom navigation bars is to support
+ // navigating to the initial location when tapping the item that is
+ // already active. This example demonstrates how to support this behavior,
+ // using the initialLocation parameter of goBranch.
+ initialLocation: index == navigationShell.currentIndex,
+ );
+ }
+}
+
+/// Custom branch Navigator container that provides animated transitions
+/// when switching branches.
+class AnimatedBranchContainer extends StatelessWidget {
+ /// Creates a AnimatedBranchContainer
+ const AnimatedBranchContainer(
+ {super.key, required this.currentIndex, required this.children});
+
+ /// The index (in [children]) of the branch Navigator to display.
+ final int currentIndex;
+
+ /// The children (branch Navigators) to display in this container.
+ final List<Widget> children;
+
+ @override
+ Widget build(BuildContext context) {
+ return Stack(
+ children: children.mapIndexed(
+ (int index, Widget navigator) {
+ return AnimatedScale(
+ scale: index == currentIndex ? 1 : 1.5,
+ duration: const Duration(milliseconds: 400),
+ child: AnimatedOpacity(
+ opacity: index == currentIndex ? 1 : 0,
+ duration: const Duration(milliseconds: 400),
+ child: _branchNavigatorWrapper(index, navigator),
+ ),
+ );
+ },
+ ).toList());
+ }
+
+ Widget _branchNavigatorWrapper(int index, Widget navigator) => IgnorePointer(
+ ignoring: index != currentIndex,
+ child: TickerMode(
+ enabled: index == currentIndex,
+ child: navigator,
+ ),
+ );
+}
+
+/// Widget for the root page for the first section of the bottom navigation bar.
+class RootScreenA extends StatelessWidget {
+ /// Creates a RootScreenA
+ const RootScreenA({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('Root of section A'),
+ ),
+ body: Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: <Widget>[
+ Text('Screen A', style: Theme.of(context).textTheme.titleLarge),
+ const Padding(padding: EdgeInsets.all(4)),
+ TextButton(
+ onPressed: () {
+ GoRouter.of(context).go('/a/details');
+ },
+ child: const Text('View details'),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+/// The details screen for either the A or B screen.
+class DetailsScreen extends StatefulWidget {
+ /// Constructs a [DetailsScreen].
+ const DetailsScreen({
+ required this.label,
+ this.param,
+ this.withScaffold = true,
+ super.key,
+ });
+
+ /// The label to display in the center of the screen.
+ final String label;
+
+ /// Optional param
+ final String? param;
+
+ /// Wrap in scaffold
+ final bool withScaffold;
+
+ @override
+ State<StatefulWidget> createState() => DetailsScreenState();
+}
+
+/// The state for DetailsScreen
+class DetailsScreenState extends State<DetailsScreen> {
+ int _counter = 0;
+
+ @override
+ Widget build(BuildContext context) {
+ if (widget.withScaffold) {
+ return Scaffold(
+ appBar: AppBar(
+ title: Text('Details Screen - ${widget.label}'),
+ ),
+ body: _build(context),
+ );
+ } else {
+ return Container(
+ color: Theme.of(context).scaffoldBackgroundColor,
+ child: _build(context),
+ );
+ }
+ }
+
+ Widget _build(BuildContext context) {
+ return Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: <Widget>[
+ Text('Details for ${widget.label} - Counter: $_counter',
+ style: Theme.of(context).textTheme.titleLarge),
+ const Padding(padding: EdgeInsets.all(4)),
+ TextButton(
+ onPressed: () {
+ setState(() {
+ _counter++;
+ });
+ },
+ child: const Text('Increment counter'),
+ ),
+ const Padding(padding: EdgeInsets.all(8)),
+ if (widget.param != null)
+ Text('Parameter: ${widget.param!}',
+ style: Theme.of(context).textTheme.titleMedium),
+ const Padding(padding: EdgeInsets.all(8)),
+ if (!widget.withScaffold) ...<Widget>[
+ const Padding(padding: EdgeInsets.all(16)),
+ TextButton(
+ onPressed: () {
+ GoRouter.of(context).pop();
+ },
+ child: const Text('< Back',
+ style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
+ ),
+ ]
+ ],
+ ),
+ );
+ }
+}
+
+/// Builds a nested shell using a [TabBar] and [TabBarView].
+class TabbedRootScreen extends StatefulWidget {
+ /// Constructs a TabbedRootScreen
+ const TabbedRootScreen(
+ {required this.navigationShell, required this.children, super.key});
+
+ /// The current state of the parent StatefulShellRoute.
+ final StatefulNavigationShell navigationShell;
+
+ /// The children (branch Navigators) to display in the [TabBarView].
+ final List<Widget> children;
+
+ @override
+ State<StatefulWidget> createState() => _TabbedRootScreenState();
+}
+
+class _TabbedRootScreenState extends State<TabbedRootScreen>
+ with SingleTickerProviderStateMixin {
+ late final TabController _tabController = TabController(
+ length: widget.children.length,
+ vsync: this,
+ initialIndex: widget.navigationShell.currentIndex);
+
+ @override
+ void didUpdateWidget(covariant TabbedRootScreen oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ _tabController.index = widget.navigationShell.currentIndex;
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final List<Tab> tabs = widget.children
+ .mapIndexed((int i, _) => Tab(text: 'Tab ${i + 1}'))
+ .toList();
+
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('Root of Section B (nested TabBar shell)'),
+ bottom: TabBar(
+ controller: _tabController,
+ tabs: tabs,
+ onTap: (int tappedIndex) => _onTabTap(context, tappedIndex),
+ )),
+ body: TabBarView(
+ controller: _tabController,
+ children: widget.children,
+ ),
+ );
+ }
+
+ void _onTabTap(BuildContext context, int index) {
+ widget.navigationShell.goBranch(index);
+ }
+}
+
+/// Widget for the pages in the top tab bar.
+class TabScreen extends StatelessWidget {
+ /// Creates a RootScreen
+ const TabScreen({required this.label, required this.detailsPath, super.key});
+
+ /// The label
+ final String label;
+
+ /// The path to the detail page
+ final String detailsPath;
+
+ @override
+ Widget build(BuildContext context) {
+ return Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: <Widget>[
+ Text('Screen $label', style: Theme.of(context).textTheme.titleLarge),
+ const Padding(padding: EdgeInsets.all(4)),
+ TextButton(
+ onPressed: () {
+ GoRouter.of(context).go(detailsPath);
+ },
+ child: const Text('View details'),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/packages/go_router/example/lib/others/stateful_shell_state_restoration.dart b/packages/go_router/example/lib/others/stateful_shell_state_restoration.dart
new file mode 100644
index 0000000..aeecd11
--- /dev/null
+++ b/packages/go_router/example/lib/others/stateful_shell_state_restoration.dart
@@ -0,0 +1,236 @@
+// 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';
+
+void main() => runApp(RestorableStatefulShellRouteExampleApp());
+
+/// An example demonstrating how to use StatefulShellRoute with state
+/// restoration.
+class RestorableStatefulShellRouteExampleApp extends StatelessWidget {
+ /// Creates a NestedTabNavigationExampleApp
+ RestorableStatefulShellRouteExampleApp({super.key});
+
+ final GoRouter _router = GoRouter(
+ initialLocation: '/a',
+ restorationScopeId: 'router',
+ routes: <RouteBase>[
+ StatefulShellRoute.indexedStack(
+ restorationScopeId: 'shell1',
+ pageBuilder: (BuildContext context, GoRouterState state,
+ StatefulNavigationShell navigationShell) {
+ return MaterialPage<void>(
+ restorationId: 'shellWidget1',
+ child: ScaffoldWithNavBar(navigationShell: navigationShell));
+ },
+ branches: <StatefulShellBranch>[
+ // The route branch for the first tab of the bottom navigation bar.
+ StatefulShellBranch(
+ restorationScopeId: 'branchA',
+ routes: <RouteBase>[
+ GoRoute(
+ // The screen to display as the root in the first tab of the
+ // bottom navigation bar.
+ path: '/a',
+ pageBuilder: (BuildContext context, GoRouterState state) =>
+ const MaterialPage<void>(
+ restorationId: 'screenA',
+ child:
+ RootScreen(label: 'A', detailsPath: '/a/details')),
+ routes: <RouteBase>[
+ // The details screen to display stacked on navigator of the
+ // first tab. This will cover screen A but not the application
+ // shell (bottom navigation bar).
+ GoRoute(
+ path: 'details',
+ pageBuilder: (BuildContext context, GoRouterState state) =>
+ const MaterialPage<void>(
+ restorationId: 'screenADetail',
+ child: DetailsScreen(label: 'A')),
+ ),
+ ],
+ ),
+ ],
+ ),
+ // The route branch for the second tab of the bottom navigation bar.
+ StatefulShellBranch(
+ restorationScopeId: 'branchB',
+ routes: <RouteBase>[
+ GoRoute(
+ // The screen to display as the root in the second tab of the
+ // bottom navigation bar.
+ path: '/b',
+ pageBuilder: (BuildContext context, GoRouterState state) =>
+ const MaterialPage<void>(
+ restorationId: 'screenB',
+ child:
+ RootScreen(label: 'B', detailsPath: '/b/details')),
+ routes: <RouteBase>[
+ // The details screen to display stacked on navigator of the
+ // first tab. This will cover screen A but not the application
+ // shell (bottom navigation bar).
+ GoRoute(
+ path: 'details',
+ pageBuilder: (BuildContext context, GoRouterState state) =>
+ const MaterialPage<void>(
+ restorationId: 'screenBDetail',
+ child: DetailsScreen(label: 'B')),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ],
+ ),
+ ],
+ );
+
+ @override
+ Widget build(BuildContext context) {
+ return MaterialApp.router(
+ restorationScopeId: 'app',
+ title: 'Flutter Demo',
+ theme: ThemeData(
+ primarySwatch: Colors.blue,
+ ),
+ routerConfig: _router,
+ );
+ }
+}
+
+/// Builds the "shell" for the app by building a Scaffold with a
+/// BottomNavigationBar, where [child] is placed in the body of the Scaffold.
+class ScaffoldWithNavBar extends StatelessWidget {
+ /// Constructs an [ScaffoldWithNavBar].
+ const ScaffoldWithNavBar({
+ required this.navigationShell,
+ Key? key,
+ }) : super(key: key ?? const ValueKey<String>('ScaffoldWithNavBar'));
+
+ /// The navigation shell and container for the branch Navigators.
+ final StatefulNavigationShell navigationShell;
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ body: navigationShell,
+ bottomNavigationBar: BottomNavigationBar(
+ items: const <BottomNavigationBarItem>[
+ BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Section A'),
+ BottomNavigationBarItem(icon: Icon(Icons.work), label: 'Section B'),
+ ],
+ currentIndex: navigationShell.currentIndex,
+ onTap: (int tappedIndex) => navigationShell.goBranch(tappedIndex),
+ ),
+ );
+ }
+}
+
+/// Widget for the root/initial pages in the bottom navigation bar.
+class RootScreen extends StatelessWidget {
+ /// Creates a RootScreen
+ const RootScreen({
+ required this.label,
+ required this.detailsPath,
+ super.key,
+ });
+
+ /// The label
+ final String label;
+
+ /// The path to the detail page
+ final String detailsPath;
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(
+ title: Text('Root of section $label'),
+ ),
+ body: Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: <Widget>[
+ Text('Screen $label',
+ style: Theme.of(context).textTheme.titleLarge),
+ const Padding(padding: EdgeInsets.all(4)),
+ TextButton(
+ onPressed: () {
+ GoRouter.of(context).go(detailsPath);
+ },
+ child: const Text('View details'),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+/// The details screen for either the A or B screen.
+class DetailsScreen extends StatefulWidget {
+ /// Constructs a [DetailsScreen].
+ const DetailsScreen({
+ required this.label,
+ super.key,
+ });
+
+ /// The label to display in the center of the screen.
+ final String label;
+
+ @override
+ State<StatefulWidget> createState() => DetailsScreenState();
+}
+
+/// The state for DetailsScreen
+class DetailsScreenState extends State<DetailsScreen> with RestorationMixin {
+ final RestorableInt _counter = RestorableInt(0);
+
+ @override
+ String? get restorationId => 'DetailsScreen-${widget.label}';
+
+ @override
+ void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
+ registerForRestoration(_counter, 'counter');
+ }
+
+ @override
+ void dispose() {
+ super.dispose();
+ _counter.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(
+ title: Text('Details Screen - ${widget.label}'),
+ ),
+ body: _build(context),
+ );
+ }
+
+ Widget _build(BuildContext context) {
+ return Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: <Widget>[
+ Text('Details for ${widget.label} - Counter: ${_counter.value}',
+ style: Theme.of(context).textTheme.titleLarge),
+ const Padding(padding: EdgeInsets.all(4)),
+ TextButton(
+ onPressed: () {
+ setState(() {
+ _counter.value++;
+ });
+ },
+ child: const Text('Increment counter'),
+ ),
+ const Padding(padding: EdgeInsets.all(8)),
+ ],
+ ),
+ );
+ }
+}
diff --git a/packages/go_router/example/lib/stateful_shell_route.dart b/packages/go_router/example/lib/stateful_shell_route.dart
new file mode 100644
index 0000000..eb0a67b
--- /dev/null
+++ b/packages/go_router/example/lib/stateful_shell_route.dart
@@ -0,0 +1,324 @@
+// 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';
+
+final GlobalKey<NavigatorState> _rootNavigatorKey =
+ GlobalKey<NavigatorState>(debugLabel: 'root');
+final GlobalKey<NavigatorState> _sectionANavigatorKey =
+ GlobalKey<NavigatorState>(debugLabel: 'sectionANav');
+
+// This example demonstrates how to setup nested navigation using a
+// BottomNavigationBar, where each bar item uses its own persistent navigator,
+// i.e. navigation state is maintained separately for each item. This setup also
+// enables deep linking into nested pages.
+
+void main() {
+ runApp(NestedTabNavigationExampleApp());
+}
+
+/// An example demonstrating how to use nested navigators
+class NestedTabNavigationExampleApp extends StatelessWidget {
+ /// Creates a NestedTabNavigationExampleApp
+ NestedTabNavigationExampleApp({super.key});
+
+ final GoRouter _router = GoRouter(
+ navigatorKey: _rootNavigatorKey,
+ initialLocation: '/a',
+ routes: <RouteBase>[
+ StatefulShellRoute.indexedStack(
+ builder: (BuildContext context, GoRouterState state,
+ StatefulNavigationShell navigationShell) {
+ // Return the widget that implements the custom shell (in this case
+ // using a BottomNavigationBar). The StatefulNavigationShell is passed
+ // to be able access the state of the shell and to navigate to other
+ // branches in a stateful way.
+ return ScaffoldWithNavBar(navigationShell: navigationShell);
+ },
+ branches: <StatefulShellBranch>[
+ // The route branch for the first tab of the bottom navigation bar.
+ StatefulShellBranch(
+ navigatorKey: _sectionANavigatorKey,
+ routes: <RouteBase>[
+ GoRoute(
+ // The screen to display as the root in the first tab of the
+ // bottom navigation bar.
+ path: '/a',
+ builder: (BuildContext context, GoRouterState state) =>
+ const RootScreen(label: 'A', detailsPath: '/a/details'),
+ routes: <RouteBase>[
+ // The details screen to display stacked on navigator of the
+ // first tab. This will cover screen A but not the application
+ // shell (bottom navigation bar).
+ GoRoute(
+ path: 'details',
+ builder: (BuildContext context, GoRouterState state) =>
+ const DetailsScreen(label: 'A'),
+ ),
+ ],
+ ),
+ ],
+ ),
+
+ // The route branch for the second tab of the bottom navigation bar.
+ StatefulShellBranch(
+ // It's not necessary to provide a navigatorKey if it isn't also
+ // needed elsewhere. If not provided, a default key will be used.
+ routes: <RouteBase>[
+ GoRoute(
+ // The screen to display as the root in the second tab of the
+ // bottom navigation bar.
+ path: '/b',
+ builder: (BuildContext context, GoRouterState state) =>
+ const RootScreen(
+ label: 'B',
+ detailsPath: '/b/details/1',
+ secondDetailsPath: '/b/details/2',
+ ),
+ routes: <RouteBase>[
+ GoRoute(
+ path: 'details/:param',
+ builder: (BuildContext context, GoRouterState state) =>
+ DetailsScreen(
+ label: 'B',
+ param: state.pathParameters['param'],
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+
+ // The route branch for the third tab of the bottom navigation bar.
+ StatefulShellBranch(
+ routes: <RouteBase>[
+ GoRoute(
+ // The screen to display as the root in the third tab of the
+ // bottom navigation bar.
+ path: '/c',
+ builder: (BuildContext context, GoRouterState state) =>
+ const RootScreen(
+ label: 'C',
+ detailsPath: '/c/details',
+ ),
+ routes: <RouteBase>[
+ GoRoute(
+ path: 'details',
+ builder: (BuildContext context, GoRouterState state) =>
+ DetailsScreen(
+ label: 'C',
+ extra: state.extra,
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ],
+ ),
+ ],
+ );
+
+ @override
+ Widget build(BuildContext context) {
+ return MaterialApp.router(
+ title: 'Flutter Demo',
+ theme: ThemeData(
+ primarySwatch: Colors.blue,
+ ),
+ routerConfig: _router,
+ );
+ }
+}
+
+/// Builds the "shell" for the app by building a Scaffold with a
+/// BottomNavigationBar, where [child] is placed in the body of the Scaffold.
+class ScaffoldWithNavBar extends StatelessWidget {
+ /// Constructs an [ScaffoldWithNavBar].
+ const ScaffoldWithNavBar({
+ required this.navigationShell,
+ Key? key,
+ }) : super(key: key ?? const ValueKey<String>('ScaffoldWithNavBar'));
+
+ /// The navigation shell and container for the branch Navigators.
+ final StatefulNavigationShell navigationShell;
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ body: navigationShell,
+ bottomNavigationBar: BottomNavigationBar(
+ // Here, the items of BottomNavigationBar are hard coded. In a real
+ // world scenario, the items would most likely be generated from the
+ // branches of the shell route, which can be fetched using
+ // `navigationShell.route.branches`.
+ items: const <BottomNavigationBarItem>[
+ BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Section A'),
+ BottomNavigationBarItem(icon: Icon(Icons.work), label: 'Section B'),
+ BottomNavigationBarItem(icon: Icon(Icons.tab), label: 'Section C'),
+ ],
+ currentIndex: navigationShell.currentIndex,
+ onTap: (int index) => _onTap(context, index),
+ ),
+ );
+ }
+
+ /// Navigate to the current location of the branch at the provided index when
+ /// tapping an item in the BottomNavigationBar.
+ void _onTap(BuildContext context, int index) {
+ // When navigating to a new branch, it's recommended to use the goBranch
+ // method, as doing so makes sure the last navigation state of the
+ // Navigator for the branch is restored.
+ navigationShell.goBranch(
+ index,
+ // A common pattern when using bottom navigation bars is to support
+ // navigating to the initial location when tapping the item that is
+ // already active. This example demonstrates how to support this behavior,
+ // using the initialLocation parameter of goBranch.
+ initialLocation: index == navigationShell.currentIndex,
+ );
+ }
+}
+
+/// Widget for the root/initial pages in the bottom navigation bar.
+class RootScreen extends StatelessWidget {
+ /// Creates a RootScreen
+ const RootScreen({
+ required this.label,
+ required this.detailsPath,
+ this.secondDetailsPath,
+ super.key,
+ });
+
+ /// The label
+ final String label;
+
+ /// The path to the detail page
+ final String detailsPath;
+
+ /// The path to another detail page
+ final String? secondDetailsPath;
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(
+ title: Text('Root of section $label'),
+ ),
+ body: Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: <Widget>[
+ Text('Screen $label',
+ style: Theme.of(context).textTheme.titleLarge),
+ const Padding(padding: EdgeInsets.all(4)),
+ TextButton(
+ onPressed: () {
+ GoRouter.of(context).go(detailsPath, extra: '$label-XYZ');
+ },
+ child: const Text('View details'),
+ ),
+ const Padding(padding: EdgeInsets.all(4)),
+ if (secondDetailsPath != null)
+ TextButton(
+ onPressed: () {
+ GoRouter.of(context).go(secondDetailsPath!);
+ },
+ child: const Text('View more details'),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+/// The details screen for either the A or B screen.
+class DetailsScreen extends StatefulWidget {
+ /// Constructs a [DetailsScreen].
+ const DetailsScreen({
+ required this.label,
+ this.param,
+ this.extra,
+ this.withScaffold = true,
+ super.key,
+ });
+
+ /// The label to display in the center of the screen.
+ final String label;
+
+ /// Optional param
+ final String? param;
+
+ /// Optional extra object
+ final Object? extra;
+
+ /// Wrap in scaffold
+ final bool withScaffold;
+
+ @override
+ State<StatefulWidget> createState() => DetailsScreenState();
+}
+
+/// The state for DetailsScreen
+class DetailsScreenState extends State<DetailsScreen> {
+ int _counter = 0;
+
+ @override
+ Widget build(BuildContext context) {
+ if (widget.withScaffold) {
+ return Scaffold(
+ appBar: AppBar(
+ title: Text('Details Screen - ${widget.label}'),
+ ),
+ body: _build(context),
+ );
+ } else {
+ return Container(
+ color: Theme.of(context).scaffoldBackgroundColor,
+ child: _build(context),
+ );
+ }
+ }
+
+ Widget _build(BuildContext context) {
+ return Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: <Widget>[
+ Text('Details for ${widget.label} - Counter: $_counter',
+ style: Theme.of(context).textTheme.titleLarge),
+ const Padding(padding: EdgeInsets.all(4)),
+ TextButton(
+ onPressed: () {
+ setState(() {
+ _counter++;
+ });
+ },
+ child: const Text('Increment counter'),
+ ),
+ const Padding(padding: EdgeInsets.all(8)),
+ if (widget.param != null)
+ Text('Parameter: ${widget.param!}',
+ style: Theme.of(context).textTheme.titleMedium),
+ const Padding(padding: EdgeInsets.all(8)),
+ if (widget.extra != null)
+ Text('Extra: ${widget.extra!}',
+ style: Theme.of(context).textTheme.titleMedium),
+ if (!widget.withScaffold) ...<Widget>[
+ const Padding(padding: EdgeInsets.all(16)),
+ TextButton(
+ onPressed: () {
+ GoRouter.of(context).pop();
+ },
+ child: const Text('< Back',
+ style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
+ ),
+ ]
+ ],
+ ),
+ );
+ }
+}
diff --git a/packages/go_router/lib/go_router.dart b/packages/go_router/lib/go_router.dart
index bcf426f..31ed9c8 100644
--- a/packages/go_router/lib/go_router.dart
+++ b/packages/go_router/lib/go_router.dart
@@ -7,7 +7,16 @@
library go_router;
export 'src/configuration.dart'
- show GoRoute, GoRouterState, RouteBase, ShellRoute;
+ show
+ GoRoute,
+ GoRouterState,
+ RouteBase,
+ ShellRoute,
+ ShellNavigationContainerBuilder,
+ StatefulNavigationShell,
+ StatefulNavigationShellState,
+ StatefulShellBranch,
+ StatefulShellRoute;
export 'src/misc/extensions.dart';
export 'src/misc/inherited_router.dart';
export 'src/pages/custom_transition_page.dart';
@@ -21,4 +30,11 @@
TypedShellRoute;
export 'src/router.dart';
export 'src/typedefs.dart'
- show GoRouterPageBuilder, GoRouterRedirect, GoRouterWidgetBuilder;
+ show
+ GoRouterPageBuilder,
+ GoRouterRedirect,
+ GoRouterWidgetBuilder,
+ ShellRouteBuilder,
+ ShellRoutePageBuilder,
+ StatefulShellRouteBuilder,
+ StatefulShellRoutePageBuilder;
diff --git a/packages/go_router/lib/src/builder.dart b/packages/go_router/lib/src/builder.dart
index c497a79..f151251 100644
--- a/packages/go_router/lib/src/builder.dart
+++ b/packages/go_router/lib/src/builder.dart
@@ -5,17 +5,28 @@
import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart';
+import '../go_router.dart';
import 'configuration.dart';
import 'logging.dart';
import 'match.dart';
import 'matching.dart';
import 'misc/error_screen.dart';
import 'pages/cupertino.dart';
-import 'pages/custom_transition_page.dart';
import 'pages/material.dart';
import 'route_data.dart';
import 'typedefs.dart';
+/// 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.
+///
+/// The `match` is the corresponding [RouteMatch] the `route`
+/// associates with.
+///
+/// Used by of [RouteBuilder.onPopPageWithRouteMatch].
+typedef PopPageWithRouteMatchCallback = bool Function(
+ Route<dynamic> route, dynamic result, RouteMatch? match);
+
/// Builds the top-level Navigator for GoRouter.
class RouteBuilder {
/// [RouteBuilder] constructor.
@@ -26,6 +37,7 @@
required this.errorBuilder,
required this.restorationScopeId,
required this.observers,
+ required this.onPopPageWithRouteMatch,
});
/// Builder function for a go router with Navigator.
@@ -50,19 +62,18 @@
final GoRouterStateRegistry _registry = GoRouterStateRegistry();
- final Map<Page<Object?>, RouteMatch> _routeMatchLookUp =
- <Page<Object?>, RouteMatch>{};
-
- /// Looks the the [RouteMatch] for a given [Page].
+ /// A callback called when a `route` produced by `match` is about to be popped
+ /// with the `result`.
///
- /// The [Page] must be in the latest [Navigator.pages]; otherwise, this method
- /// returns null.
- RouteMatch? getRouteMatchForPage(Page<Object?> page) =>
- _routeMatchLookUp[page];
+ /// If this method returns true, this builder pops the `route` and `match`.
+ ///
+ /// If this method returns false, this builder aborts the pop.
+ final PopPageWithRouteMatchCallback onPopPageWithRouteMatch;
- // final Map<>
/// 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>{};
@@ -70,7 +81,6 @@
Widget build(
BuildContext context,
RouteMatchList matchList,
- PopPageCallback onPopPage,
bool routerNeglect,
) {
if (matchList.isEmpty) {
@@ -85,14 +95,14 @@
try {
final Map<Page<Object?>, GoRouterState> newRegistry =
<Page<Object?>, GoRouterState>{};
- final Widget result = tryBuild(context, matchList, onPopPage,
- routerNeglect, configuration.navigatorKey, newRegistry);
+ final Widget result = tryBuild(context, matchList, routerNeglect,
+ configuration.navigatorKey, newRegistry);
_registry.updateRegistry(newRegistry);
return GoRouterStateRegistryScope(
registry: _registry, child: result);
} on _RouteBuilderError catch (e) {
- return _buildErrorNavigator(context, e, matchList.uri, onPopPage,
- configuration.navigatorKey);
+ return _buildErrorNavigator(context, e, matchList.uri,
+ onPopPageWithRouteMatch, configuration.navigatorKey);
}
},
),
@@ -107,60 +117,79 @@
Widget tryBuild(
BuildContext context,
RouteMatchList matchList,
- PopPageCallback onPopPage,
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(
- onPopPage,
- buildPages(context, matchList, onPopPage, routerNeglect, navigatorKey,
- registry),
+ pagePopContext.onPopPage,
+ _buildPages(context, matchList, pagePopContext, routerNeglect,
+ navigatorKey, registry),
navigatorKey,
observers: observers,
+ restorationScopeId: restorationScopeId,
),
);
}
/// Returns the top-level pages instead of the root navigator. Used for
/// testing.
- @visibleForTesting
- List<Page<Object?>> buildPages(
+ List<Page<Object?>> _buildPages(
BuildContext context,
RouteMatchList matchList,
- PopPageCallback onPopPage,
+ _PagePopContext pagePopContext,
bool routerNeglect,
GlobalKey<NavigatorState> navigatorKey,
Map<Page<Object?>, GoRouterState> registry) {
final Map<GlobalKey<NavigatorState>, List<Page<Object?>>> keyToPage =
<GlobalKey<NavigatorState>, List<Page<Object?>>>{};
try {
- _routeMatchLookUp.clear();
- _buildRecursive(context, matchList, 0, onPopPage, routerNeglect,
+ _buildRecursive(context, matchList, 0, pagePopContext, routerNeglect,
keyToPage, navigatorKey, registry);
// Every Page should have a corresponding RouteMatch.
- assert(keyToPage.values.flattened
- .every((Page<Object?> page) => _routeMatchLookUp.containsKey(page)));
+ assert(keyToPage.values.flattened.every((Page<Object?> page) =>
+ pagePopContext.getRouteMatchForPage(page) != null));
return keyToPage[navigatorKey]!;
} on _RouteBuilderError catch (e) {
return <Page<Object?>>[
_buildErrorPage(context, e, matchList.uri),
];
} finally {
- /// Clean up previous cache to prevent memory leak.
+ /// 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, _) => !keyToPage.keys.contains(key));
+ (GlobalKey<NavigatorState> key, _) => !activeKeys.contains(key));
}
}
+ 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,
- PopPageCallback onPopPage,
+ _PagePopContext pagePopContext,
bool routerNeglect,
Map<GlobalKey<NavigatorState>, List<Page<Object?>>> keyToPages,
GlobalKey<NavigatorState> navigatorKey,
@@ -178,9 +207,9 @@
final RouteBase route = match.route;
final GoRouterState state = buildState(matchList, match);
+ Page<Object?>? page;
if (route is GoRoute) {
- final Page<Object?> page = _buildPageForRoute(context, state, match);
- registry[page] = state;
+ page = _buildPageForGoRoute(context, state, match, route, pagePopContext);
// If this GoRoute is for a different Navigator, add it to the
// list of out of scope pages
final GlobalKey<NavigatorState> goRouteNavKey =
@@ -188,68 +217,100 @@
keyToPages.putIfAbsent(goRouteNavKey, () => <Page<Object?>>[]).add(page);
- _buildRecursive(context, matchList, startIndex + 1, onPopPage,
+ _buildRecursive(context, matchList, startIndex + 1, pagePopContext,
routerNeglect, keyToPages, navigatorKey, registry);
- } else if (route is ShellRoute) {
+ } else if (route is ShellRouteBase) {
+ assert(startIndex + 1 < matchList.matches.length,
+ 'Shell routes must always have child routes');
// The key for the Navigator that will display this ShellRoute's page.
final GlobalKey<NavigatorState> parentNavigatorKey = navigatorKey;
- // The key to provide to the ShellRoute's Navigator.
- final GlobalKey<NavigatorState> shellNavigatorKey = route.navigatorKey;
-
- // The observers list for the ShellRoute's Navigator.
- final List<NavigatorObserver> observers =
- route.observers ?? <NavigatorObserver>[];
-
// Add an entry for the parent navigator if none exists.
keyToPages.putIfAbsent(parentNavigatorKey, () => <Page<Object?>>[]);
- // Add an entry for the shell route's navigator
- keyToPages.putIfAbsent(shellNavigatorKey, () => <Page<Object?>>[]);
-
// 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[parentNavigatorKey]!.length;
+ // Get the current sub-route of this shell route from the match list.
+ final RouteBase subRoute = matchList.matches[startIndex + 1].route;
+
+ // The key to provide to the shell route's Navigator.
+ final GlobalKey<NavigatorState> shellNavigatorKey =
+ route.navigatorKeyForSubRoute(subRoute);
+
+ // Add an entry for the shell route's navigator
+ keyToPages.putIfAbsent(shellNavigatorKey, () => <Page<Object?>>[]);
+
// Build the remaining pages
- _buildRecursive(context, matchList, startIndex + 1, onPopPage,
+ _buildRecursive(context, matchList, startIndex + 1, pagePopContext,
routerNeglect, keyToPages, shellNavigatorKey, registry);
final HeroController heroController = _goHeroCache.putIfAbsent(
shellNavigatorKey, () => _getHeroController(context));
- // Build the Navigator
- final Widget child = HeroControllerScope(
- controller: heroController,
- child: _buildNavigator(
- onPopPage, keyToPages[shellNavigatorKey]!, shellNavigatorKey,
- observers: observers),
+
+ // Build the Navigator for this shell route
+ Widget buildShellNavigator(
+ List<NavigatorObserver>? observers, String? restorationScopeId) {
+ return _buildNavigator(
+ pagePopContext.onPopPage,
+ keyToPages[shellNavigatorKey]!,
+ shellNavigatorKey,
+ observers: observers ?? const <NavigatorObserver>[],
+ restorationScopeId: restorationScopeId,
+ heroController: heroController,
+ );
+ }
+
+ // 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
- final Page<Object?> page =
- _buildPageForRoute(context, state, match, child: child);
- registry[page] = state;
+ page = _buildPageForShellRoute(
+ context, state, match, route, pagePopContext, shellRouteContext);
// Place the ShellRoute's Page onto the list for the parent navigator.
keyToPages
.putIfAbsent(parentNavigatorKey, () => <Page<Object?>>[])
.insert(shellPageIdx, page);
}
+ if (page != null) {
+ registry[page] = state;
+ pagePopContext._setRouteMatchForPage(page, match);
+ } else {
+ throw _RouteBuilderException('Unsupported route type $route');
+ }
}
- Navigator _buildNavigator(
+ static Widget _buildNavigator(
PopPageCallback onPopPage,
List<Page<Object?>> pages,
Key? navigatorKey, {
List<NavigatorObserver> observers = const <NavigatorObserver>[],
+ String? restorationScopeId,
+ HeroController? heroController,
}) {
- return Navigator(
+ final Widget navigator = Navigator(
key: navigatorKey,
restorationScopeId: restorationScopeId,
pages: pages,
observers: observers,
onPopPage: onPopPage,
);
+ if (heroController != null) {
+ return HeroControllerScope(
+ controller: heroController,
+ child: navigator,
+ );
+ } else {
+ return navigator;
+ }
}
/// Helper method that builds a [GoRouterState] object for the given [match]
@@ -282,70 +343,72 @@
);
}
- /// Builds a [Page] for [StackedRoute]
- Page<Object?> _buildPageForRoute(
- BuildContext context, GoRouterState state, RouteMatch match,
- {Widget? child}) {
- final RouteBase route = match.route;
+ /// Builds a [Page] for [GoRoute]
+ Page<Object?> _buildPageForGoRoute(BuildContext context, GoRouterState state,
+ RouteMatch match, GoRoute route, _PagePopContext pagePopContext) {
Page<Object?>? page;
- if (route is GoRoute) {
- // Call the pageBuilder if it's non-null
- final GoRouterPageBuilder? pageBuilder = route.pageBuilder;
- if (pageBuilder != null) {
- page = pageBuilder(context, state);
- }
- } else if (route is ShellRoute) {
- final ShellRoutePageBuilder? pageBuilder = route.pageBuilder;
- assert(child != null, 'ShellRoute must contain a child route');
- if (pageBuilder != null) {
- page = pageBuilder(context, state, child!);
+ // Call the pageBuilder if it's non-null
+ final GoRouterPageBuilder? pageBuilder = route.pageBuilder;
+ if (pageBuilder != null) {
+ page = pageBuilder(context, state);
+ if (page is NoOpPage) {
+ page = null;
}
}
+ // Return the result of the route's builder() or pageBuilder()
+ return page ??
+ buildPage(context, state, Builder(builder: (BuildContext context) {
+ return _callGoRouteBuilder(context, state, route);
+ }));
+ }
+
+ /// Calls the user-provided route builder from the [GoRoute].
+ Widget _callGoRouteBuilder(
+ BuildContext context, GoRouterState state, GoRoute route) {
+ final GoRouterWidgetBuilder? builder = route.builder;
+
+ if (builder == null) {
+ throw _RouteBuilderError('No routeBuilder provided to GoRoute: $route');
+ }
+
+ return builder(context, state);
+ }
+
+ /// Builds a [Page] for [ShellRouteBase]
+ 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;
}
- page ??= buildPage(context, state, Builder(builder: (BuildContext context) {
- return _callRouteBuilder(context, state, match, childWidget: child);
- }));
- _routeMatchLookUp[page] = match;
-
// Return the result of the route's builder() or pageBuilder()
- return page;
+ return page ??
+ buildPage(context, state, Builder(builder: (BuildContext context) {
+ return _callShellRouteBaseBuilder(
+ context, state, route, shellRouteContext);
+ }));
}
- /// Calls the user-provided route builder from the [RouteMatch]'s [RouteBase].
- Widget _callRouteBuilder(
- BuildContext context, GoRouterState state, RouteMatch match,
- {Widget? childWidget}) {
- final RouteBase route = match.route;
-
- if (route is GoRoute) {
- final GoRouterWidgetBuilder? builder = route.builder;
-
- if (builder == null) {
- throw _RouteBuilderError('No routeBuilder provided to GoRoute: $route');
- }
-
- return builder(context, state);
- } else if (route is ShellRoute) {
- if (childWidget == null) {
- throw _RouteBuilderException(
- 'Attempt to build ShellRoute without a child widget');
- }
-
- final ShellRouteBuilder? builder = route.builder;
-
- if (builder == null) {
- throw _RouteBuilderError('No builder provided to ShellRoute: $route');
- }
-
- return builder(context, state, childWidget);
+ /// 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 _RouteBuilderError('No builder provided to ShellRoute: $route');
}
- throw _RouteBuilderException('Unsupported route type $route');
+ return widget;
}
_PageBuilderForAppType? _pageBuilderForAppType;
@@ -427,10 +490,10 @@
BuildContext context,
_RouteBuilderError e,
Uri uri,
- PopPageCallback onPopPage,
+ PopPageWithRouteMatchCallback onPopPage,
GlobalKey<NavigatorState> navigatorKey) {
return _buildNavigator(
- onPopPage,
+ (Route<dynamic> route, dynamic result) => onPopPage(route, result, null),
<Page<Object?>>[
_buildErrorPage(context, e, uri),
],
@@ -529,3 +592,33 @@
return '$message ${exception ?? ""}';
}
}
+
+/// Context used to provide a route to page association when popping routes.
+class _PagePopContext {
+ _PagePopContext._(this.onPopPageWithRouteMatch);
+
+ final Map<Page<dynamic>, RouteMatch> _routeMatchLookUp =
+ <Page<Object?>, 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.
+ RouteMatch? getRouteMatchForPage(Page<Object?> page) =>
+ _routeMatchLookUp[page];
+
+ void _setRouteMatchForPage(Page<Object?> page, RouteMatch match) =>
+ _routeMatchLookUp[page] = match;
+
+ /// Function used as [Navigator.onPopPage] callback when creating Navigators.
+ ///
+ /// This function forwards to [onPopPageWithRouteMatch], including the
+ /// [RouteMatch] associated with the popped route.
+ bool onPopPage(Route<dynamic> route, dynamic result) {
+ final Page<Object?> page = route.settings as Page<Object?>;
+ return onPopPageWithRouteMatch(route, result, _routeMatchLookUp[page]);
+ }
+}
diff --git a/packages/go_router/lib/src/configuration.dart b/packages/go_router/lib/src/configuration.dart
index 2c2f8e7..07d02d4 100644
--- a/packages/go_router/lib/src/configuration.dart
+++ b/packages/go_router/lib/src/configuration.dart
@@ -6,6 +6,7 @@
import 'configuration.dart';
import 'logging.dart';
+import 'matching.dart';
import 'misc/errors.dart';
import 'path_utils.dart';
import 'typedefs.dart';
@@ -25,6 +26,8 @@
_debugVerifyNoDuplicatePathParameter(routes, <String, GoRoute>{})),
assert(_debugCheckParentNavigatorKeys(
routes, <GlobalKey<NavigatorState>>[navigatorKey])) {
+ assert(_debugCheckStatefulShellBranchDefaultLocations(
+ routes, RouteMatcher(this)));
_cacheNameToPath('', routes);
log.info(debugKnownRoutes());
}
@@ -41,7 +44,7 @@
'sub-route path may not start or end with /: $route');
}
subRouteIsTopLevel = false;
- } else if (route is ShellRoute) {
+ } else if (route is ShellRouteBase) {
subRouteIsTopLevel = isTopLevel;
}
_debugCheckPath(route.routes, subRouteIsTopLevel);
@@ -86,6 +89,21 @@
route.routes,
<GlobalKey<NavigatorState>>[...allowedKeys..add(route.navigatorKey)],
);
+ } else if (route is StatefulShellRoute) {
+ for (final StatefulShellBranch branch in route.branches) {
+ assert(
+ !allowedKeys.contains(branch.navigatorKey),
+ 'StatefulShellBranch must not reuse an ancestor navigatorKey '
+ '(${branch.navigatorKey})');
+
+ _debugCheckParentNavigatorKeys(
+ branch.routes,
+ <GlobalKey<NavigatorState>>[
+ ...allowedKeys,
+ branch.navigatorKey,
+ ],
+ );
+ }
}
}
return true;
@@ -111,6 +129,56 @@
return true;
}
+ // Check to see that the configured initialLocation of StatefulShellBranches
+ // points to a descendant route of the route branch.
+ bool _debugCheckStatefulShellBranchDefaultLocations(
+ List<RouteBase> routes, RouteMatcher matcher) {
+ try {
+ for (final RouteBase route in routes) {
+ if (route is StatefulShellRoute) {
+ for (final StatefulShellBranch branch in route.branches) {
+ if (branch.initialLocation == null) {
+ // Recursively search for the first GoRoute descendant. Will
+ // throw assertion error if not found.
+ final GoRoute? route = branch.defaultRoute;
+ final String? initialLocation =
+ route != null ? locationForRoute(route) : null;
+ assert(
+ initialLocation != null,
+ 'The default location of a StatefulShellBranch must be '
+ 'derivable from GoRoute descendant');
+ assert(
+ route!.pathParameters.isEmpty,
+ 'The default location of a StatefulShellBranch cannot be '
+ 'a parameterized route');
+ } else {
+ final List<RouteBase> matchRoutes =
+ matcher.findMatch(branch.initialLocation!).routes;
+ final int shellIndex = matchRoutes.indexOf(route);
+ bool matchFound = false;
+ if (shellIndex >= 0 && (shellIndex + 1) < matchRoutes.length) {
+ final RouteBase branchRoot = matchRoutes[shellIndex + 1];
+ matchFound = branch.routes.contains(branchRoot);
+ }
+ assert(
+ matchFound,
+ 'The initialLocation (${branch.initialLocation}) of '
+ 'StatefulShellBranch must match a descendant route of the '
+ 'branch');
+ }
+ }
+ }
+ _debugCheckStatefulShellBranchDefaultLocations(route.routes, matcher);
+ }
+ } on MatcherError catch (e) {
+ assert(
+ false,
+ 'initialLocation (${e.location}) of StatefulShellBranch must '
+ 'be a valid location');
+ }
+ return true;
+ }
+
/// The list of top level routes used by [GoRouterDelegate].
final List<RouteBase> routes;
@@ -167,6 +235,13 @@
.toString();
}
+ /// Get the location for the provided route.
+ ///
+ /// Builds the absolute path for the route, by concatenating the paths of the
+ /// route and all its ancestors.
+ String? locationForRoute(RouteBase route) =>
+ fullPathForRoute(route, '', routes);
+
@override
String toString() {
return 'RouterConfiguration: $routes';
@@ -222,7 +297,7 @@
if (route.routes.isNotEmpty) {
_cacheNameToPath(fullPath, route.routes);
}
- } else if (route is ShellRoute) {
+ } else if (route is ShellRouteBase) {
if (route.routes.isNotEmpty) {
_cacheNameToPath(parentFullPath, route.routes);
}
diff --git a/packages/go_router/lib/src/delegate.dart b/packages/go_router/lib/src/delegate.dart
index a62906a..985dce3 100644
--- a/packages/go_router/lib/src/delegate.dart
+++ b/packages/go_router/lib/src/delegate.dart
@@ -27,25 +27,29 @@
required List<NavigatorObserver> observers,
required this.routerNeglect,
String? restorationScopeId,
- }) : _configuration = configuration,
- builder = RouteBuilder(
- configuration: configuration,
- builderWithNav: builderWithNav,
- errorPageBuilder: errorPageBuilder,
- errorBuilder: errorBuilder,
- restorationScopeId: restorationScopeId,
- observers: observers,
- );
+ }) : _configuration = configuration {
+ builder = RouteBuilder(
+ configuration: configuration,
+ builderWithNav: builderWithNav,
+ errorPageBuilder: errorPageBuilder,
+ errorBuilder: errorBuilder,
+ restorationScopeId: restorationScopeId,
+ observers: observers,
+ onPopPageWithRouteMatch: _handlePopPageWithRouteMatch,
+ );
+ }
/// Builds the top-level Navigator given a configuration and location.
@visibleForTesting
- final RouteBuilder builder;
+ late final RouteBuilder builder;
/// Set to true to disable creating history entries on the web.
final bool routerNeglect;
RouteMatchList _matchList = RouteMatchList.empty;
+ final RouteConfiguration _configuration;
+
/// Stores the number of times each route route has been pushed.
///
/// This is used to generate a unique key for each route.
@@ -58,7 +62,6 @@
/// }
/// ```
final Map<String, int> _pushCounts = <String, int>{};
- final RouteConfiguration _configuration;
_NavigatorStateIterator _createNavigatorStateIterator() =>
_NavigatorStateIterator(_matchList, navigatorKey.currentState!);
@@ -85,10 +88,6 @@
Future<T?> _push<T extends Object?>(
RouteMatchList matches, ValueKey<String> pageKey) async {
final ImperativeRouteMatch<T> newPageKeyMatch = ImperativeRouteMatch<T>(
- route: matches.last.route,
- matchedLocation: matches.last.matchedLocation,
- extra: matches.last.extra,
- error: matches.last.error,
pageKey: pageKey,
matches: matches,
);
@@ -149,12 +148,11 @@
);
}
- bool _onPopPage(Route<Object?> route, Object? result) {
+ bool _handlePopPageWithRouteMatch(
+ Route<Object?> route, Object? result, RouteMatch? match) {
if (!route.didPop(result)) {
return false;
}
- final Page<Object?> page = route.settings as Page<Object?>;
- final RouteMatch? match = builder.getRouteMatchForPage(page);
assert(match != null);
if (match is ImperativeRouteMatch) {
match.complete(result);
@@ -219,7 +217,6 @@
return builder.build(
context,
_matchList,
- _onPopPage,
routerNeglect,
);
}
@@ -257,6 +254,7 @@
if (index < 0) {
return false;
}
+ late RouteBase subRoute;
for (index -= 1; index >= 0; index -= 1) {
final RouteMatch match = matchList.matches[index];
final RouteBase route = match.route;
@@ -294,19 +292,22 @@
current = parentNavigatorKey.currentState!;
return true;
- } else if (route is ShellRoute) {
+ } else if (route is ShellRouteBase) {
// Must have a ModalRoute parent because the navigator ShellRoute
// created must not be the root navigator.
+ final GlobalKey<NavigatorState> navigatorKey =
+ route.navigatorKeyForSubRoute(subRoute);
final ModalRoute<Object?> parentModalRoute =
- ModalRoute.of(route.navigatorKey.currentContext!)!;
+ 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 == false) {
continue;
}
- current = route.navigatorKey.currentState!;
+ current = navigatorKey.currentState!;
return true;
}
+ subRoute = route;
}
assert(index == -1);
current = root;
diff --git a/packages/go_router/lib/src/match.dart b/packages/go_router/lib/src/match.dart
index 7c3c736..24aa058 100644
--- a/packages/go_router/lib/src/match.dart
+++ b/packages/go_router/lib/src/match.dart
@@ -36,7 +36,7 @@
required Map<String, String> pathParameters,
required Object? extra,
}) {
- if (route is ShellRoute) {
+ if (route is ShellRouteBase) {
return RouteMatch(
route: route,
matchedLocation: remainingLocation,
@@ -112,13 +112,15 @@
class ImperativeRouteMatch<T> extends RouteMatch {
/// Constructor for [ImperativeRouteMatch].
ImperativeRouteMatch({
- required super.route,
- required super.matchedLocation,
- required super.extra,
- required super.error,
required super.pageKey,
required this.matches,
- }) : _completer = Completer<T?>();
+ }) : _completer = Completer<T?>(),
+ super(
+ route: matches.last.route,
+ matchedLocation: matches.last.matchedLocation,
+ extra: matches.last.extra,
+ error: matches.last.error,
+ );
/// The matches that produces this route match.
final RouteMatchList matches;
diff --git a/packages/go_router/lib/src/matching.dart b/packages/go_router/lib/src/matching.dart
index 5855204..b809cca 100644
--- a/packages/go_router/lib/src/matching.dart
+++ b/packages/go_router/lib/src/matching.dart
@@ -158,7 +158,7 @@
newMatches.removeRange(index, newMatches.length);
// Also pop ShellRoutes when there are no subsequent route matches
- while (newMatches.isNotEmpty && newMatches.last.route is ShellRoute) {
+ while (newMatches.isNotEmpty && newMatches.last.route is ShellRouteBase) {
newMatches.removeLast();
}
// Removing ImperativeRouteMatch should not change uri and pathParameters.
@@ -193,10 +193,13 @@
RouteMatch get last => matches.last;
/// Returns true if the current match intends to display an error screen.
- bool get isError => matches.length == 1 && matches.first.error != null;
+ bool get isError => error != null;
/// Returns the error that this match intends to display.
- Exception? get error => matches.first.error;
+ Exception? get error => matches.firstOrNull?.error;
+
+ /// The routes for each of the matches.
+ List<RouteBase> get routes => matches.map((RouteMatch e) => e.route).toList();
RouteMatchList _copyWith({
List<RouteMatch>? matches,
@@ -237,6 +240,115 @@
String toString() {
return '${objectRuntimeType(this, 'RouteMatchList')}($fullPath)';
}
+
+ /// Returns a pre-parsed [RouteInformation], containing a reference to this
+ /// match list.
+ RouteInformation toPreParsedRouteInformation() {
+ return RouteInformation(
+ // TODO(tolo): remove this ignore and migrate the code
+ // https://github.com/flutter/flutter/issues/124045.
+ // ignore: deprecated_member_use
+ location: uri.toString(),
+ state: this,
+ );
+ }
+
+ /// Attempts to extract a pre-parsed match list from the provided
+ /// [RouteInformation].
+ static RouteMatchList? fromPreParsedRouteInformation(
+ RouteInformation routeInformation) {
+ if (routeInformation.state is RouteMatchList) {
+ return routeInformation.state! as RouteMatchList;
+ }
+ return null;
+ }
+}
+
+/// Handles encoding and decoding of [RouteMatchList] objects to a format
+/// suitable for using with [StandardMessageCodec].
+///
+/// The primary use of this class is for state restoration.
+class RouteMatchListCodec {
+ /// Creates a new [RouteMatchListCodec] object.
+ RouteMatchListCodec(this._matcher);
+
+ static const String _encodedDataKey = 'go_router/encoded_route_match_list';
+ static const String _locationKey = 'location';
+ static const String _stateKey = 'state';
+ static const String _imperativeMatchesKey = 'imperativeMatches';
+ static const String _pageKey = 'pageKey';
+
+ final RouteMatcher _matcher;
+
+ /// Encodes the provided [RouteMatchList].
+ Object? encodeMatchList(RouteMatchList matchlist) {
+ if (matchlist.isEmpty) {
+ return null;
+ }
+ final List<Map<Object?, Object?>> imperativeMatches = matchlist.matches
+ .whereType<ImperativeRouteMatch<Object?>>()
+ .map((ImperativeRouteMatch<Object?> e) => _toPrimitives(
+ e.matches.uri.toString(), e.extra,
+ pageKey: e.pageKey.value))
+ .toList();
+
+ return <Object?, Object?>{
+ _encodedDataKey: _toPrimitives(
+ matchlist.uri.toString(), matchlist.matches.first.extra,
+ imperativeMatches: imperativeMatches),
+ };
+ }
+
+ static Map<Object?, Object?> _toPrimitives(String location, Object? state,
+ {List<dynamic>? imperativeMatches, String? pageKey}) {
+ return <Object?, Object?>{
+ _locationKey: location,
+ _stateKey: state,
+ if (imperativeMatches != null) _imperativeMatchesKey: imperativeMatches,
+ if (pageKey != null) _pageKey: pageKey,
+ };
+ }
+
+ /// Attempts to decode the provided object into a [RouteMatchList].
+ RouteMatchList? decodeMatchList(Object? object) {
+ if (object is Map && object[_encodedDataKey] is Map) {
+ final Map<Object?, Object?> data =
+ object[_encodedDataKey] as Map<Object?, Object?>;
+ final Object? rootLocation = data[_locationKey];
+ if (rootLocation is! String) {
+ return null;
+ }
+ final RouteMatchList matchList =
+ _matcher.findMatch(rootLocation, extra: data[_stateKey]);
+
+ final List<Object?>? imperativeMatches =
+ data[_imperativeMatchesKey] as List<Object?>?;
+ if (imperativeMatches != null) {
+ for (int i = 0; i < imperativeMatches.length; i++) {
+ final Object? match = imperativeMatches[i];
+ if (match is! Map ||
+ match[_locationKey] is! String ||
+ match[_pageKey] is! String) {
+ continue;
+ }
+ final ValueKey<String> pageKey =
+ ValueKey<String>(match[_pageKey] as String);
+ final RouteMatchList imperativeMatchList = _matcher.findMatch(
+ match[_locationKey] as String,
+ extra: match[_stateKey]);
+ final ImperativeRouteMatch<Object?> imperativeMatch =
+ ImperativeRouteMatch<Object?>(
+ pageKey: pageKey,
+ matches: imperativeMatchList,
+ );
+ matchList.push(imperativeMatch);
+ }
+ }
+
+ return matchList;
+ }
+ return null;
+ }
}
/// An error that occurred during matching.
@@ -307,7 +419,7 @@
// Otherwise, recurse
final String childRestLoc;
final String newParentSubLoc;
- if (match.route is ShellRoute) {
+ if (match.route is ShellRouteBase) {
childRestLoc = remainingLocation;
newParentSubLoc = matchedLocation;
} else {
diff --git a/packages/go_router/lib/src/parser.dart b/packages/go_router/lib/src/parser.dart
index 7fd00cf..78a7ee0 100644
--- a/packages/go_router/lib/src/parser.dart
+++ b/packages/go_router/lib/src/parser.dart
@@ -57,11 +57,17 @@
) {
late final RouteMatchList initialMatches;
try {
- // TODO(chunhtai): remove this ignore and migrate the code
- // https://github.com/flutter/flutter/issues/124045.
- // ignore: deprecated_member_use, unnecessary_non_null_assertion
- initialMatches = matcher.findMatch(routeInformation.location!,
- extra: routeInformation.state);
+ final RouteMatchList? preParsedMatchList =
+ RouteMatchList.fromPreParsedRouteInformation(routeInformation);
+ if (preParsedMatchList != null) {
+ initialMatches = preParsedMatchList;
+ } else {
+ // TODO(chunhtai): remove this ignore and migrate the code
+ // https://github.com/flutter/flutter/issues/124045.
+ // ignore: deprecated_member_use, unnecessary_non_null_assertion
+ initialMatches = matcher.findMatch(routeInformation.location!,
+ extra: routeInformation.state);
+ }
} on MatcherError {
// TODO(chunhtai): remove this ignore and migrate the code
// https://github.com/flutter/flutter/issues/124045.
diff --git a/packages/go_router/lib/src/path_utils.dart b/packages/go_router/lib/src/path_utils.dart
index e9db923..ba403f4 100644
--- a/packages/go_router/lib/src/path_utils.dart
+++ b/packages/go_router/lib/src/path_utils.dart
@@ -2,6 +2,8 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
+import '../go_router.dart';
+
final RegExp _parameterRegExp = RegExp(r':(\w+)(\((?:\\.|[^\\()])+\))?');
/// Converts a [pattern] such as `/user/:id` into [RegExp].
@@ -135,3 +137,24 @@
return canon;
}
+
+/// Builds an absolute path for the provided route.
+String? fullPathForRoute(
+ RouteBase targetRoute, String parentFullpath, List<RouteBase> routes) {
+ for (final RouteBase route in routes) {
+ final String fullPath = (route is GoRoute)
+ ? concatenatePaths(parentFullpath, route.path)
+ : parentFullpath;
+
+ if (route == targetRoute) {
+ return fullPath;
+ } else {
+ final String? subRoutePath =
+ fullPathForRoute(targetRoute, fullPath, route.routes);
+ if (subRoutePath != null) {
+ return subRoutePath;
+ }
+ }
+ }
+ return null;
+}
diff --git a/packages/go_router/lib/src/route.dart b/packages/go_router/lib/src/route.dart
index 21e8053..69b27e3 100644
--- a/packages/go_router/lib/src/route.dart
+++ b/packages/go_router/lib/src/route.dart
@@ -2,11 +2,13 @@
// 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 'package:meta/meta.dart';
-import 'configuration.dart';
-import 'pages/custom_transition_page.dart';
+import '../go_router.dart';
+import 'match.dart';
+import 'matching.dart';
import 'path_utils.dart';
import 'typedefs.dart';
@@ -101,6 +103,13 @@
/// The list of child routes associated with this route.
final List<RouteBase> routes;
+
+ /// Builds a lists containing the provided routes along with all their
+ /// descendant [routes].
+ static Iterable<RouteBase> routesRecursively(Iterable<RouteBase> routes) {
+ return routes.expand(
+ (RouteBase e) => <RouteBase>[e, ...routesRecursively(e.routes)]);
+ }
}
/// A route that is displayed visually above the matching parent route using the
@@ -320,6 +329,72 @@
late final RegExp _pathRE;
}
+/// Base class for classes that act as shells for sub-routes, such
+/// as [ShellRoute] and [StatefulShellRoute].
+abstract class ShellRouteBase extends RouteBase {
+ /// Constructs a [ShellRouteBase].
+ const ShellRouteBase._({super.routes}) : super._();
+
+ /// Attempts to build the Widget representing this shell route.
+ ///
+ /// Returns null if this shell route does not build a Widget, but instead uses
+ /// a Page to represent itself (see [buildPage]).
+ Widget? buildWidget(BuildContext context, GoRouterState state,
+ ShellRouteContext shellRouteContext);
+
+ /// Attempts to build the Page representing this shell route.
+ ///
+ /// Returns null if this shell route does not build a Page, but instead uses
+ /// a Widget to represent itself (see [buildWidget]).
+ Page<dynamic>? buildPage(BuildContext context, GoRouterState state,
+ ShellRouteContext shellRouteContext);
+
+ /// 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.
+class ShellRouteContext {
+ /// Constructs a [ShellRouteContext].
+ ShellRouteContext({
+ required this.route,
+ required this.routerState,
+ required this.navigatorKey,
+ required this.routeMatchList,
+ required this.navigatorBuilder,
+ });
+
+ /// The associated shell route.
+ final ShellRouteBase route;
+
+ /// The current route state associated with [route].
+ final GoRouterState routerState;
+
+ /// The [Navigator] key to be used for the nested navigation associated with
+ /// [route].
+ final GlobalKey<NavigatorState> navigatorKey;
+
+ /// The route match list representing the current location within the
+ /// associated shell route.
+ final RouteMatchList routeMatchList;
+
+ /// Function used to build the [Navigator] for the current route.
+ final NavigatorBuilder navigatorBuilder;
+}
+
/// A route that displays a UI shell around the matching child route.
///
/// When a ShellRoute is added to the list of routes on GoRouter or GoRoute, a
@@ -415,7 +490,7 @@
/// ```
///
/// {@category Configuration}
-class ShellRoute extends RouteBase {
+class ShellRoute extends ShellRouteBase {
/// Constructs a [ShellRoute].
ShellRoute({
this.builder,
@@ -423,6 +498,7 @@
this.observers,
super.routes,
GlobalKey<NavigatorState>? navigatorKey,
+ this.restorationScopeId,
}) : assert(routes.isNotEmpty),
navigatorKey = navigatorKey ?? GlobalKey<NavigatorState>(),
super._() {
@@ -436,18 +512,42 @@
/// The widget builder for a shell route.
///
- /// Similar to GoRoute builder, but with an additional child parameter. This
- /// child parameter is the Widget built by calling the matching sub-route's
- /// builder.
+ /// Similar to [GoRoute.builder], but with an additional child parameter. This
+ /// child parameter is the Widget managing the nested navigation for the
+ /// matching sub-routes. Typically, a shell route builds its shell around this
+ /// Widget.
final ShellRouteBuilder? builder;
/// The page builder for a shell route.
///
- /// Similar to GoRoute pageBuilder, but with an additional child parameter.
- /// This child parameter is the Widget built by calling the matching
- /// sub-route's builder.
+ /// Similar to [GoRoute.pageBuilder], but with an additional child parameter.
+ /// This child parameter is the Widget managing the nested navigation for the
+ /// matching sub-routes. Typically, a shell route builds its shell around this
+ /// Widget.
final ShellRoutePageBuilder? pageBuilder;
+ @override
+ Widget? buildWidget(BuildContext context, GoRouterState state,
+ ShellRouteContext shellRouteContext) {
+ if (builder != null) {
+ final Widget navigator =
+ shellRouteContext.navigatorBuilder(observers, restorationScopeId);
+ return builder!(context, state, navigator);
+ }
+ return null;
+ }
+
+ @override
+ Page<dynamic>? buildPage(BuildContext context, GoRouterState state,
+ ShellRouteContext shellRouteContext) {
+ if (pageBuilder != null) {
+ final Widget navigator =
+ shellRouteContext.navigatorBuilder(observers, restorationScopeId);
+ return pageBuilder!(context, state, navigator);
+ }
+ return null;
+ }
+
/// The observers for a shell route.
///
/// The observers parameter is used by the [Navigator] built for this route.
@@ -458,4 +558,720 @@
/// All ShellRoutes build a Navigator by default. Child GoRoutes
/// are placed onto this Navigator instead of the root Navigator.
final GlobalKey<NavigatorState> navigatorKey;
+
+ /// Restoration ID to save and restore the state of the navigator, including
+ /// its history.
+ final String? restorationScopeId;
+
+ @override
+ GlobalKey<NavigatorState> navigatorKeyForSubRoute(RouteBase subRoute) {
+ assert(routes.contains(subRoute));
+ return navigatorKey;
+ }
+
+ @override
+ Iterable<GlobalKey<NavigatorState>> get _navigatorKeys =>
+ <GlobalKey<NavigatorState>>[navigatorKey];
+}
+
+/// A route that displays a UI shell with separate [Navigator]s for its
+/// sub-routes.
+///
+/// Similar to [ShellRoute], this route class places its sub-route on a
+/// different Navigator than the root [Navigator]. However, this route class
+/// differs in that it creates separate [Navigator]s for each of its nested
+/// branches (i.e. parallel navigation trees), making it possible to build an
+/// app with stateful nested navigation. This is convenient when for instance
+/// implementing a UI with a [BottomNavigationBar], with a persistent navigation
+/// state for each tab.
+///
+/// A StatefulShellRoute is created by specifying a List of
+/// [StatefulShellBranch] items, each representing a separate stateful branch
+/// in the route tree. StatefulShellBranch provides the root routes and the
+/// Navigator key ([GlobalKey]) for the branch, as well as an optional initial
+/// location.
+///
+/// Like [ShellRoute], either a [builder] or a [pageBuilder] must be provided
+/// when creating a StatefulShellRoute. However, these builders differ slightly
+/// in that they accept a [StatefulNavigationShell] parameter instead of a
+/// child Widget. The StatefulNavigationShell can be used to access information
+/// about the state of the route, as well as to switch the active branch (i.e.
+/// restoring the navigation stack of another branch). The latter is
+/// accomplished by using the method [StatefulNavigationShell.goBranch], for
+/// example:
+///
+/// ```
+/// void _onItemTapped(int index) {
+/// navigationShell.goBranch(index: index);
+/// }
+/// ```
+///
+/// The StatefulNavigationShell is also responsible for managing and maintaining
+/// the state of the branch Navigators. Typically, a shell is built around this
+/// Widget, for example by using it as the body of [Scaffold] with a
+/// [BottomNavigationBar].
+///
+/// When creating a StatefulShellRoute, a [navigatorContainerBuilder] function
+/// must be provided. This function is responsible for building the actual
+/// container for the Widgets representing the branch Navigators. Typically,
+/// the Widget returned by this function handles the layout (including
+/// [Offstage] handling etc) of the branch Navigators and any animations needed
+/// when switching active branch.
+///
+/// For a default implementation of [navigatorContainerBuilder] that is
+/// appropriate for most use cases, consider using the constructor
+/// [StatefulShellRoute.indexedStack].
+///
+/// With StatefulShellRoute (and any route below it), animated transitions
+/// between routes in the same navigation stack works the same way as with other
+/// route classes, and can be customized using pageBuilder. However, since
+/// StatefulShellRoute maintains a set of parallel navigation stacks,
+/// any transitions when switching between branches is the responsibility of the
+/// branch Navigator container (i.e. [navigatorContainerBuilder]). The default
+/// [IndexedStack] implementation ([StatefulShellRoute.indexedStack]) does not
+/// use animated transitions, but an example is provided on how to accomplish
+/// this (see link to custom StatefulShellRoute example below).
+///
+/// See also:
+/// * [StatefulShellRoute.indexedStack] which provides a default
+/// StatefulShellRoute implementation suitable for most use cases.
+/// * [Stateful Nested Navigation example](https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/stateful_shell_route.dart)
+/// for a complete runnable example using StatefulShellRoute.
+/// * [Custom StatefulShellRoute example](https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/others/custom_stateful_shell_route.dart)
+/// which demonstrates how to customize the container for the branch Navigators
+/// and how to implement animated transitions when switching branches.
+class StatefulShellRoute extends ShellRouteBase {
+ /// Constructs a [StatefulShellRoute] from a list of [StatefulShellBranch]es,
+ /// each representing a separate nested navigation tree (branch).
+ ///
+ /// A separate [Navigator] will be created for each of the branches, using
+ /// the navigator key specified in [StatefulShellBranch]. The Widget
+ /// implementing the container for the branch Navigators is provided by
+ /// [navigatorContainerBuilder].
+ StatefulShellRoute({
+ required this.branches,
+ this.builder,
+ this.pageBuilder,
+ required this.navigatorContainerBuilder,
+ this.restorationScopeId,
+ }) : assert(branches.isNotEmpty),
+ assert((pageBuilder != null) ^ (builder != null),
+ 'One of builder or pageBuilder must be provided, but not both'),
+ assert(_debugUniqueNavigatorKeys(branches).length == branches.length,
+ 'Navigator keys must be unique'),
+ assert(_debugValidateParentNavigatorKeys(branches)),
+ assert(_debugValidateRestorationScopeIds(restorationScopeId, branches)),
+ super._(routes: _routes(branches));
+
+ /// Constructs a StatefulShellRoute that uses an [IndexedStack] for its
+ /// nested [Navigator]s.
+ ///
+ /// This constructor provides an IndexedStack based implementation for the
+ /// container ([navigatorContainerBuilder]) used to manage the Widgets
+ /// representing the branch Navigators. A part from that, this constructor
+ /// works the same way as the default constructor.
+ ///
+ /// See [Stateful Nested Navigation](https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/stacked_shell_route.dart)
+ /// for a complete runnable example using StatefulShellRoute.indexedStack.
+ StatefulShellRoute.indexedStack({
+ required List<StatefulShellBranch> branches,
+ StatefulShellRouteBuilder? builder,
+ StatefulShellRoutePageBuilder? pageBuilder,
+ String? restorationScopeId,
+ }) : this(
+ branches: branches,
+ builder: builder,
+ pageBuilder: pageBuilder,
+ restorationScopeId: restorationScopeId,
+ navigatorContainerBuilder: _indexedStackContainerBuilder,
+ );
+
+ /// Restoration ID to save and restore the state of the navigator, including
+ /// its history.
+ final String? restorationScopeId;
+
+ /// The widget builder for a stateful shell route.
+ ///
+ /// Similar to [GoRoute.builder], but with an additional
+ /// [StatefulNavigationShell] parameter. StatefulNavigationShell is a Widget
+ /// responsible for managing the nested navigation for the
+ /// matching sub-routes. Typically, a shell route builds its shell around this
+ /// Widget. StatefulNavigationShell can also be used to access information
+ /// about which branch is active, and also to navigate to a different branch
+ /// (using [StatefulNavigationShell.goBranch]).
+ ///
+ /// Custom implementations may choose to ignore the child parameter passed to
+ /// the builder function, and instead use [StatefulNavigationShell] to
+ /// create a custom container for the branch Navigators.
+ final StatefulShellRouteBuilder? builder;
+
+ /// The page builder for a stateful shell route.
+ ///
+ /// Similar to [GoRoute.pageBuilder], but with an additional
+ /// [StatefulNavigationShell] parameter. StatefulNavigationShell is a Widget
+ /// responsible for managing the nested navigation for the
+ /// matching sub-routes. Typically, a shell route builds its shell around this
+ /// Widget. StatefulNavigationShell can also be used to access information
+ /// about which branch is active, and also to navigate to a different branch
+ /// (using [StatefulNavigationShell.goBranch]).
+ ///
+ /// Custom implementations may choose to ignore the child parameter passed to
+ /// the builder function, and instead use [StatefulNavigationShell] to
+ /// create a custom container for the branch Navigators.
+ final StatefulShellRoutePageBuilder? pageBuilder;
+
+ /// The builder for the branch Navigator container.
+ ///
+ /// The function responsible for building the container for the branch
+ /// Navigators. When this function is invoked, access is provided to a List of
+ /// Widgets representing the branch Navigators, where the the index
+ /// corresponds to the index of in [branches].
+ ///
+ /// The builder function is expected to return a Widget that ensures that the
+ /// state of the branch Widgets is maintained, for instance by inducting them
+ /// in the Widget tree.
+ final ShellNavigationContainerBuilder navigatorContainerBuilder;
+
+ /// Representations of the different stateful route branches that this
+ /// shell route will manage.
+ ///
+ /// Each branch uses a separate [Navigator], identified
+ /// [StatefulShellBranch.navigatorKey].
+ final List<StatefulShellBranch> branches;
+
+ final GlobalKey<StatefulNavigationShellState> _shellStateKey =
+ GlobalKey<StatefulNavigationShellState>();
+
+ @override
+ Widget? buildWidget(BuildContext context, GoRouterState state,
+ ShellRouteContext shellRouteContext) {
+ if (builder != null) {
+ return builder!(context, state, _createShell(context, shellRouteContext));
+ }
+ return null;
+ }
+
+ @override
+ Page<dynamic>? buildPage(BuildContext context, GoRouterState state,
+ ShellRouteContext shellRouteContext) {
+ if (pageBuilder != null) {
+ return pageBuilder!(
+ context, state, _createShell(context, shellRouteContext));
+ }
+ return null;
+ }
+
+ @override
+ GlobalKey<NavigatorState> navigatorKeyForSubRoute(RouteBase subRoute) {
+ final StatefulShellBranch? branch = branches.firstWhereOrNull(
+ (StatefulShellBranch e) => e.routes.contains(subRoute));
+ assert(branch != null);
+ return branch!.navigatorKey;
+ }
+
+ @override
+ Iterable<GlobalKey<NavigatorState>> get _navigatorKeys =>
+ branches.map((StatefulShellBranch b) => b.navigatorKey);
+
+ StatefulNavigationShell _createShell(
+ BuildContext context, ShellRouteContext shellRouteContext) =>
+ StatefulNavigationShell(
+ shellRouteContext: shellRouteContext,
+ router: GoRouter.of(context),
+ containerBuilder: navigatorContainerBuilder);
+
+ static Widget _indexedStackContainerBuilder(BuildContext context,
+ StatefulNavigationShell navigationShell, List<Widget> children) {
+ return _IndexedStackedRouteBranchContainer(
+ currentIndex: navigationShell.currentIndex, children: children);
+ }
+
+ static List<RouteBase> _routes(List<StatefulShellBranch> branches) =>
+ branches.expand((StatefulShellBranch e) => e.routes).toList();
+
+ static Set<GlobalKey<NavigatorState>> _debugUniqueNavigatorKeys(
+ List<StatefulShellBranch> branches) =>
+ Set<GlobalKey<NavigatorState>>.from(
+ branches.map((StatefulShellBranch e) => e.navigatorKey));
+
+ static bool _debugValidateParentNavigatorKeys(
+ List<StatefulShellBranch> branches) {
+ for (final StatefulShellBranch branch in branches) {
+ for (final RouteBase route in branch.routes) {
+ if (route is GoRoute) {
+ assert(route.parentNavigatorKey == null ||
+ route.parentNavigatorKey == branch.navigatorKey);
+ }
+ }
+ }
+ return true;
+ }
+
+ static bool _debugValidateRestorationScopeIds(
+ String? restorationScopeId, List<StatefulShellBranch> branches) {
+ if (branches
+ .map((StatefulShellBranch e) => e.restorationScopeId)
+ .whereNotNull()
+ .isNotEmpty) {
+ assert(
+ restorationScopeId != null,
+ 'A restorationScopeId must be set for '
+ 'the StatefulShellRoute when using restorationScopeIds on one or more '
+ 'of the branches');
+ }
+ return true;
+ }
+}
+
+/// Representation of a separate branch in a stateful navigation tree, used to
+/// configure [StatefulShellRoute].
+///
+/// The only required argument when creating a StatefulShellBranch is the
+/// sub-routes ([routes]), however sometimes it may be convenient to also
+/// provide a [initialLocation]. The value of this parameter is used when
+/// loading the branch for the first time (for instance when switching branch
+/// using the goBranch method in [StatefulNavigationShell]).
+///
+/// A separate [Navigator] will be built for each StatefulShellBranch in a
+/// [StatefulShellRoute], and the routes of this branch will be placed onto that
+/// Navigator instead of the root Navigator. A custom [navigatorKey] can be
+/// provided when creating a StatefulShellBranch, which can be useful when the
+/// Navigator needs to be accessed elsewhere. If no key is provided, a default
+/// one will be created.
+@immutable
+class StatefulShellBranch {
+ /// Constructs a [StatefulShellBranch].
+ StatefulShellBranch({
+ required this.routes,
+ GlobalKey<NavigatorState>? navigatorKey,
+ this.initialLocation,
+ this.restorationScopeId,
+ this.observers,
+ }) : navigatorKey = navigatorKey ?? GlobalKey<NavigatorState>();
+
+ /// The [GlobalKey] to be used by the [Navigator] built for this branch.
+ ///
+ /// A separate Navigator will be built for each StatefulShellBranch in a
+ /// [StatefulShellRoute] and this key will be used to identify the Navigator.
+ /// The routes associated with this branch will be placed o onto that
+ /// Navigator instead of the root Navigator.
+ final GlobalKey<NavigatorState> navigatorKey;
+
+ /// The list of child routes associated with this route branch.
+ final List<RouteBase> routes;
+
+ /// The initial location for this route branch.
+ ///
+ /// If none is specified, the location of the first descendant [GoRoute] will
+ /// be used (i.e. [defaultRoute]). The initial location is used when loading
+ /// the branch for the first time (for instance when switching branch using
+ /// the goBranch method).
+ final String? initialLocation;
+
+ /// Restoration ID to save and restore the state of the navigator, including
+ /// its history.
+ final String? restorationScopeId;
+
+ /// The observers for this branch.
+ ///
+ /// The observers parameter is used by the [Navigator] built for this branch.
+ final List<NavigatorObserver>? observers;
+
+ /// The default route of this branch, i.e. the first descendant [GoRoute].
+ ///
+ /// This route will be used when loading the branch for the first time, if
+ /// an [initialLocation] has not been provided.
+ GoRoute? get defaultRoute =>
+ RouteBase.routesRecursively(routes).whereType<GoRoute>().firstOrNull;
+}
+
+/// Builder for a custom container for the branch Navigators of a
+/// [StatefulShellRoute].
+typedef ShellNavigationContainerBuilder = Widget Function(BuildContext context,
+ StatefulNavigationShell navigationShell, List<Widget> children);
+
+/// Widget for managing the state of a [StatefulShellRoute].
+///
+/// Normally, this widget is not used directly, but is instead created
+/// internally by StatefulShellRoute. However, if a custom container for the
+/// branch Navigators is required, StatefulNavigationShell can be used in
+/// the builder or pageBuilder methods of StatefulShellRoute to facilitate this.
+/// The container is created using the provided [ShellNavigationContainerBuilder],
+/// where the List of Widgets represent the Navigators for each branch.
+///
+/// Example:
+/// ```
+/// builder: (BuildContext context, GoRouterState state,
+/// StatefulNavigationShell navigationShell) {
+/// return StatefulNavigationShell(
+/// shellRouteState: state,
+/// containerBuilder: (_, __, List<Widget> children) => MyCustomShell(shellState: state, children: children),
+/// );
+/// }
+/// ```
+class StatefulNavigationShell extends StatefulWidget {
+ /// Constructs an [StatefulNavigationShell].
+ StatefulNavigationShell({
+ required this.shellRouteContext,
+ required GoRouter router,
+ required this.containerBuilder,
+ }) : assert(shellRouteContext.route is StatefulShellRoute),
+ _router = router,
+ currentIndex = _indexOfBranchNavigatorKey(
+ shellRouteContext.route as StatefulShellRoute,
+ shellRouteContext.navigatorKey),
+ super(
+ key:
+ (shellRouteContext.route as StatefulShellRoute)._shellStateKey);
+
+ /// The ShellRouteContext responsible for building the Navigator for the
+ /// current [StatefulShellBranch].
+ final ShellRouteContext shellRouteContext;
+
+ /// The builder for a custom container for shell route Navigators.
+ final ShellNavigationContainerBuilder containerBuilder;
+
+ /// The index of the currently active [StatefulShellBranch].
+ ///
+ /// Corresponds to the index in the branches field of [StatefulShellRoute].
+ final int currentIndex;
+
+ final GoRouter _router;
+
+ /// The associated [StatefulShellRoute].
+ StatefulShellRoute get route => shellRouteContext.route as StatefulShellRoute;
+
+ /// Navigate to the last location of the [StatefulShellBranch] at the provided
+ /// index in the associated [StatefulShellBranch].
+ ///
+ /// This method will switch the currently active branch [Navigator] for the
+ /// [StatefulShellRoute]. If the branch has not been visited before, or if
+ /// initialLocation is true, this method will navigate to initial location of
+ /// the branch (see [StatefulShellBranch.initialLocation]).
+ void goBranch(int index, {bool initialLocation = false}) {
+ final StatefulShellRoute route =
+ shellRouteContext.route as StatefulShellRoute;
+ final StatefulNavigationShellState? shellState =
+ route._shellStateKey.currentState;
+ if (shellState != null) {
+ shellState.goBranch(index, initialLocation: initialLocation);
+ } else {
+ _router.go(_effectiveInitialBranchLocation(index));
+ }
+ }
+
+ /// Gets the effective initial location for the branch at the provided index
+ /// in the associated [StatefulShellRoute].
+ ///
+ /// The effective initial location is either the
+ /// [StackedShellBranch.initialLocation], if specified, or the location of the
+ /// [StackedShellBranch.defaultRoute].
+ String _effectiveInitialBranchLocation(int index) {
+ final StatefulShellRoute route =
+ shellRouteContext.route as StatefulShellRoute;
+ final StatefulShellBranch branch = route.branches[index];
+ final String? initialLocation = branch.initialLocation;
+ if (initialLocation != null) {
+ return initialLocation;
+ } else {
+ /// Recursively traverses the routes of the provided StackedShellBranch to
+ /// find the first GoRoute, from which a full path will be derived.
+ final GoRoute route = branch.defaultRoute!;
+ return _router.locationForRoute(route)!;
+ }
+ }
+
+ @override
+ State<StatefulWidget> createState() => StatefulNavigationShellState();
+
+ /// Gets the state for the nearest stateful shell route in the Widget tree.
+ static StatefulNavigationShellState of(BuildContext context) {
+ final StatefulNavigationShellState? shellState =
+ context.findAncestorStateOfType<StatefulNavigationShellState>();
+ assert(shellState != null);
+ return shellState!;
+ }
+
+ /// Gets the state for the nearest stateful shell route in the Widget tree.
+ ///
+ /// Returns null if no stateful shell route is found.
+ static StatefulNavigationShellState? maybeOf(BuildContext context) {
+ final StatefulNavigationShellState? shellState =
+ context.findAncestorStateOfType<StatefulNavigationShellState>();
+ return shellState;
+ }
+
+ static int _indexOfBranchNavigatorKey(
+ StatefulShellRoute route, GlobalKey<NavigatorState> navigatorKey) {
+ final int index = route.branches.indexWhere(
+ (StatefulShellBranch branch) => branch.navigatorKey == navigatorKey);
+ assert(index >= 0);
+ return index;
+ }
+}
+
+/// State for StatefulNavigationShell.
+class StatefulNavigationShellState extends State<StatefulNavigationShell>
+ with RestorationMixin {
+ final Map<Key, Widget> _branchNavigators = <Key, Widget>{};
+
+ /// The associated [StatefulShellRoute].
+ StatefulShellRoute get route => widget.route;
+
+ GoRouter get _router => widget._router;
+ RouteMatcher get _matcher => _router.routeInformationParser.matcher;
+
+ final Map<StatefulShellBranch, _RestorableRouteMatchList> _branchLocations =
+ <StatefulShellBranch, _RestorableRouteMatchList>{};
+
+ @override
+ String? get restorationId => route.restorationScopeId;
+
+ /// Generates a derived restoration ID for the branch location property,
+ /// falling back to the identity hash code of the branch to ensure an ID is
+ /// always returned (needed for _RestorableRouteMatchList/RestorableValue).
+ String _branchLocationRestorationScopeId(StatefulShellBranch branch) {
+ return branch.restorationScopeId != null
+ ? '${branch.restorationScopeId}-location'
+ : identityHashCode(branch).toString();
+ }
+
+ _RestorableRouteMatchList _branchLocation(StatefulShellBranch branch,
+ [bool register = true]) {
+ return _branchLocations.putIfAbsent(branch, () {
+ final _RestorableRouteMatchList branchLocation =
+ _RestorableRouteMatchList(_matcher);
+ if (register) {
+ registerForRestoration(
+ branchLocation, _branchLocationRestorationScopeId(branch));
+ }
+ return branchLocation;
+ });
+ }
+
+ RouteMatchList? _matchListForBranch(int index) =>
+ _branchLocations[route.branches[index]]?.value;
+
+ /// Creates a new RouteMatchList that is scoped to the Navigators of the
+ /// current shell route or it's descendants. This involves removing all the
+ /// 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 false;
+ });
+ if (index > 0) {
+ final List<RouteMatch> matches = matchList.matches.sublist(0, index);
+ return RouteMatchList(
+ matches: matches,
+ uri: Uri.parse(matches.last.matchedLocation),
+ pathParameters: matchList.pathParameters,
+ );
+ }
+ return matchList;
+ }
+
+ void _updateCurrentBranchStateFromWidget() {
+ final StatefulShellBranch branch = route.branches[widget.currentIndex];
+ final ShellRouteContext shellRouteContext = widget.shellRouteContext;
+ final RouteMatchList currentBranchLocation =
+ _scopedMatchList(shellRouteContext.routeMatchList);
+
+ final _RestorableRouteMatchList branchLocation =
+ _branchLocation(branch, false);
+ final RouteMatchList previousBranchLocation = branchLocation.value;
+ branchLocation.value = currentBranchLocation;
+ final bool hasExistingNavigator =
+ _branchNavigators[branch.navigatorKey] != null;
+
+ /// Only update the Navigator of the route match list has changed
+ final bool locationChanged =
+ previousBranchLocation != currentBranchLocation;
+ if (locationChanged || !hasExistingNavigator) {
+ _branchNavigators[branch.navigatorKey] = shellRouteContext
+ .navigatorBuilder(branch.observers, branch.restorationScopeId);
+ }
+ }
+
+ /// The index of the currently active [StatefulShellBranch].
+ ///
+ /// Corresponds to the index in the branches field of [StatefulShellRoute].
+ int get currentIndex => widget.currentIndex;
+
+ /// Navigate to the last location of the [StatefulShellBranch] at the provided
+ /// index in the associated [StatefulShellBranch].
+ ///
+ /// This method will switch the currently active branch [Navigator] for the
+ /// [StatefulShellRoute]. If the branch has not been visited before, or if
+ /// initialLocation is true, this method will navigate to initial location of
+ /// the branch (see [StatefulShellBranch.initialLocation]).
+ void goBranch(int index, {bool initialLocation = false}) {
+ assert(index >= 0 && index < route.branches.length);
+ final RouteMatchList? matchlist =
+ initialLocation ? null : _matchListForBranch(index);
+ if (matchlist != null && matchlist.isNotEmpty) {
+ final RouteInformation preParsed =
+ matchlist.toPreParsedRouteInformation();
+ // TODO(tolo): remove this ignore and migrate the code
+ // https://github.com/flutter/flutter/issues/124045.
+ // ignore: deprecated_member_use, unnecessary_non_null_assertion
+ _router.go(preParsed.location!, extra: preParsed.state);
+ } else {
+ _router.go(widget._effectiveInitialBranchLocation(index));
+ }
+ }
+
+ @override
+ void initState() {
+ super.initState();
+ _updateCurrentBranchStateFromWidget();
+ }
+
+ @override
+ void dispose() {
+ super.dispose();
+ for (final StatefulShellBranch branch in route.branches) {
+ _branchLocations[branch]?.dispose();
+ }
+ }
+
+ @override
+ void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
+ route.branches.forEach(_branchLocation);
+ }
+
+ @override
+ void didUpdateWidget(covariant StatefulNavigationShell oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ _updateCurrentBranchStateFromWidget();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final List<Widget> children = route.branches
+ .map((StatefulShellBranch branch) => _BranchNavigatorProxy(
+ key: ObjectKey(branch),
+ branch: branch,
+ navigatorForBranch: (StatefulShellBranch b) =>
+ _branchNavigators[b.navigatorKey]))
+ .toList();
+
+ return widget.containerBuilder(context, widget, children);
+ }
+}
+
+/// [RestorableProperty] for enabling state restoration of [RouteMatchList]s.
+class _RestorableRouteMatchList extends RestorableProperty<RouteMatchList> {
+ _RestorableRouteMatchList(RouteMatcher matcher)
+ : _matchListCodec = RouteMatchListCodec(matcher);
+
+ final RouteMatchListCodec _matchListCodec;
+
+ RouteMatchList get value => _value;
+ RouteMatchList _value = RouteMatchList.empty;
+ set value(RouteMatchList newValue) {
+ if (newValue != _value) {
+ _value = newValue;
+ notifyListeners();
+ }
+ }
+
+ @override
+ void initWithValue(RouteMatchList value) {
+ _value = value;
+ }
+
+ @override
+ RouteMatchList createDefaultValue() => RouteMatchList.empty;
+
+ @override
+ RouteMatchList fromPrimitives(Object? data) {
+ return _matchListCodec.decodeMatchList(data) ?? RouteMatchList.empty;
+ }
+
+ @override
+ Object? toPrimitives() {
+ if (value != null && value.isNotEmpty) {
+ return _matchListCodec.encodeMatchList(value);
+ }
+ return null;
+ }
+}
+
+typedef _NavigatorForBranch = Widget? Function(StatefulShellBranch);
+
+/// Widget that serves as the proxy for a branch Navigator Widget, which
+/// possibly hasn't been created yet.
+///
+/// This Widget hides the logic handling whether a Navigator Widget has been
+/// created yet for a branch or not, and at the same time ensures that the same
+/// Widget class is consistently passed to the containerBuilder. The latter is
+/// important for container implementations that cache child widgets,
+/// such as [TabBarView].
+class _BranchNavigatorProxy extends StatefulWidget {
+ const _BranchNavigatorProxy({
+ super.key,
+ required this.branch,
+ required this.navigatorForBranch,
+ });
+
+ final StatefulShellBranch branch;
+ final _NavigatorForBranch navigatorForBranch;
+
+ @override
+ State<StatefulWidget> createState() => _BranchNavigatorProxyState();
+}
+
+/// State for _BranchNavigatorProxy, using AutomaticKeepAliveClientMixin to
+/// properly handle some scenarios where Slivers are used to manage the branches
+/// (such as [TabBarView]).
+class _BranchNavigatorProxyState extends State<_BranchNavigatorProxy>
+ with AutomaticKeepAliveClientMixin {
+ @override
+ Widget build(BuildContext context) {
+ super.build(context);
+ return widget.navigatorForBranch(widget.branch) ?? const SizedBox.shrink();
+ }
+
+ @override
+ bool get wantKeepAlive => true;
+}
+
+/// Default implementation of a container widget for the [Navigator]s of the
+/// route branches. This implementation uses an [IndexedStack] as a container.
+class _IndexedStackedRouteBranchContainer extends StatelessWidget {
+ const _IndexedStackedRouteBranchContainer(
+ {required this.currentIndex, required this.children});
+
+ final int currentIndex;
+
+ final List<Widget> children;
+
+ @override
+ Widget build(BuildContext context) {
+ final List<Widget> stackItems = children
+ .mapIndexed((int index, Widget child) =>
+ _buildRouteBranchContainer(context, currentIndex == index, child))
+ .toList();
+
+ return IndexedStack(index: currentIndex, children: stackItems);
+ }
+
+ Widget _buildRouteBranchContainer(
+ BuildContext context, bool isActive, Widget child) {
+ return Offstage(
+ offstage: !isActive,
+ child: TickerMode(
+ enabled: isActive,
+ child: child,
+ ),
+ );
+ }
}
diff --git a/packages/go_router/lib/src/router.dart b/packages/go_router/lib/src/router.dart
index e9b4ec6..0e2d59b 100644
--- a/packages/go_router/lib/src/router.dart
+++ b/packages/go_router/lib/src/router.dart
@@ -143,11 +143,6 @@
GoRouteInformationParser get routeInformationParser =>
_routeInformationParser;
- /// The route configuration. Used for testing.
- // TODO(johnpryan): Remove this, integration tests shouldn't need access
- @visibleForTesting
- RouteConfiguration get routeConfiguration => _routeConfiguration;
-
/// Gets the current location.
// TODO(chunhtai): deprecates this once go_router_builder is migrated to
// GoRouterState.of.
@@ -189,6 +184,13 @@
queryParameters: queryParameters,
);
+ /// Get the location for the provided route.
+ ///
+ /// Builds the absolute path for the route, by concatenating the paths of the
+ /// route and all its ancestors.
+ String? locationForRoute(RouteBase route) =>
+ _routeInformationParser.configuration.locationForRoute(route);
+
/// Navigate to a URI location w/ optional query parameters, e.g.
/// `/family/f2/person/p1?color=blue`
void go(String location, {Object? extra}) {
diff --git a/packages/go_router/lib/src/typedefs.dart b/packages/go_router/lib/src/typedefs.dart
index 2926994..7a40715 100644
--- a/packages/go_router/lib/src/typedefs.dart
+++ b/packages/go_router/lib/src/typedefs.dart
@@ -34,11 +34,18 @@
Widget child,
);
-/// The signature of the navigatorBuilder callback.
-typedef GoRouterNavigatorBuilder = Widget Function(
+/// The widget builder for [StatefulShellRoute].
+typedef StatefulShellRouteBuilder = Widget Function(
BuildContext context,
GoRouterState state,
- Widget child,
+ StatefulNavigationShell navigationShell,
+);
+
+/// The page builder for [StatefulShellRoute].
+typedef StatefulShellRoutePageBuilder = Page<dynamic> Function(
+ BuildContext context,
+ GoRouterState state,
+ StatefulNavigationShell navigationShell,
);
/// Signature of a go router builder function with navigator.
@@ -50,3 +57,7 @@
/// The signature of the redirect callback.
typedef GoRouterRedirect = FutureOr<String?> Function(
BuildContext context, GoRouterState state);
+
+/// Signature for functions used to build Navigators
+typedef NavigatorBuilder = Widget Function(
+ List<NavigatorObserver>? observers, String? restorationScopeId);
diff --git a/packages/go_router/pubspec.yaml b/packages/go_router/pubspec.yaml
index 53c28f8..00f849d 100644
--- a/packages/go_router/pubspec.yaml
+++ b/packages/go_router/pubspec.yaml
@@ -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: 7.0.2
+version: 7.1.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
diff --git a/packages/go_router/test/builder_test.dart b/packages/go_router/test/builder_test.dart
index 81f4182..ac97cab 100644
--- a/packages/go_router/test/builder_test.dart
+++ b/packages/go_router/test/builder_test.dart
@@ -8,6 +8,9 @@
import 'package:go_router/src/configuration.dart';
import 'package:go_router/src/match.dart';
import 'package:go_router/src/matching.dart';
+import 'package:go_router/src/router.dart';
+
+import 'test_helpers.dart';
void main() {
group('RouteBuilder', () {
@@ -76,17 +79,13 @@
);
final RouteMatchList matches = RouteMatchList(
- matches: <RouteMatch>[
- RouteMatch(
- route: config.routes.first,
- matchedLocation: '/',
- extra: null,
- error: null,
- pageKey: const ValueKey<String>('/'),
- ),
- ],
- uri: Uri.parse('/'),
- pathParameters: const <String, String>{});
+ matches: <RouteMatch>[
+ createRouteMatch(config.routes.first, '/'),
+ createRouteMatch(config.routes.first.routes.first, '/'),
+ ],
+ uri: Uri.parse('/'),
+ pathParameters: const <String, String>{},
+ );
await tester.pumpWidget(
_BuilderTestWidget(
@@ -272,6 +271,104 @@
expect(find.byType(_HomeScreen), findsNothing);
expect(find.byType(_DetailsScreen), findsOneWidget);
});
+
+ testWidgets('Uses the correct restorationScopeId for ShellRoute',
+ (WidgetTester tester) async {
+ final GlobalKey<NavigatorState> rootNavigatorKey =
+ GlobalKey<NavigatorState>(debugLabel: 'root');
+ final GlobalKey<NavigatorState> shellNavigatorKey =
+ GlobalKey<NavigatorState>(debugLabel: 'shell');
+ final RouteConfiguration config = RouteConfiguration(
+ navigatorKey: rootNavigatorKey,
+ routes: <RouteBase>[
+ ShellRoute(
+ builder: (BuildContext context, GoRouterState state, Widget child) {
+ return _HomeScreen(child: child);
+ },
+ navigatorKey: shellNavigatorKey,
+ restorationScopeId: 'scope1',
+ routes: <RouteBase>[
+ GoRoute(
+ path: '/a',
+ builder: (BuildContext context, GoRouterState state) {
+ return _DetailsScreen();
+ },
+ ),
+ ],
+ ),
+ ],
+ redirectLimit: 10,
+ topRedirect: (BuildContext context, GoRouterState state) {
+ return null;
+ },
+ );
+
+ final RouteMatchList matches = RouteMatchList(
+ matches: <RouteMatch>[
+ createRouteMatch(config.routes.first, ''),
+ createRouteMatch(config.routes.first.routes.first, '/a'),
+ ],
+ uri: Uri.parse('/b'),
+ pathParameters: const <String, String>{},
+ );
+
+ await tester.pumpWidget(
+ _BuilderTestWidget(
+ routeConfiguration: config,
+ matches: matches,
+ ),
+ );
+
+ expect(find.byKey(rootNavigatorKey), findsOneWidget);
+ expect(find.byKey(shellNavigatorKey), findsOneWidget);
+ expect(
+ (shellNavigatorKey.currentWidget as Navigator?)?.restorationScopeId,
+ 'scope1');
+ });
+
+ testWidgets('Uses the correct restorationScopeId for StatefulShellRoute',
+ (WidgetTester tester) async {
+ final GlobalKey<NavigatorState> rootNavigatorKey =
+ GlobalKey<NavigatorState>(debugLabel: 'root');
+ final GlobalKey<NavigatorState> shellNavigatorKey =
+ GlobalKey<NavigatorState>(debugLabel: 'shell');
+ final GoRouter goRouter = GoRouter(
+ initialLocation: '/a',
+ navigatorKey: rootNavigatorKey,
+ routes: <RouteBase>[
+ StatefulShellRoute.indexedStack(
+ restorationScopeId: 'shell',
+ builder: (BuildContext context, GoRouterState state,
+ StatefulNavigationShell navigationShell) =>
+ _HomeScreen(child: navigationShell),
+ branches: <StatefulShellBranch>[
+ StatefulShellBranch(
+ navigatorKey: shellNavigatorKey,
+ restorationScopeId: 'scope1',
+ routes: <RouteBase>[
+ GoRoute(
+ path: '/a',
+ builder: (BuildContext context, GoRouterState state) {
+ return _DetailsScreen();
+ },
+ ),
+ ],
+ ),
+ ],
+ ),
+ ],
+ );
+
+ await tester.pumpWidget(MaterialApp.router(
+ routerConfig: goRouter,
+ ));
+
+ expect(find.byKey(rootNavigatorKey), findsOneWidget);
+ expect(find.byKey(shellNavigatorKey), findsOneWidget);
+ expect(
+ (shellNavigatorKey.currentWidget as Navigator?)?.restorationScopeId,
+ 'scope1');
+ });
});
}
@@ -340,15 +437,15 @@
},
restorationScopeId: null,
observers: <NavigatorObserver>[],
+ onPopPageWithRouteMatch: (_, __, ___) => false,
);
}
@override
Widget build(BuildContext context) {
return MaterialApp(
- home: builder.tryBuild(context, matches, (_, __) => false, false,
+ home: builder.tryBuild(context, matches, false,
routeConfiguration.navigatorKey, <Page<Object?>, GoRouterState>{}),
- // builder: (context, child) => ,
);
}
}
diff --git a/packages/go_router/test/configuration_test.dart b/packages/go_router/test/configuration_test.dart
index 08cf53c..3124702 100644
--- a/packages/go_router/test/configuration_test.dart
+++ b/packages/go_router/test/configuration_test.dart
@@ -6,6 +6,8 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/src/configuration.dart';
+import 'test_helpers.dart';
+
void main() {
group('RouteConfiguration', () {
test('throws when parentNavigatorKey is not an ancestor', () {
@@ -81,6 +83,483 @@
});
test(
+ 'throws when StatefulShellRoute sub-route uses incorrect parentNavigatorKey',
+ () {
+ final GlobalKey<NavigatorState> root =
+ GlobalKey<NavigatorState>(debugLabel: 'root');
+ final GlobalKey<NavigatorState> keyA =
+ GlobalKey<NavigatorState>(debugLabel: 'A');
+ final GlobalKey<NavigatorState> keyB =
+ GlobalKey<NavigatorState>(debugLabel: 'B');
+
+ expect(
+ () {
+ RouteConfiguration(
+ navigatorKey: root,
+ routes: <RouteBase>[
+ StatefulShellRoute.indexedStack(branches: <StatefulShellBranch>[
+ StatefulShellBranch(
+ navigatorKey: keyA,
+ routes: <RouteBase>[
+ GoRoute(
+ path: '/a',
+ builder: _mockScreenBuilder,
+ routes: <RouteBase>[
+ GoRoute(
+ path: 'details',
+ builder: _mockScreenBuilder,
+ parentNavigatorKey: keyB),
+ ]),
+ ],
+ ),
+ ], builder: mockStackedShellBuilder),
+ ],
+ redirectLimit: 10,
+ topRedirect: (BuildContext context, GoRouterState state) {
+ return null;
+ },
+ );
+ },
+ throwsAssertionError,
+ );
+ });
+
+ test(
+ 'does not throw when StatefulShellRoute sub-route uses correct parentNavigatorKeys',
+ () {
+ final GlobalKey<NavigatorState> root =
+ GlobalKey<NavigatorState>(debugLabel: 'root');
+ final GlobalKey<NavigatorState> keyA =
+ GlobalKey<NavigatorState>(debugLabel: 'A');
+
+ RouteConfiguration(
+ navigatorKey: root,
+ routes: <RouteBase>[
+ StatefulShellRoute.indexedStack(branches: <StatefulShellBranch>[
+ StatefulShellBranch(
+ navigatorKey: keyA,
+ routes: <RouteBase>[
+ GoRoute(
+ path: '/a',
+ builder: _mockScreenBuilder,
+ routes: <RouteBase>[
+ GoRoute(
+ path: 'details',
+ builder: _mockScreenBuilder,
+ parentNavigatorKey: keyA),
+ ]),
+ ],
+ ),
+ ], builder: mockStackedShellBuilder),
+ ],
+ redirectLimit: 10,
+ topRedirect: (BuildContext context, GoRouterState state) {
+ return null;
+ },
+ );
+ });
+
+ test(
+ 'throws when a sub-route of StatefulShellRoute has a parentNavigatorKey',
+ () {
+ final GlobalKey<NavigatorState> root =
+ GlobalKey<NavigatorState>(debugLabel: 'root');
+ final GlobalKey<NavigatorState> someNavigatorKey =
+ GlobalKey<NavigatorState>();
+ expect(
+ () {
+ RouteConfiguration(
+ navigatorKey: root,
+ routes: <RouteBase>[
+ StatefulShellRoute.indexedStack(branches: <StatefulShellBranch>[
+ StatefulShellBranch(
+ routes: <RouteBase>[
+ GoRoute(
+ path: '/a',
+ builder: _mockScreenBuilder,
+ routes: <RouteBase>[
+ GoRoute(
+ path: 'details',
+ builder: _mockScreenBuilder,
+ parentNavigatorKey: someNavigatorKey),
+ ]),
+ ],
+ ),
+ StatefulShellBranch(
+ routes: <RouteBase>[
+ GoRoute(
+ path: '/b',
+ builder: _mockScreenBuilder,
+ parentNavigatorKey: someNavigatorKey),
+ ],
+ ),
+ ], builder: mockStackedShellBuilder),
+ ],
+ redirectLimit: 10,
+ topRedirect: (BuildContext context, GoRouterState state) {
+ return null;
+ },
+ );
+ },
+ throwsAssertionError,
+ );
+ });
+
+ test('throws when StatefulShellRoute has duplicate navigator keys', () {
+ final GlobalKey<NavigatorState> root =
+ GlobalKey<NavigatorState>(debugLabel: 'root');
+ final GlobalKey<NavigatorState> keyA =
+ GlobalKey<NavigatorState>(debugLabel: 'A');
+ final List<GoRoute> shellRouteChildren = <GoRoute>[
+ GoRoute(
+ path: '/a', builder: _mockScreenBuilder, parentNavigatorKey: keyA),
+ GoRoute(
+ path: '/b', builder: _mockScreenBuilder, parentNavigatorKey: keyA),
+ ];
+ expect(
+ () {
+ RouteConfiguration(
+ navigatorKey: root,
+ routes: <RouteBase>[
+ StatefulShellRoute.indexedStack(branches: <StatefulShellBranch>[
+ StatefulShellBranch(routes: shellRouteChildren)
+ ], builder: mockStackedShellBuilder),
+ ],
+ redirectLimit: 10,
+ topRedirect: (BuildContext context, GoRouterState state) {
+ return null;
+ },
+ );
+ },
+ throwsAssertionError,
+ );
+ });
+
+ test(
+ 'throws when a child of StatefulShellRoute has an incorrect '
+ 'parentNavigatorKey', () {
+ final GlobalKey<NavigatorState> root =
+ GlobalKey<NavigatorState>(debugLabel: 'root');
+ final GlobalKey<NavigatorState> sectionANavigatorKey =
+ GlobalKey<NavigatorState>();
+ final GlobalKey<NavigatorState> sectionBNavigatorKey =
+ GlobalKey<NavigatorState>();
+ final GoRoute routeA = GoRoute(
+ path: '/a',
+ builder: _mockScreenBuilder,
+ parentNavigatorKey: sectionBNavigatorKey);
+ final GoRoute routeB = GoRoute(
+ path: '/b',
+ builder: _mockScreenBuilder,
+ parentNavigatorKey: sectionANavigatorKey);
+ expect(
+ () {
+ RouteConfiguration(
+ navigatorKey: root,
+ routes: <RouteBase>[
+ StatefulShellRoute.indexedStack(branches: <StatefulShellBranch>[
+ StatefulShellBranch(
+ routes: <RouteBase>[routeA],
+ navigatorKey: sectionANavigatorKey),
+ StatefulShellBranch(
+ routes: <RouteBase>[routeB],
+ navigatorKey: sectionBNavigatorKey),
+ ], builder: mockStackedShellBuilder),
+ ],
+ redirectLimit: 10,
+ topRedirect: (BuildContext context, GoRouterState state) {
+ return null;
+ },
+ );
+ },
+ throwsAssertionError,
+ );
+ });
+
+ test(
+ 'throws when a branch of a StatefulShellRoute has an incorrect '
+ 'initialLocation', () {
+ final GlobalKey<NavigatorState> root =
+ GlobalKey<NavigatorState>(debugLabel: 'root');
+ final GlobalKey<NavigatorState> sectionANavigatorKey =
+ GlobalKey<NavigatorState>();
+ final GlobalKey<NavigatorState> sectionBNavigatorKey =
+ GlobalKey<NavigatorState>();
+ expect(
+ () {
+ RouteConfiguration(
+ navigatorKey: root,
+ routes: <RouteBase>[
+ StatefulShellRoute.indexedStack(branches: <StatefulShellBranch>[
+ StatefulShellBranch(
+ initialLocation: '/x',
+ navigatorKey: sectionANavigatorKey,
+ routes: <RouteBase>[
+ GoRoute(
+ path: '/a',
+ builder: _mockScreenBuilder,
+ ),
+ ],
+ ),
+ StatefulShellBranch(
+ navigatorKey: sectionBNavigatorKey,
+ routes: <RouteBase>[
+ GoRoute(
+ path: '/b',
+ builder: _mockScreenBuilder,
+ ),
+ ],
+ ),
+ ], builder: mockStackedShellBuilder),
+ ],
+ redirectLimit: 10,
+ topRedirect: (BuildContext context, GoRouterState state) {
+ return null;
+ },
+ );
+ },
+ throwsAssertionError,
+ );
+ });
+
+ test(
+ 'throws when a branch of a StatefulShellRoute has a initialLocation '
+ 'that is not a descendant of the same branch', () {
+ final GlobalKey<NavigatorState> root =
+ GlobalKey<NavigatorState>(debugLabel: 'root');
+ final GlobalKey<NavigatorState> sectionANavigatorKey =
+ GlobalKey<NavigatorState>();
+ final GlobalKey<NavigatorState> sectionBNavigatorKey =
+ GlobalKey<NavigatorState>();
+ expect(
+ () {
+ RouteConfiguration(
+ navigatorKey: root,
+ routes: <RouteBase>[
+ StatefulShellRoute.indexedStack(branches: <StatefulShellBranch>[
+ StatefulShellBranch(
+ initialLocation: '/b',
+ navigatorKey: sectionANavigatorKey,
+ routes: <RouteBase>[
+ GoRoute(
+ path: '/a',
+ builder: _mockScreenBuilder,
+ ),
+ ],
+ ),
+ StatefulShellBranch(
+ initialLocation: '/b',
+ navigatorKey: sectionBNavigatorKey,
+ routes: <RouteBase>[
+ StatefulShellRoute.indexedStack(
+ branches: <StatefulShellBranch>[
+ StatefulShellBranch(
+ routes: <RouteBase>[
+ GoRoute(
+ path: '/b',
+ builder: _mockScreenBuilder,
+ ),
+ ],
+ ),
+ ],
+ builder: mockStackedShellBuilder),
+ ],
+ ),
+ ], builder: mockStackedShellBuilder),
+ ],
+ redirectLimit: 10,
+ topRedirect: (BuildContext context, GoRouterState state) {
+ return null;
+ },
+ );
+ },
+ throwsAssertionError,
+ );
+ });
+
+ test(
+ 'does not throw when a branch of a StatefulShellRoute has correctly '
+ 'configured initialLocations', () {
+ final GlobalKey<NavigatorState> root =
+ GlobalKey<NavigatorState>(debugLabel: 'root');
+
+ RouteConfiguration(
+ navigatorKey: root,
+ routes: <RouteBase>[
+ StatefulShellRoute.indexedStack(branches: <StatefulShellBranch>[
+ StatefulShellBranch(
+ routes: <RouteBase>[
+ GoRoute(
+ path: '/a',
+ builder: _mockScreenBuilder,
+ routes: <RouteBase>[
+ GoRoute(
+ path: 'detail',
+ builder: _mockScreenBuilder,
+ ),
+ ]),
+ ],
+ ),
+ StatefulShellBranch(
+ initialLocation: '/b/detail',
+ routes: <RouteBase>[
+ GoRoute(
+ path: '/b',
+ builder: _mockScreenBuilder,
+ routes: <RouteBase>[
+ GoRoute(
+ path: 'detail',
+ builder: _mockScreenBuilder,
+ ),
+ ]),
+ ],
+ ),
+ StatefulShellBranch(
+ initialLocation: '/c/detail',
+ routes: <RouteBase>[
+ StatefulShellRoute.indexedStack(branches: <StatefulShellBranch>[
+ StatefulShellBranch(
+ routes: <RouteBase>[
+ GoRoute(
+ path: '/c',
+ builder: _mockScreenBuilder,
+ routes: <RouteBase>[
+ GoRoute(
+ path: 'detail',
+ builder: _mockScreenBuilder,
+ ),
+ ]),
+ ],
+ ),
+ StatefulShellBranch(
+ initialLocation: '/d/detail',
+ routes: <RouteBase>[
+ GoRoute(
+ path: '/d',
+ builder: _mockScreenBuilder,
+ routes: <RouteBase>[
+ GoRoute(
+ path: 'detail',
+ builder: _mockScreenBuilder,
+ ),
+ ]),
+ ],
+ ),
+ ], builder: mockStackedShellBuilder),
+ ],
+ ),
+ StatefulShellBranch(routes: <RouteBase>[
+ ShellRoute(
+ builder: _mockShellBuilder,
+ routes: <RouteBase>[
+ ShellRoute(
+ builder: _mockShellBuilder,
+ routes: <RouteBase>[
+ GoRoute(
+ path: '/e',
+ builder: _mockScreenBuilder,
+ ),
+ ],
+ )
+ ],
+ ),
+ ]),
+ ], builder: mockStackedShellBuilder),
+ ],
+ redirectLimit: 10,
+ topRedirect: (BuildContext context, GoRouterState state) {
+ return null;
+ },
+ );
+ });
+
+ test(
+ 'derives the correct initialLocation for a StatefulShellBranch',
+ () {
+ final StatefulShellBranch branchA;
+ final StatefulShellBranch branchY;
+ final StatefulShellBranch branchB;
+
+ final RouteConfiguration config = RouteConfiguration(
+ navigatorKey: GlobalKey<NavigatorState>(debugLabel: 'root'),
+ routes: <RouteBase>[
+ StatefulShellRoute.indexedStack(
+ builder: mockStackedShellBuilder,
+ branches: <StatefulShellBranch>[
+ branchA = StatefulShellBranch(routes: <RouteBase>[
+ GoRoute(
+ path: '/a',
+ builder: _mockScreenBuilder,
+ routes: <RouteBase>[
+ GoRoute(
+ path: 'x',
+ builder: _mockScreenBuilder,
+ routes: <RouteBase>[
+ StatefulShellRoute.indexedStack(
+ builder: mockStackedShellBuilder,
+ branches: <StatefulShellBranch>[
+ branchY =
+ StatefulShellBranch(routes: <RouteBase>[
+ ShellRoute(
+ builder: _mockShellBuilder,
+ routes: <RouteBase>[
+ GoRoute(
+ path: 'y1',
+ builder: _mockScreenBuilder,
+ ),
+ GoRoute(
+ path: 'y2',
+ builder: _mockScreenBuilder,
+ ),
+ ])
+ ])
+ ]),
+ ],
+ ),
+ ],
+ ),
+ ]),
+ branchB = StatefulShellBranch(routes: <RouteBase>[
+ ShellRoute(
+ builder: _mockShellBuilder,
+ routes: <RouteBase>[
+ ShellRoute(
+ builder: _mockShellBuilder,
+ routes: <RouteBase>[
+ GoRoute(
+ path: '/b1',
+ builder: _mockScreenBuilder,
+ ),
+ GoRoute(
+ path: '/b2',
+ builder: _mockScreenBuilder,
+ ),
+ ],
+ )
+ ],
+ ),
+ ]),
+ ],
+ ),
+ ],
+ redirectLimit: 10,
+ topRedirect: (BuildContext context, GoRouterState state) {
+ return null;
+ },
+ );
+
+ String? initialLocation(StatefulShellBranch branch) {
+ final GoRoute? route = branch.defaultRoute;
+ return route != null ? config.locationForRoute(route) : null;
+ }
+
+ expect('/a', initialLocation(branchA));
+ expect('/a/x/y1', initialLocation(branchY));
+ expect('/b1', initialLocation(branchB));
+ },
+ );
+
+ test(
'throws when there is a GoRoute ancestor with a different parentNavigatorKey',
() {
final GlobalKey<NavigatorState> root =
diff --git a/packages/go_router/test/delegate_test.dart b/packages/go_router/test/delegate_test.dart
index a0533a1..c25877b 100644
--- a/packages/go_router/test/delegate_test.dart
+++ b/packages/go_router/test/delegate_test.dart
@@ -8,6 +8,8 @@
import 'package:go_router/src/match.dart';
import 'package:go_router/src/misc/error_screen.dart';
+import 'test_helpers.dart';
+
Future<GoRouter> createGoRouter(
WidgetTester tester, {
Listenable? refreshListenable,
@@ -30,6 +32,46 @@
return router;
}
+Future<GoRouter> createGoRouterWithStatefulShellRoute(
+ WidgetTester tester) async {
+ final GoRouter router = GoRouter(
+ initialLocation: '/',
+ routes: <RouteBase>[
+ GoRoute(path: '/', builder: (_, __) => const DummyStatefulWidget()),
+ GoRoute(path: '/a', builder: (_, __) => const DummyStatefulWidget()),
+ StatefulShellRoute.indexedStack(branches: <StatefulShellBranch>[
+ StatefulShellBranch(routes: <RouteBase>[
+ GoRoute(
+ path: '/c',
+ builder: (_, __) => const DummyStatefulWidget(),
+ routes: <RouteBase>[
+ GoRoute(
+ path: 'c1',
+ builder: (_, __) => const DummyStatefulWidget()),
+ GoRoute(
+ path: 'c2',
+ builder: (_, __) => const DummyStatefulWidget()),
+ ]),
+ ]),
+ StatefulShellBranch(routes: <RouteBase>[
+ GoRoute(
+ path: '/d',
+ builder: (_, __) => const DummyStatefulWidget(),
+ routes: <RouteBase>[
+ GoRoute(
+ path: 'd1',
+ builder: (_, __) => const DummyStatefulWidget()),
+ ]),
+ ]),
+ ], builder: mockStackedShellBuilder),
+ ],
+ );
+ await tester.pumpWidget(MaterialApp.router(
+ routerConfig: router,
+ ));
+ return router;
+}
+
void main() {
group('pop', () {
testWidgets('removes the last element', (WidgetTester tester) async {
@@ -79,6 +121,66 @@
);
},
);
+
+ testWidgets(
+ 'It should successfully push a route from outside the the current '
+ 'StatefulShellRoute',
+ (WidgetTester tester) async {
+ final GoRouter goRouter =
+ await createGoRouterWithStatefulShellRoute(tester);
+ goRouter.push('/c/c1');
+ await tester.pumpAndSettle();
+
+ goRouter.push('/a');
+ await tester.pumpAndSettle();
+
+ expect(goRouter.routerDelegate.matches.matches.length, 3);
+ expect(
+ goRouter.routerDelegate.matches.matches[2].pageKey,
+ const Key('/a-p0'),
+ );
+ },
+ );
+
+ testWidgets(
+ 'It should successfully push a route that is a descendant of the current '
+ 'StatefulShellRoute branch',
+ (WidgetTester tester) async {
+ final GoRouter goRouter =
+ await createGoRouterWithStatefulShellRoute(tester);
+ goRouter.push('/c/c1');
+ await tester.pumpAndSettle();
+
+ goRouter.push('/c/c2');
+ await tester.pumpAndSettle();
+
+ expect(goRouter.routerDelegate.matches.matches.length, 3);
+ expect(
+ goRouter.routerDelegate.matches.matches[2].pageKey,
+ const Key('/c/c2-p0'),
+ );
+ },
+ );
+
+ testWidgets(
+ 'It should successfully push the root of the current StatefulShellRoute '
+ 'branch upon itself',
+ (WidgetTester tester) async {
+ final GoRouter goRouter =
+ await createGoRouterWithStatefulShellRoute(tester);
+ goRouter.push('/c');
+ await tester.pumpAndSettle();
+
+ goRouter.push('/c');
+ await tester.pumpAndSettle();
+
+ expect(goRouter.routerDelegate.matches.matches.length, 3);
+ expect(
+ goRouter.routerDelegate.matches.matches[2].pageKey,
+ const Key('/c-p1'),
+ );
+ },
+ );
});
group('canPop', () {
diff --git a/packages/go_router/test/go_router_test.dart b/packages/go_router/test/go_router_test.dart
index a93b492..532a4f6 100644
--- a/packages/go_router/test/go_router_test.dart
+++ b/packages/go_router/test/go_router_test.dart
@@ -2504,6 +2504,87 @@
expect(imperativeRouteMatch.matches.pathParameters['pid'], pid);
});
+ testWidgets('StatefulShellRoute supports nested routes with params',
+ (WidgetTester tester) async {
+ StatefulNavigationShell? routeState;
+ final List<RouteBase> routes = <RouteBase>[
+ StatefulShellRoute.indexedStack(
+ builder: (BuildContext context, GoRouterState state,
+ StatefulNavigationShell navigationShell) {
+ routeState = navigationShell;
+ return navigationShell;
+ },
+ branches: <StatefulShellBranch>[
+ StatefulShellBranch(
+ routes: <RouteBase>[
+ GoRoute(
+ path: '/a',
+ builder: (BuildContext context, GoRouterState state) =>
+ const Text('Screen A'),
+ ),
+ ],
+ ),
+ StatefulShellBranch(
+ routes: <RouteBase>[
+ GoRoute(
+ path: '/family',
+ builder: (BuildContext context, GoRouterState state) =>
+ const Text('Families'),
+ routes: <RouteBase>[
+ GoRoute(
+ path: ':fid',
+ builder: (BuildContext context, GoRouterState state) =>
+ FamilyScreen(state.pathParameters['fid']!),
+ routes: <GoRoute>[
+ GoRoute(
+ path: 'person/:pid',
+ builder:
+ (BuildContext context, GoRouterState state) {
+ final String fid = state.pathParameters['fid']!;
+ final String pid = state.pathParameters['pid']!;
+
+ return PersonScreen(fid, pid);
+ },
+ ),
+ ],
+ )
+ ]),
+ ],
+ ),
+ ],
+ ),
+ ];
+
+ final GoRouter router =
+ await createRouter(routes, tester, initialLocation: '/a');
+ const String fid = 'f1';
+ const String pid = 'p2';
+ const String loc = '/family/$fid/person/$pid';
+
+ router.go(loc);
+ await tester.pumpAndSettle();
+ RouteMatchList matches = router.routerDelegate.matches;
+
+ expect(router.location, loc);
+ expect(matches.matches, hasLength(4));
+ expect(find.byType(PersonScreen), findsOneWidget);
+ expect(matches.pathParameters['fid'], fid);
+ expect(matches.pathParameters['pid'], pid);
+
+ routeState?.goBranch(0);
+ await tester.pumpAndSettle();
+ expect(find.text('Screen A'), findsOneWidget);
+ expect(find.byType(PersonScreen), findsNothing);
+
+ routeState?.goBranch(1);
+ await tester.pumpAndSettle();
+ expect(find.text('Screen A'), findsNothing);
+ expect(find.byType(PersonScreen), findsOneWidget);
+ matches = router.routerDelegate.matches;
+ expect(matches.pathParameters['fid'], fid);
+ expect(matches.pathParameters['pid'], pid);
+ });
+
testWidgets('goNames should allow dynamics values for queryParams',
(WidgetTester tester) async {
const Map<String, dynamic> queryParametersAll = <String, List<dynamic>>{
@@ -2993,6 +3074,766 @@
expect(find.text('Screen B'), findsOneWidget);
expect(find.text('Screen C'), findsNothing);
});
+
+ testWidgets('Builds StatefulShellRoute', (WidgetTester tester) async {
+ final GlobalKey<NavigatorState> rootNavigatorKey =
+ GlobalKey<NavigatorState>();
+
+ final List<RouteBase> routes = <RouteBase>[
+ StatefulShellRoute.indexedStack(
+ builder: (BuildContext context, GoRouterState state,
+ StatefulNavigationShell navigationShell) =>
+ navigationShell,
+ branches: <StatefulShellBranch>[
+ StatefulShellBranch(routes: <GoRoute>[
+ GoRoute(
+ path: '/a',
+ builder: (BuildContext context, GoRouterState state) =>
+ const Text('Screen A'),
+ ),
+ ]),
+ StatefulShellBranch(routes: <GoRoute>[
+ GoRoute(
+ path: '/b',
+ builder: (BuildContext context, GoRouterState state) =>
+ const Text('Screen B'),
+ ),
+ ]),
+ ],
+ ),
+ ];
+
+ final GoRouter router = await createRouter(routes, tester,
+ initialLocation: '/a', navigatorKey: rootNavigatorKey);
+ expect(find.text('Screen A'), findsOneWidget);
+ expect(find.text('Screen B'), findsNothing);
+
+ router.go('/b');
+ await tester.pumpAndSettle();
+ expect(find.text('Screen A'), findsNothing);
+ expect(find.text('Screen B'), findsOneWidget);
+ });
+
+ testWidgets('Builds StatefulShellRoute as a sub-route',
+ (WidgetTester tester) async {
+ final GlobalKey<NavigatorState> rootNavigatorKey =
+ GlobalKey<NavigatorState>();
+
+ final List<RouteBase> routes = <RouteBase>[
+ GoRoute(
+ path: '/root',
+ builder: (BuildContext context, GoRouterState state) =>
+ const Text('Root'),
+ routes: <RouteBase>[
+ StatefulShellRoute.indexedStack(
+ builder: (BuildContext context, GoRouterState state,
+ StatefulNavigationShell navigationShell) =>
+ navigationShell,
+ branches: <StatefulShellBranch>[
+ StatefulShellBranch(routes: <GoRoute>[
+ GoRoute(
+ path: 'a',
+ builder: (BuildContext context, GoRouterState state) =>
+ const Text('Screen A'),
+ ),
+ ]),
+ StatefulShellBranch(routes: <GoRoute>[
+ GoRoute(
+ path: 'b',
+ builder: (BuildContext context, GoRouterState state) =>
+ const Text('Screen B'),
+ ),
+ ]),
+ ],
+ ),
+ ],
+ ),
+ ];
+
+ final GoRouter router = await createRouter(routes, tester,
+ initialLocation: '/root/a', navigatorKey: rootNavigatorKey);
+ expect(find.text('Screen A'), findsOneWidget);
+ expect(find.text('Screen B'), findsNothing);
+
+ router.go('/root/b');
+ await tester.pumpAndSettle();
+ expect(find.text('Screen A'), findsNothing);
+ expect(find.text('Screen B'), findsOneWidget);
+ });
+
+ testWidgets(
+ 'Navigation with goBranch is correctly handled in StatefulShellRoute',
+ (WidgetTester tester) async {
+ final GlobalKey<NavigatorState> rootNavigatorKey =
+ GlobalKey<NavigatorState>();
+ final GlobalKey<DummyStatefulWidgetState> statefulWidgetKey =
+ GlobalKey<DummyStatefulWidgetState>();
+ StatefulNavigationShell? routeState;
+
+ final List<RouteBase> routes = <RouteBase>[
+ StatefulShellRoute.indexedStack(
+ builder: (BuildContext context, GoRouterState state,
+ StatefulNavigationShell navigationShell) {
+ routeState = navigationShell;
+ return navigationShell;
+ },
+ branches: <StatefulShellBranch>[
+ StatefulShellBranch(
+ routes: <RouteBase>[
+ GoRoute(
+ path: '/a',
+ builder: (BuildContext context, GoRouterState state) =>
+ const Text('Screen A'),
+ ),
+ ],
+ ),
+ StatefulShellBranch(
+ routes: <RouteBase>[
+ GoRoute(
+ path: '/b',
+ builder: (BuildContext context, GoRouterState state) =>
+ const Text('Screen B'),
+ ),
+ ],
+ ),
+ StatefulShellBranch(
+ routes: <RouteBase>[
+ GoRoute(
+ path: '/c',
+ builder: (BuildContext context, GoRouterState state) =>
+ const Text('Screen C'),
+ ),
+ ],
+ ),
+ StatefulShellBranch(
+ routes: <RouteBase>[
+ GoRoute(
+ path: '/d',
+ builder: (BuildContext context, GoRouterState state) =>
+ const Text('Screen D'),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ];
+
+ await createRouter(routes, tester,
+ initialLocation: '/a', navigatorKey: rootNavigatorKey);
+ statefulWidgetKey.currentState?.increment();
+ expect(find.text('Screen A'), findsOneWidget);
+ expect(find.text('Screen B'), findsNothing);
+ expect(find.text('Screen C'), findsNothing);
+ expect(find.text('Screen D'), findsNothing);
+
+ routeState!.goBranch(1);
+ await tester.pumpAndSettle();
+ expect(find.text('Screen A'), findsNothing);
+ expect(find.text('Screen B'), findsOneWidget);
+ expect(find.text('Screen C'), findsNothing);
+ expect(find.text('Screen D'), findsNothing);
+
+ routeState!.goBranch(2);
+ await tester.pumpAndSettle();
+ expect(find.text('Screen A'), findsNothing);
+ expect(find.text('Screen B'), findsNothing);
+ expect(find.text('Screen C'), findsOneWidget);
+ expect(find.text('Screen D'), findsNothing);
+
+ routeState!.goBranch(3);
+ await tester.pumpAndSettle();
+ expect(find.text('Screen A'), findsNothing);
+ expect(find.text('Screen B'), findsNothing);
+ expect(find.text('Screen C'), findsNothing);
+ expect(find.text('Screen D'), findsOneWidget);
+
+ expect(() {
+ // Verify that navigation to unknown index fails
+ routeState!.goBranch(4);
+ }, throwsA(isA<Error>()));
+ });
+
+ testWidgets(
+ 'Navigates to correct nested navigation tree in StatefulShellRoute '
+ 'and maintains state', (WidgetTester tester) async {
+ final GlobalKey<NavigatorState> rootNavigatorKey =
+ GlobalKey<NavigatorState>();
+ final GlobalKey<DummyStatefulWidgetState> statefulWidgetKey =
+ GlobalKey<DummyStatefulWidgetState>();
+ StatefulNavigationShell? routeState;
+
+ final List<RouteBase> routes = <RouteBase>[
+ StatefulShellRoute.indexedStack(
+ builder: (BuildContext context, GoRouterState state,
+ StatefulNavigationShell navigationShell) {
+ routeState = navigationShell;
+ return navigationShell;
+ },
+ branches: <StatefulShellBranch>[
+ StatefulShellBranch(routes: <GoRoute>[
+ GoRoute(
+ path: '/a',
+ builder: (BuildContext context, GoRouterState state) =>
+ const Text('Screen A'),
+ routes: <RouteBase>[
+ GoRoute(
+ path: 'detailA',
+ builder: (BuildContext context, GoRouterState state) =>
+ Column(children: <Widget>[
+ const Text('Screen A Detail'),
+ DummyStatefulWidget(key: statefulWidgetKey),
+ ]),
+ ),
+ ],
+ ),
+ ]),
+ StatefulShellBranch(routes: <GoRoute>[
+ GoRoute(
+ path: '/b',
+ builder: (BuildContext context, GoRouterState state) =>
+ const Text('Screen B'),
+ ),
+ ]),
+ ],
+ ),
+ ];
+
+ final GoRouter router = await createRouter(routes, tester,
+ initialLocation: '/a/detailA', navigatorKey: rootNavigatorKey);
+ statefulWidgetKey.currentState?.increment();
+ expect(find.text('Screen A'), findsNothing);
+ expect(find.text('Screen A Detail'), findsOneWidget);
+ expect(find.text('Screen B'), findsNothing);
+
+ routeState!.goBranch(1);
+ await tester.pumpAndSettle();
+ expect(find.text('Screen A'), findsNothing);
+ expect(find.text('Screen A Detail'), findsNothing);
+ expect(find.text('Screen B'), findsOneWidget);
+
+ routeState!.goBranch(0);
+ await tester.pumpAndSettle();
+ expect(statefulWidgetKey.currentState?.counter, equals(1));
+
+ router.go('/a');
+ await tester.pumpAndSettle();
+ expect(find.text('Screen A'), findsOneWidget);
+ expect(find.text('Screen A Detail'), findsNothing);
+ router.go('/a/detailA');
+ await tester.pumpAndSettle();
+ expect(statefulWidgetKey.currentState?.counter, equals(0));
+ });
+
+ testWidgets('Maintains state for nested StatefulShellRoute',
+ (WidgetTester tester) async {
+ final GlobalKey<NavigatorState> rootNavigatorKey =
+ GlobalKey<NavigatorState>();
+ final GlobalKey<DummyStatefulWidgetState> statefulWidgetKey =
+ GlobalKey<DummyStatefulWidgetState>();
+ StatefulNavigationShell? routeState1;
+ StatefulNavigationShell? routeState2;
+
+ final List<RouteBase> routes = <RouteBase>[
+ StatefulShellRoute.indexedStack(
+ builder: (BuildContext context, GoRouterState state,
+ StatefulNavigationShell navigationShell) {
+ routeState1 = navigationShell;
+ return navigationShell;
+ },
+ branches: <StatefulShellBranch>[
+ StatefulShellBranch(routes: <RouteBase>[
+ StatefulShellRoute.indexedStack(
+ builder: (BuildContext context, GoRouterState state,
+ StatefulNavigationShell navigationShell) {
+ routeState2 = navigationShell;
+ return navigationShell;
+ },
+ branches: <StatefulShellBranch>[
+ StatefulShellBranch(routes: <RouteBase>[
+ GoRoute(
+ path: '/a',
+ builder: (BuildContext context, GoRouterState state) =>
+ const Text('Screen A'),
+ routes: <RouteBase>[
+ GoRoute(
+ path: 'detailA',
+ builder:
+ (BuildContext context, GoRouterState state) =>
+ Column(children: <Widget>[
+ const Text('Screen A Detail'),
+ DummyStatefulWidget(key: statefulWidgetKey),
+ ]),
+ ),
+ ],
+ ),
+ ]),
+ StatefulShellBranch(routes: <RouteBase>[
+ GoRoute(
+ path: '/b',
+ builder: (BuildContext context, GoRouterState state) =>
+ const Text('Screen B'),
+ ),
+ ]),
+ StatefulShellBranch(routes: <RouteBase>[
+ GoRoute(
+ path: '/c',
+ builder: (BuildContext context, GoRouterState state) =>
+ const Text('Screen C'),
+ ),
+ ]),
+ ]),
+ ]),
+ StatefulShellBranch(routes: <GoRoute>[
+ GoRoute(
+ path: '/d',
+ builder: (BuildContext context, GoRouterState state) =>
+ const Text('Screen D'),
+ ),
+ ]),
+ ],
+ ),
+ ];
+
+ await createRouter(routes, tester,
+ initialLocation: '/a/detailA', navigatorKey: rootNavigatorKey);
+ statefulWidgetKey.currentState?.increment();
+ expect(find.text('Screen A Detail'), findsOneWidget);
+ routeState2!.goBranch(1);
+ await tester.pumpAndSettle();
+ expect(find.text('Screen B'), findsOneWidget);
+
+ routeState1!.goBranch(1);
+ await tester.pumpAndSettle();
+ expect(find.text('Screen D'), findsOneWidget);
+
+ routeState1!.goBranch(0);
+ await tester.pumpAndSettle();
+ expect(find.text('Screen B'), findsOneWidget);
+
+ routeState2!.goBranch(2);
+ await tester.pumpAndSettle();
+ expect(find.text('Screen C'), findsOneWidget);
+
+ routeState2!.goBranch(0);
+ await tester.pumpAndSettle();
+ expect(find.text('Screen A Detail'), findsOneWidget);
+ expect(statefulWidgetKey.currentState?.counter, equals(1));
+ });
+
+ testWidgets(
+ 'Pops from the correct Navigator in a StatefulShellRoute when the '
+ 'Android back button is pressed', (WidgetTester tester) async {
+ final GlobalKey<NavigatorState> rootNavigatorKey =
+ GlobalKey<NavigatorState>();
+ final GlobalKey<NavigatorState> sectionANavigatorKey =
+ GlobalKey<NavigatorState>();
+ final GlobalKey<NavigatorState> sectionBNavigatorKey =
+ GlobalKey<NavigatorState>();
+ StatefulNavigationShell? routeState;
+
+ final List<RouteBase> routes = <RouteBase>[
+ StatefulShellRoute.indexedStack(
+ builder: (BuildContext context, GoRouterState state,
+ StatefulNavigationShell navigationShell) {
+ routeState = navigationShell;
+ return navigationShell;
+ },
+ branches: <StatefulShellBranch>[
+ StatefulShellBranch(
+ navigatorKey: sectionANavigatorKey,
+ routes: <GoRoute>[
+ GoRoute(
+ path: '/a',
+ builder: (BuildContext context, GoRouterState state) =>
+ const Text('Screen A'),
+ routes: <RouteBase>[
+ GoRoute(
+ path: 'detailA',
+ builder: (BuildContext context, GoRouterState state) =>
+ const Text('Screen A Detail'),
+ ),
+ ],
+ ),
+ ]),
+ StatefulShellBranch(
+ navigatorKey: sectionBNavigatorKey,
+ routes: <GoRoute>[
+ GoRoute(
+ path: '/b',
+ builder: (BuildContext context, GoRouterState state) =>
+ const Text('Screen B'),
+ routes: <RouteBase>[
+ GoRoute(
+ path: 'detailB',
+ builder: (BuildContext context, GoRouterState state) =>
+ const Text('Screen B Detail'),
+ ),
+ ],
+ ),
+ ]),
+ ],
+ ),
+ ];
+
+ final GoRouter router = await createRouter(routes, tester,
+ initialLocation: '/a/detailA', navigatorKey: rootNavigatorKey);
+ expect(find.text('Screen A'), findsNothing);
+ expect(find.text('Screen A Detail'), findsOneWidget);
+ expect(find.text('Screen B'), findsNothing);
+ expect(find.text('Screen B Detail'), findsNothing);
+
+ router.go('/b/detailB');
+ await tester.pumpAndSettle();
+
+ expect(find.text('Screen A'), findsNothing);
+ expect(find.text('Screen A Detail'), findsNothing);
+ expect(find.text('Screen B'), findsNothing);
+ expect(find.text('Screen B Detail'), findsOneWidget);
+
+ await simulateAndroidBackButton(tester);
+ await tester.pumpAndSettle();
+
+ expect(find.text('Screen A'), findsNothing);
+ expect(find.text('Screen A Detail'), findsNothing);
+ expect(find.text('Screen B'), findsOneWidget);
+ expect(find.text('Screen B Detail'), findsNothing);
+
+ routeState!.goBranch(0);
+ await tester.pumpAndSettle();
+ expect(find.text('Screen A'), findsNothing);
+ expect(find.text('Screen A Detail'), findsOneWidget);
+
+ await simulateAndroidBackButton(tester);
+ await tester.pumpAndSettle();
+ expect(find.text('Screen A'), findsOneWidget);
+ expect(find.text('Screen A Detail'), findsNothing);
+ });
+
+ testWidgets(
+ 'Maintains extra navigation information when navigating '
+ 'between branches in StatefulShellRoute', (WidgetTester tester) async {
+ final GlobalKey<NavigatorState> rootNavigatorKey =
+ GlobalKey<NavigatorState>();
+ StatefulNavigationShell? routeState;
+
+ final List<RouteBase> routes = <RouteBase>[
+ StatefulShellRoute.indexedStack(
+ builder: (BuildContext context, GoRouterState state,
+ StatefulNavigationShell navigationShell) {
+ routeState = navigationShell;
+ return navigationShell;
+ },
+ branches: <StatefulShellBranch>[
+ StatefulShellBranch(routes: <GoRoute>[
+ GoRoute(
+ path: '/a',
+ builder: (BuildContext context, GoRouterState state) =>
+ const Text('Screen A'),
+ ),
+ ]),
+ StatefulShellBranch(routes: <GoRoute>[
+ GoRoute(
+ path: '/b',
+ builder: (BuildContext context, GoRouterState state) =>
+ Text('Screen B - ${state.extra}'),
+ ),
+ ]),
+ ],
+ ),
+ ];
+
+ final GoRouter router = await createRouter(routes, tester,
+ initialLocation: '/a', navigatorKey: rootNavigatorKey);
+ expect(find.text('Screen A'), findsOneWidget);
+
+ router.go('/b', extra: 'X');
+ await tester.pumpAndSettle();
+ expect(find.text('Screen A'), findsNothing);
+ expect(find.text('Screen B - X'), findsOneWidget);
+
+ routeState!.goBranch(0);
+ await tester.pumpAndSettle();
+ expect(find.text('Screen A'), findsOneWidget);
+ expect(find.text('Screen B - X'), findsNothing);
+
+ routeState!.goBranch(1);
+ await tester.pumpAndSettle();
+ expect(find.text('Screen A'), findsNothing);
+ expect(find.text('Screen B - X'), findsOneWidget);
+ });
+
+ testWidgets(
+ 'Pushed non-descendant routes are correctly restored when '
+ 'navigating between branches in StatefulShellRoute',
+ (WidgetTester tester) async {
+ final GlobalKey<NavigatorState> rootNavigatorKey =
+ GlobalKey<NavigatorState>();
+ StatefulNavigationShell? routeState;
+
+ final List<RouteBase> routes = <RouteBase>[
+ GoRoute(
+ path: '/common',
+ builder: (BuildContext context, GoRouterState state) =>
+ Text('Common - ${state.extra}'),
+ ),
+ StatefulShellRoute.indexedStack(
+ builder: (BuildContext context, GoRouterState state,
+ StatefulNavigationShell navigationShell) {
+ routeState = navigationShell;
+ return navigationShell;
+ },
+ branches: <StatefulShellBranch>[
+ StatefulShellBranch(routes: <GoRoute>[
+ GoRoute(
+ path: '/a',
+ builder: (BuildContext context, GoRouterState state) =>
+ const Text('Screen A'),
+ ),
+ ]),
+ StatefulShellBranch(routes: <GoRoute>[
+ GoRoute(
+ path: '/b',
+ builder: (BuildContext context, GoRouterState state) =>
+ const Text('Screen B'),
+ ),
+ ]),
+ ],
+ ),
+ ];
+
+ final GoRouter router = await createRouter(routes, tester,
+ initialLocation: '/a', navigatorKey: rootNavigatorKey);
+ expect(find.text('Screen A'), findsOneWidget);
+
+ router.go('/b');
+ await tester.pumpAndSettle();
+ expect(find.text('Screen A'), findsNothing);
+ expect(find.text('Screen B'), findsOneWidget);
+
+ router.push('/common', extra: 'X');
+ await tester.pumpAndSettle();
+ expect(find.text('Screen A'), findsNothing);
+ expect(find.text('Screen B'), findsNothing);
+ expect(find.text('Common - X'), findsOneWidget);
+
+ routeState!.goBranch(0);
+ await tester.pumpAndSettle();
+ expect(find.text('Screen A'), findsOneWidget);
+
+ routeState!.goBranch(1);
+ await tester.pumpAndSettle();
+ expect(find.text('Screen A'), findsNothing);
+ expect(find.text('Screen B'), findsNothing);
+ expect(find.text('Common - X'), findsOneWidget);
+ });
+
+ testWidgets(
+ 'Redirects are correctly handled when switching branch in a '
+ 'StatefulShellRoute', (WidgetTester tester) async {
+ final GlobalKey<NavigatorState> rootNavigatorKey =
+ GlobalKey<NavigatorState>();
+ StatefulNavigationShell? routeState;
+
+ final List<RouteBase> routes = <RouteBase>[
+ StatefulShellRoute.indexedStack(
+ builder: (BuildContext context, GoRouterState state,
+ StatefulNavigationShell navigationShell) {
+ routeState = navigationShell;
+ return navigationShell;
+ },
+ branches: <StatefulShellBranch>[
+ StatefulShellBranch(routes: <GoRoute>[
+ GoRoute(
+ path: '/a',
+ builder: (BuildContext context, GoRouterState state) =>
+ const Text('Screen A'),
+ ),
+ ]),
+ StatefulShellBranch(routes: <GoRoute>[
+ GoRoute(
+ path: '/b',
+ builder: (BuildContext context, GoRouterState state) =>
+ const Text('Screen B'),
+ routes: <RouteBase>[
+ GoRoute(
+ path: 'details1',
+ builder: (BuildContext context, GoRouterState state) =>
+ const Text('Screen B Detail1'),
+ ),
+ GoRoute(
+ path: 'details2',
+ builder: (BuildContext context, GoRouterState state) =>
+ const Text('Screen B Detail2'),
+ ),
+ ],
+ ),
+ ]),
+ StatefulShellBranch(routes: <GoRoute>[
+ GoRoute(
+ path: '/c',
+ redirect: (_, __) => '/c/main2',
+ ),
+ GoRoute(
+ path: '/c/main1',
+ builder: (BuildContext context, GoRouterState state) =>
+ const Text('Screen C1'),
+ ),
+ GoRoute(
+ path: '/c/main2',
+ builder: (BuildContext context, GoRouterState state) =>
+ const Text('Screen C2'),
+ ),
+ ]),
+ ],
+ ),
+ ];
+
+ String redirectDestinationBranchB = '/b/details1';
+ await createRouter(
+ routes,
+ tester,
+ initialLocation: '/a',
+ navigatorKey: rootNavigatorKey,
+ redirect: (_, GoRouterState state) {
+ if (state.location.startsWith('/b')) {
+ return redirectDestinationBranchB;
+ }
+ return null;
+ },
+ );
+ expect(find.text('Screen A'), findsOneWidget);
+ expect(find.text('Screen B Detail'), findsNothing);
+
+ routeState!.goBranch(1);
+ await tester.pumpAndSettle();
+ expect(find.text('Screen A'), findsNothing);
+ expect(find.text('Screen B Detail1'), findsOneWidget);
+
+ routeState!.goBranch(2);
+ await tester.pumpAndSettle();
+ expect(find.text('Screen A'), findsNothing);
+ expect(find.text('Screen B Detail1'), findsNothing);
+ expect(find.text('Screen C2'), findsOneWidget);
+
+ redirectDestinationBranchB = '/b/details2';
+ routeState!.goBranch(1);
+ await tester.pumpAndSettle();
+ expect(find.text('Screen A'), findsNothing);
+ expect(find.text('Screen B Detail2'), findsOneWidget);
+ expect(find.text('Screen C2'), findsNothing);
+ });
+
+ testWidgets(
+ 'Pushed top-level route is correctly handled by StatefulShellRoute',
+ (WidgetTester tester) async {
+ final GlobalKey<NavigatorState> rootNavigatorKey =
+ GlobalKey<NavigatorState>();
+ final GlobalKey<NavigatorState> nestedNavigatorKey =
+ GlobalKey<NavigatorState>();
+ StatefulNavigationShell? routeState;
+
+ final List<RouteBase> routes = <RouteBase>[
+ // First level shell
+ StatefulShellRoute.indexedStack(
+ builder: (BuildContext context, GoRouterState state,
+ StatefulNavigationShell navigationShell) {
+ routeState = navigationShell;
+ return navigationShell;
+ },
+ branches: <StatefulShellBranch>[
+ StatefulShellBranch(routes: <GoRoute>[
+ GoRoute(
+ path: '/a',
+ builder: (BuildContext context, GoRouterState state) =>
+ const Text('Screen A'),
+ ),
+ ]),
+ StatefulShellBranch(routes: <RouteBase>[
+ // Second level / nested shell
+ StatefulShellRoute.indexedStack(
+ builder: (BuildContext context, GoRouterState state,
+ StatefulNavigationShell navigationShell) =>
+ navigationShell,
+ branches: <StatefulShellBranch>[
+ StatefulShellBranch(routes: <GoRoute>[
+ GoRoute(
+ path: '/b1',
+ builder: (BuildContext context, GoRouterState state) =>
+ const Text('Screen B1'),
+ ),
+ ]),
+ StatefulShellBranch(
+ navigatorKey: nestedNavigatorKey,
+ routes: <GoRoute>[
+ GoRoute(
+ path: '/b2',
+ builder:
+ (BuildContext context, GoRouterState state) =>
+ const Text('Screen B2'),
+ ),
+ GoRoute(
+ path: '/b2-modal',
+ // We pass an explicit parentNavigatorKey here, to
+ // properly test the logic in RouteBuilder, i.e.
+ // routes with parentNavigatorKeys under the shell
+ // should not be stripped.
+ parentNavigatorKey: nestedNavigatorKey,
+ builder:
+ (BuildContext context, GoRouterState state) =>
+ const Text('Nested Modal'),
+ ),
+ ]),
+ ],
+ ),
+ ]),
+ ],
+ ),
+ GoRoute(
+ path: '/top-modal',
+ parentNavigatorKey: rootNavigatorKey,
+ builder: (BuildContext context, GoRouterState state) =>
+ const Text('Top Modal'),
+ ),
+ ];
+
+ final GoRouter router = await createRouter(routes, tester,
+ initialLocation: '/a', navigatorKey: rootNavigatorKey);
+ expect(find.text('Screen A'), findsOneWidget);
+
+ routeState!.goBranch(1);
+ await tester.pumpAndSettle();
+ expect(find.text('Screen B1'), findsOneWidget);
+
+ // Navigate nested (second level) shell to second branch
+ router.go('/b2');
+ await tester.pumpAndSettle();
+ expect(find.text('Screen B2'), findsOneWidget);
+
+ // Push route over second branch of nested (second level) shell
+ router.push('/b2-modal');
+ await tester.pumpAndSettle();
+ expect(find.text('Nested Modal'), findsOneWidget);
+
+ // Push top-level route while on second branch
+ router.push('/top-modal');
+ await tester.pumpAndSettle();
+ expect(find.text('Top Modal'), findsOneWidget);
+
+ // Return to shell and first branch
+ router.go('/a');
+ await tester.pumpAndSettle();
+ expect(find.text('Screen A'), findsOneWidget);
+
+ // Switch to second branch, which should only contain 'Nested Modal'
+ // (in the nested shell)
+ routeState!.goBranch(1);
+ await tester.pumpAndSettle();
+ expect(find.text('Screen A'), findsNothing);
+ expect(find.text('Screen B1'), findsNothing);
+ expect(find.text('Screen B2'), findsNothing);
+ expect(find.text('Top Modal'), findsNothing);
+ expect(find.text('Nested Modal'), findsOneWidget);
+ });
});
group('Imperative navigation', () {
@@ -3113,44 +3954,47 @@
);
testWidgets(
- 'It checks if ShellRoute navigators can pop',
+ 'It checks if StatefulShellRoute navigators can pop',
(WidgetTester tester) async {
- final GlobalKey<NavigatorState> shellNavigatorKey =
+ final GlobalKey<NavigatorState> rootNavigatorKey =
GlobalKey<NavigatorState>();
final GoRouter router = GoRouter(
+ navigatorKey: rootNavigatorKey,
initialLocation: '/a',
routes: <RouteBase>[
- ShellRoute(
- navigatorKey: shellNavigatorKey,
- builder:
- (BuildContext context, GoRouterState state, Widget child) {
- return Scaffold(
- appBar: AppBar(title: const Text('Shell')),
- body: child,
- );
- },
- routes: <GoRoute>[
- GoRoute(
- path: '/a',
- builder: (BuildContext context, _) {
- return Scaffold(
- body: TextButton(
- onPressed: () async {
- shellNavigatorKey.currentState!.push(
- MaterialPageRoute<void>(
- builder: (BuildContext context) {
- return const Scaffold(
- body: Text('pageless route'),
- );
- },
- ),
+ StatefulShellRoute.indexedStack(
+ builder: mockStackedShellBuilder,
+ branches: <StatefulShellBranch>[
+ StatefulShellBranch(routes: <GoRoute>[
+ GoRoute(
+ path: '/a',
+ builder: (BuildContext context, _) {
+ return const Scaffold(
+ body: Text('Screen A'),
+ );
+ },
+ ),
+ ]),
+ StatefulShellBranch(routes: <GoRoute>[
+ GoRoute(
+ path: '/b',
+ builder: (BuildContext context, _) {
+ return const Scaffold(
+ body: Text('Screen B'),
+ );
+ },
+ routes: <RouteBase>[
+ GoRoute(
+ path: 'detail',
+ builder: (BuildContext context, _) {
+ return const Scaffold(
+ body: Text('Screen B detail'),
);
},
- child: const Text('Push'),
),
- );
- },
- ),
+ ],
+ ),
+ ]),
],
),
],
@@ -3164,16 +4008,19 @@
);
expect(router.canPop(), false);
- expect(find.text('Push'), findsOneWidget);
- await tester.tap(find.text('Push'));
+ router.go('/b/detail');
await tester.pumpAndSettle();
- expect(
- find.text('pageless route', skipOffstage: false), findsOneWidget);
+ expect(find.text('Screen B detail', skipOffstage: false),
+ findsOneWidget);
expect(router.canPop(), true);
+ // Verify that it is actually the StatefulShellRoute that reports
+ // canPop = true
+ expect(rootNavigatorKey.currentState?.canPop(), false);
},
);
+
testWidgets('Pageless route should include in can pop',
(WidgetTester tester) async {
final GlobalKey<NavigatorState> root =
@@ -3483,6 +4330,321 @@
},
);
});
+
+ group('state restoration', () {
+ testWidgets('Restores state correctly', (WidgetTester tester) async {
+ final GlobalKey<DummyRestorableStatefulWidgetState> statefulWidgetKeyA =
+ GlobalKey<DummyRestorableStatefulWidgetState>();
+
+ final List<RouteBase> routes = <RouteBase>[
+ GoRoute(
+ path: '/a',
+ pageBuilder: createPageBuilder(
+ restorationId: 'screenA', child: const Text('Screen A')),
+ routes: <RouteBase>[
+ GoRoute(
+ path: 'detail',
+ pageBuilder: createPageBuilder(
+ restorationId: 'screenADetail',
+ child: Column(children: <Widget>[
+ const Text('Screen A Detail'),
+ DummyRestorableStatefulWidget(
+ key: statefulWidgetKeyA, restorationId: 'counterA'),
+ ])),
+ ),
+ ],
+ ),
+ ];
+
+ await createRouter(routes, tester,
+ initialLocation: '/a/detail', restorationScopeId: 'test');
+ await tester.pumpAndSettle();
+ statefulWidgetKeyA.currentState?.increment();
+ expect(statefulWidgetKeyA.currentState?.counter, equals(1));
+ await tester.pumpAndSettle(); // Give state change time to persist
+
+ await tester.restartAndRestore();
+
+ await tester.pumpAndSettle();
+ expect(find.text('Screen A Detail'), findsOneWidget);
+ expect(statefulWidgetKeyA.currentState?.counter, equals(1));
+ });
+
+ testWidgets('Restores state of branches in StatefulShellRoute correctly',
+ (WidgetTester tester) async {
+ final GlobalKey<NavigatorState> rootNavigatorKey =
+ GlobalKey<NavigatorState>();
+ final GlobalKey<DummyRestorableStatefulWidgetState> statefulWidgetKeyA =
+ GlobalKey<DummyRestorableStatefulWidgetState>();
+ final GlobalKey<DummyRestorableStatefulWidgetState> statefulWidgetKeyB =
+ GlobalKey<DummyRestorableStatefulWidgetState>();
+ final GlobalKey<DummyRestorableStatefulWidgetState> statefulWidgetKeyC =
+ GlobalKey<DummyRestorableStatefulWidgetState>();
+ StatefulNavigationShell? routeState;
+
+ final List<RouteBase> routes = <RouteBase>[
+ StatefulShellRoute.indexedStack(
+ restorationScopeId: 'shell',
+ pageBuilder: (BuildContext context, GoRouterState state,
+ StatefulNavigationShell navigationShell) {
+ routeState = navigationShell;
+ return MaterialPage<dynamic>(
+ restorationId: 'shellWidget', child: navigationShell);
+ },
+ branches: <StatefulShellBranch>[
+ StatefulShellBranch(
+ restorationScopeId: 'branchA',
+ routes: <GoRoute>[
+ GoRoute(
+ path: '/a',
+ pageBuilder: createPageBuilder(
+ restorationId: 'screenA',
+ child: const Text('Screen A')),
+ routes: <RouteBase>[
+ GoRoute(
+ path: 'detailA',
+ pageBuilder: createPageBuilder(
+ restorationId: 'screenADetail',
+ child: Column(children: <Widget>[
+ const Text('Screen A Detail'),
+ DummyRestorableStatefulWidget(
+ key: statefulWidgetKeyA,
+ restorationId: 'counterA'),
+ ])),
+ ),
+ ],
+ ),
+ ]),
+ StatefulShellBranch(
+ restorationScopeId: 'branchB',
+ routes: <GoRoute>[
+ GoRoute(
+ path: '/b',
+ pageBuilder: createPageBuilder(
+ restorationId: 'screenB',
+ child: const Text('Screen B')),
+ routes: <RouteBase>[
+ GoRoute(
+ path: 'detailB',
+ pageBuilder: createPageBuilder(
+ restorationId: 'screenBDetail',
+ child: Column(children: <Widget>[
+ const Text('Screen B Detail'),
+ DummyRestorableStatefulWidget(
+ key: statefulWidgetKeyB,
+ restorationId: 'counterB'),
+ ])),
+ ),
+ ],
+ ),
+ ]),
+ StatefulShellBranch(routes: <GoRoute>[
+ GoRoute(
+ path: '/c',
+ pageBuilder: createPageBuilder(
+ restorationId: 'screenC', child: const Text('Screen C')),
+ routes: <RouteBase>[
+ GoRoute(
+ path: 'detailC',
+ pageBuilder: createPageBuilder(
+ restorationId: 'screenCDetail',
+ child: Column(children: <Widget>[
+ const Text('Screen C Detail'),
+ DummyRestorableStatefulWidget(
+ key: statefulWidgetKeyC,
+ restorationId: 'counterC'),
+ ])),
+ ),
+ ],
+ ),
+ ]),
+ ],
+ ),
+ ];
+
+ final GoRouter router = await createRouter(routes, tester,
+ initialLocation: '/a/detailA',
+ navigatorKey: rootNavigatorKey,
+ restorationScopeId: 'test');
+ await tester.pumpAndSettle();
+ statefulWidgetKeyA.currentState?.increment();
+ expect(statefulWidgetKeyA.currentState?.counter, equals(1));
+
+ router.go('/b/detailB');
+ await tester.pumpAndSettle();
+ statefulWidgetKeyB.currentState?.increment();
+ expect(statefulWidgetKeyB.currentState?.counter, equals(1));
+
+ router.go('/c/detailC');
+ await tester.pumpAndSettle();
+ statefulWidgetKeyC.currentState?.increment();
+ expect(statefulWidgetKeyC.currentState?.counter, equals(1));
+
+ routeState!.goBranch(0);
+ await tester.pumpAndSettle();
+ expect(find.text('Screen A Detail'), findsOneWidget);
+
+ await tester.restartAndRestore();
+
+ await tester.pumpAndSettle();
+ expect(find.text('Screen A Detail'), findsOneWidget);
+ expect(statefulWidgetKeyA.currentState?.counter, equals(1));
+
+ routeState!.goBranch(1);
+ await tester.pumpAndSettle();
+ expect(find.text('Screen B Detail'), findsOneWidget);
+ expect(statefulWidgetKeyB.currentState?.counter, equals(1));
+
+ routeState!.goBranch(2);
+ await tester.pumpAndSettle();
+ expect(find.text('Screen C Detail'), findsOneWidget);
+ // State of branch C should not have been restored
+ expect(statefulWidgetKeyC.currentState?.counter, equals(0));
+ });
+
+ testWidgets(
+ 'Restores state of imperative routes in StatefulShellRoute correctly',
+ (WidgetTester tester) async {
+ final GlobalKey<NavigatorState> rootNavigatorKey =
+ GlobalKey<NavigatorState>();
+ final GlobalKey<DummyRestorableStatefulWidgetState> statefulWidgetKeyA =
+ GlobalKey<DummyRestorableStatefulWidgetState>();
+ final GlobalKey<DummyRestorableStatefulWidgetState> statefulWidgetKeyB =
+ GlobalKey<DummyRestorableStatefulWidgetState>();
+ StatefulNavigationShell? routeStateRoot;
+ StatefulNavigationShell? routeStateNested;
+
+ final List<RouteBase> routes = <RouteBase>[
+ StatefulShellRoute.indexedStack(
+ restorationScopeId: 'shell',
+ pageBuilder: (BuildContext context, GoRouterState state,
+ StatefulNavigationShell navigationShell) {
+ routeStateRoot = navigationShell;
+ return MaterialPage<dynamic>(
+ restorationId: 'shellWidget', child: navigationShell);
+ },
+ branches: <StatefulShellBranch>[
+ StatefulShellBranch(
+ restorationScopeId: 'branchA',
+ routes: <GoRoute>[
+ GoRoute(
+ path: '/a',
+ pageBuilder: createPageBuilder(
+ restorationId: 'screenA',
+ child: const Text('Screen A')),
+ routes: <RouteBase>[
+ GoRoute(
+ path: 'detailA',
+ pageBuilder: createPageBuilder(
+ restorationId: 'screenADetail',
+ child: Column(children: <Widget>[
+ const Text('Screen A Detail'),
+ DummyRestorableStatefulWidget(
+ key: statefulWidgetKeyA,
+ restorationId: 'counterA'),
+ ])),
+ ),
+ ],
+ ),
+ ]),
+ StatefulShellBranch(
+ restorationScopeId: 'branchB',
+ routes: <RouteBase>[
+ StatefulShellRoute.indexedStack(
+ restorationScopeId: 'branchB-nested-shell',
+ pageBuilder: (BuildContext context, GoRouterState state,
+ StatefulNavigationShell navigationShell) {
+ routeStateNested = navigationShell;
+ return MaterialPage<dynamic>(
+ restorationId: 'shellWidget-nested',
+ child: navigationShell);
+ },
+ branches: <StatefulShellBranch>[
+ StatefulShellBranch(
+ restorationScopeId: 'branchB-nested',
+ routes: <GoRoute>[
+ GoRoute(
+ path: '/b',
+ pageBuilder: createPageBuilder(
+ restorationId: 'screenB',
+ child: const Text('Screen B')),
+ routes: <RouteBase>[
+ GoRoute(
+ path: 'detailB',
+ pageBuilder: createPageBuilder(
+ restorationId: 'screenBDetail',
+ child: Column(children: <Widget>[
+ const Text('Screen B Detail'),
+ DummyRestorableStatefulWidget(
+ key: statefulWidgetKeyB,
+ restorationId: 'counterB'),
+ ])),
+ ),
+ ],
+ ),
+ ]),
+ StatefulShellBranch(
+ restorationScopeId: 'branchC-nested',
+ routes: <GoRoute>[
+ GoRoute(
+ path: '/c',
+ pageBuilder: createPageBuilder(
+ restorationId: 'screenC',
+ child: const Text('Screen C')),
+ ),
+ ]),
+ ])
+ ]),
+ ],
+ ),
+ ];
+
+ final GoRouter router = await createRouter(routes, tester,
+ initialLocation: '/a/detailA',
+ navigatorKey: rootNavigatorKey,
+ restorationScopeId: 'test');
+ await tester.pumpAndSettle();
+ statefulWidgetKeyA.currentState?.increment();
+ expect(statefulWidgetKeyA.currentState?.counter, equals(1));
+
+ routeStateRoot!.goBranch(1);
+ await tester.pumpAndSettle();
+
+ router.go('/b/detailB');
+ await tester.pumpAndSettle();
+ statefulWidgetKeyB.currentState?.increment();
+ expect(statefulWidgetKeyB.currentState?.counter, equals(1));
+
+ routeStateRoot!.goBranch(0);
+ await tester.pumpAndSettle();
+ expect(find.text('Screen A Detail'), findsOneWidget);
+ expect(find.text('Screen B'), findsNothing);
+ expect(find.text('Screen B Pushed Detail'), findsNothing);
+
+ await tester.restartAndRestore();
+
+ await tester.pumpAndSettle();
+ expect(find.text('Screen A Detail'), findsOneWidget);
+ expect(find.text('Screen B'), findsNothing);
+ expect(find.text('Screen B Pushed Detail'), findsNothing);
+ expect(statefulWidgetKeyA.currentState?.counter, equals(1));
+
+ routeStateRoot!.goBranch(1);
+ await tester.pumpAndSettle();
+ expect(find.text('Screen A Detail'), findsNothing);
+ expect(find.text('Screen B'), findsNothing);
+ expect(find.text('Screen B Detail'), findsOneWidget);
+ expect(statefulWidgetKeyB.currentState?.counter, equals(1));
+
+ routeStateNested!.goBranch(1);
+ await tester.pumpAndSettle();
+ routeStateNested!.goBranch(0);
+ await tester.pumpAndSettle();
+
+ expect(find.text('Screen B Detail'), findsOneWidget);
+ expect(statefulWidgetKeyB.currentState?.counter, equals(1));
+ });
+ });
}
class TestInheritedNotifier extends InheritedNotifier<ValueNotifier<String>> {
diff --git a/packages/go_router/test/matching_test.dart b/packages/go_router/test/matching_test.dart
index daf8ccc..8a1d12a 100644
--- a/packages/go_router/test/matching_test.dart
+++ b/packages/go_router/test/matching_test.dart
@@ -74,4 +74,37 @@
expect(matches1 == matches2, isTrue);
expect(matches1 == matches3, isFalse);
});
+
+ test('RouteMatchList is encoded and decoded correctly', () {
+ final RouteConfiguration configuration = RouteConfiguration(
+ routes: <GoRoute>[
+ GoRoute(
+ path: '/a',
+ builder: (BuildContext context, GoRouterState state) =>
+ const Placeholder(),
+ ),
+ GoRoute(
+ path: '/b',
+ builder: (BuildContext context, GoRouterState state) =>
+ const Placeholder(),
+ ),
+ ],
+ redirectLimit: 0,
+ navigatorKey: GlobalKey<NavigatorState>(),
+ topRedirect: (_, __) => null,
+ );
+ final RouteMatcher matcher = RouteMatcher(configuration);
+ final RouteMatchListCodec codec = RouteMatchListCodec(matcher);
+
+ final RouteMatchList list1 = matcher.findMatch('/a');
+ final RouteMatchList list2 = matcher.findMatch('/b');
+ list1.push(ImperativeRouteMatch<Object?>(
+ pageKey: const ValueKey<String>('/b-p0'), matches: list2));
+
+ final Object? encoded = codec.encodeMatchList(list1);
+ final RouteMatchList? decoded = codec.decodeMatchList(encoded);
+
+ expect(decoded, isNotNull);
+ expect(decoded, equals(list1));
+ });
}
diff --git a/packages/go_router/test/test_helpers.dart b/packages/go_router/test/test_helpers.dart
index d2187d4..19f795c 100644
--- a/packages/go_router/test/test_helpers.dart
+++ b/packages/go_router/test/test_helpers.dart
@@ -8,6 +8,7 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
+import 'package:go_router/src/match.dart';
Future<GoRouter> createGoRouter(WidgetTester tester) async {
final GoRouter goRouter = GoRouter(
@@ -147,6 +148,7 @@
int redirectLimit = 5,
GlobalKey<NavigatorState>? navigatorKey,
GoRouterWidgetBuilder? errorBuilder,
+ String? restorationScopeId,
}) async {
final GoRouter goRouter = GoRouter(
routes: routes,
@@ -158,9 +160,12 @@
(BuildContext context, GoRouterState state) =>
TestErrorScreen(state.error!),
navigatorKey: navigatorKey,
+ restorationScopeId: restorationScopeId,
);
await tester.pumpWidget(
MaterialApp.router(
+ restorationScopeId:
+ restorationScopeId != null ? '$restorationScopeId-root' : null,
routerConfig: goRouter,
),
);
@@ -228,10 +233,49 @@
const DummyStatefulWidget({super.key});
@override
- State<DummyStatefulWidget> createState() => DummyStatefulWidgetState();
+ State<StatefulWidget> createState() => DummyStatefulWidgetState();
}
class DummyStatefulWidgetState extends State<DummyStatefulWidget> {
+ int counter = 0;
+
+ void increment() => setState(() {
+ counter++;
+ });
+
+ @override
+ Widget build(BuildContext context) => Container();
+}
+
+class DummyRestorableStatefulWidget extends StatefulWidget {
+ const DummyRestorableStatefulWidget({super.key, this.restorationId});
+
+ final String? restorationId;
+
+ @override
+ State<StatefulWidget> createState() => DummyRestorableStatefulWidgetState();
+}
+
+class DummyRestorableStatefulWidgetState
+ extends State<DummyRestorableStatefulWidget> with RestorationMixin {
+ final RestorableInt _counter = RestorableInt(0);
+
+ @override
+ String? get restorationId => widget.restorationId;
+
+ int get counter => _counter.value;
+
+ void increment([int count = 1]) => setState(() {
+ _counter.value += count;
+ });
+
+ @override
+ void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
+ if (restorationId != null) {
+ registerForRestoration(_counter, restorationId!);
+ }
+ }
+
@override
Widget build(BuildContext context) => Container();
}
@@ -242,3 +286,23 @@
await tester.binding.defaultBinaryMessenger
.handlePlatformMessage('flutter/navigation', message, (_) {});
}
+
+GoRouterPageBuilder createPageBuilder(
+ {String? restorationId, required Widget child}) =>
+ (BuildContext context, GoRouterState state) =>
+ MaterialPage<dynamic>(restorationId: restorationId, child: child);
+
+StatefulShellRouteBuilder mockStackedShellBuilder = (BuildContext context,
+ GoRouterState state, StatefulNavigationShell navigationShell) {
+ return navigationShell;
+};
+
+RouteMatch createRouteMatch(RouteBase route, String location) {
+ return RouteMatch(
+ route: route,
+ matchedLocation: location,
+ extra: null,
+ error: null,
+ pageKey: ValueKey<String>(location),
+ );
+}