[go_router] Add `topRoute` to `GoRouterState` (#5736)
This PR exposes the current top route to `GoRouterState`, this allows `ShellRoute` to know what is the current child and process the state accordingly.
- Issue: https://github.com/flutter/flutter/issues/140297
This could be used like this, given that each `GoRoute` had the `name` parameter given
```dart
StatefulShellRoute.indexedStack(
parentNavigatorKey: rootNavigatorKey,
builder: (
BuildContext context,
GoRouterState state,
StatefulNavigationShell navigationShell,
) {
final String? routeName =
GoRouterState.of(context).topRoute.name;
final String title = switch (routeName) {
'a' => 'A',
'b' => 'B',
_ => 'Unknown',
};
return Column(
children: <Widget>[
Text(title),
Expanded(child: navigationShell),
],
);
},
...
}
```
*If you had to change anything in the [flutter/tests] repo, include a link to the migration guide as per the [breaking change policy].*
diff --git a/packages/go_router/CHANGELOG.md b/packages/go_router/CHANGELOG.md
index e551d26..36d5e84 100644
--- a/packages/go_router/CHANGELOG.md
+++ b/packages/go_router/CHANGELOG.md
@@ -1,3 +1,8 @@
+## 13.1.0
+
+- Adds `topRoute` to `GoRouterState`
+- Adds `lastOrNull` to `RouteMatchList`
+
## 13.0.1
* Fixes new lint warnings.
diff --git a/packages/go_router/example/lib/shell_route_top_route.dart b/packages/go_router/example/lib/shell_route_top_route.dart
new file mode 100644
index 0000000..3502129
--- /dev/null
+++ b/packages/go_router/example/lib/shell_route_top_route.dart
@@ -0,0 +1,305 @@
+// 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> _shellNavigatorKey =
+ GlobalKey<NavigatorState>(debugLabel: 'shell');
+
+// This scenario demonstrates how to set up nested navigation using ShellRoute,
+// which is a pattern where an additional Navigator is placed in the widget tree
+// to be used instead of the root navigator. This allows deep-links to display
+// pages along with other UI components such as a BottomNavigationBar.
+//
+// This example demonstrates how use topRoute in a ShellRoute to create the
+// title in the AppBar above the child, which is different for each GoRoute.
+
+void main() {
+ runApp(ShellRouteExampleApp());
+}
+
+/// An example demonstrating how to use [ShellRoute]
+class ShellRouteExampleApp extends StatelessWidget {
+ /// Creates a [ShellRouteExampleApp]
+ ShellRouteExampleApp({super.key});
+
+ final GoRouter _router = GoRouter(
+ navigatorKey: _rootNavigatorKey,
+ initialLocation: '/a',
+ debugLogDiagnostics: true,
+ routes: <RouteBase>[
+ /// Application shell
+ ShellRoute(
+ navigatorKey: _shellNavigatorKey,
+ builder: (BuildContext context, GoRouterState state, Widget child) {
+ final String? routeName = GoRouterState.of(context).topRoute?.name;
+ // This title could also be created using a route's path parameters in GoRouterState
+ final String title = switch (routeName) {
+ 'a' => 'A Screen',
+ 'a.details' => 'A Details',
+ 'b' => 'B Screen',
+ 'b.details' => 'B Details',
+ 'c' => 'C Screen',
+ 'c.details' => 'C Details',
+ _ => 'Unknown',
+ };
+ return ScaffoldWithNavBar(title: title, child: child);
+ },
+ routes: <RouteBase>[
+ /// The first screen to display in the bottom navigation bar.
+ GoRoute(
+ // The name of this route used to determine the title in the ShellRoute.
+ name: 'a',
+ path: '/a',
+ builder: (BuildContext context, GoRouterState state) {
+ return const ScreenA();
+ },
+ routes: <RouteBase>[
+ // The details screen to display stacked on the inner Navigator.
+ // This will cover screen A but not the application shell.
+ GoRoute(
+ // The name of this route used to determine the title in the ShellRoute.
+ name: 'a.details',
+ path: 'details',
+ builder: (BuildContext context, GoRouterState state) {
+ return const DetailsScreen(label: 'A');
+ },
+ ),
+ ],
+ ),
+
+ /// Displayed when the second item in the the bottom navigation bar is
+ /// selected.
+ GoRoute(
+ // The name of this route used to determine the title in the ShellRoute.
+ name: 'b',
+ path: '/b',
+ builder: (BuildContext context, GoRouterState state) {
+ return const ScreenB();
+ },
+ routes: <RouteBase>[
+ // The details screen to display stacked on the inner Navigator.
+ // This will cover screen B but not the application shell.
+ GoRoute(
+ // The name of this route used to determine the title in the ShellRoute.
+ name: 'b.details',
+ path: 'details',
+ builder: (BuildContext context, GoRouterState state) {
+ return const DetailsScreen(label: 'B');
+ },
+ ),
+ ],
+ ),
+
+ /// The third screen to display in the bottom navigation bar.
+ GoRoute(
+ // The name of this route used to determine the title in the ShellRoute.
+ name: 'c',
+ path: '/c',
+ builder: (BuildContext context, GoRouterState state) {
+ return const ScreenC();
+ },
+ routes: <RouteBase>[
+ // The details screen to display stacked on the inner Navigator.
+ // This will cover screen C but not the application shell.
+ GoRoute(
+ // The name of this route used to determine the title in the ShellRoute.
+ name: 'c.details',
+ path: 'details',
+ builder: (BuildContext context, GoRouterState state) {
+ return const DetailsScreen(label: 'C');
+ },
+ ),
+ ],
+ ),
+ ],
+ ),
+ ],
+ );
+
+ @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({
+ super.key,
+ required this.title,
+ required this.child,
+ });
+
+ /// The title to display in the AppBar.
+ final String title;
+
+ /// The widget to display in the body of the Scaffold.
+ /// In this sample, it is a Navigator.
+ final Widget child;
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ body: child,
+ appBar: AppBar(
+ title: Text(title),
+ leading: _buildLeadingButton(context),
+ ),
+ bottomNavigationBar: BottomNavigationBar(
+ items: const <BottomNavigationBarItem>[
+ BottomNavigationBarItem(
+ icon: Icon(Icons.home),
+ label: 'A Screen',
+ ),
+ BottomNavigationBarItem(
+ icon: Icon(Icons.business),
+ label: 'B Screen',
+ ),
+ BottomNavigationBarItem(
+ icon: Icon(Icons.notification_important_rounded),
+ label: 'C Screen',
+ ),
+ ],
+ currentIndex: _calculateSelectedIndex(context),
+ onTap: (int idx) => _onItemTapped(idx, context),
+ ),
+ );
+ }
+
+ /// Builds the app bar leading button using the current location [Uri].
+ ///
+ /// The [Scaffold]'s default back button cannot be used because it doesn't
+ /// have the context of the current child.
+ Widget? _buildLeadingButton(BuildContext context) {
+ final RouteMatchList currentConfiguration =
+ GoRouter.of(context).routerDelegate.currentConfiguration;
+ final RouteMatch lastMatch = currentConfiguration.last;
+ final Uri location = lastMatch is ImperativeRouteMatch
+ ? lastMatch.matches.uri
+ : currentConfiguration.uri;
+ final bool canPop = location.pathSegments.length > 1;
+ return canPop ? BackButton(onPressed: GoRouter.of(context).pop) : null;
+ }
+
+ static int _calculateSelectedIndex(BuildContext context) {
+ final String location = GoRouterState.of(context).uri.toString();
+ if (location.startsWith('/a')) {
+ return 0;
+ }
+ if (location.startsWith('/b')) {
+ return 1;
+ }
+ if (location.startsWith('/c')) {
+ return 2;
+ }
+ return 0;
+ }
+
+ void _onItemTapped(int index, BuildContext context) {
+ switch (index) {
+ case 0:
+ GoRouter.of(context).go('/a');
+ case 1:
+ GoRouter.of(context).go('/b');
+ case 2:
+ GoRouter.of(context).go('/c');
+ }
+ }
+}
+
+/// The first screen in the bottom navigation bar.
+class ScreenA extends StatelessWidget {
+ /// Constructs a [ScreenA] widget.
+ const ScreenA({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ body: Center(
+ child: TextButton(
+ onPressed: () {
+ GoRouter.of(context).go('/a/details');
+ },
+ child: const Text('View A details'),
+ ),
+ ),
+ );
+ }
+}
+
+/// The second screen in the bottom navigation bar.
+class ScreenB extends StatelessWidget {
+ /// Constructs a [ScreenB] widget.
+ const ScreenB({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ body: Center(
+ child: TextButton(
+ onPressed: () {
+ GoRouter.of(context).go('/b/details');
+ },
+ child: const Text('View B details'),
+ ),
+ ),
+ );
+ }
+}
+
+/// The third screen in the bottom navigation bar.
+class ScreenC extends StatelessWidget {
+ /// Constructs a [ScreenC] widget.
+ const ScreenC({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ body: Center(
+ child: TextButton(
+ onPressed: () {
+ GoRouter.of(context).go('/c/details');
+ },
+ child: const Text('View C details'),
+ ),
+ ),
+ );
+ }
+}
+
+/// The details screen for either the A, B or C screen.
+class DetailsScreen extends StatelessWidget {
+ /// Constructs a [DetailsScreen].
+ const DetailsScreen({
+ required this.label,
+ super.key,
+ });
+
+ /// The label to display in the center of the screen.
+ final String label;
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ body: Center(
+ child: Text(
+ 'Details for $label',
+ style: Theme.of(context).textTheme.headlineMedium,
+ ),
+ ),
+ );
+ }
+}
diff --git a/packages/go_router/lib/src/builder.dart b/packages/go_router/lib/src/builder.dart
index a7bbc87..bcd79c4 100644
--- a/packages/go_router/lib/src/builder.dart
+++ b/packages/go_router/lib/src/builder.dart
@@ -377,6 +377,7 @@
pathParameters: matchList.pathParameters,
error: matchList.error,
pageKey: ValueKey<String>('${matchList.uri}(error)'),
+ topRoute: matchList.lastOrNull?.route,
);
}
diff --git a/packages/go_router/lib/src/configuration.dart b/packages/go_router/lib/src/configuration.dart
index e7ab54c..0976474 100644
--- a/packages/go_router/lib/src/configuration.dart
+++ b/packages/go_router/lib/src/configuration.dart
@@ -216,6 +216,7 @@
matchedLocation: matchList.uri.path,
extra: matchList.extra,
pageKey: const ValueKey<String>('topLevel'),
+ topRoute: matchList.lastOrNull?.route,
);
}
diff --git a/packages/go_router/lib/src/match.dart b/packages/go_router/lib/src/match.dart
index fd83610..fba08f8 100644
--- a/packages/go_router/lib/src/match.dart
+++ b/packages/go_router/lib/src/match.dart
@@ -325,6 +325,7 @@
name: route.name,
path: route.path,
extra: matches.extra,
+ topRoute: matches.lastOrNull?.route,
);
}
}
@@ -382,6 +383,7 @@
pathParameters: matches.pathParameters,
pageKey: pageKey,
extra: matches.extra,
+ topRoute: matches.lastOrNull?.route,
);
}
@@ -720,6 +722,8 @@
/// If the last RouteMatchBase from [matches] is a ShellRouteMatch, it
/// recursively goes into its [ShellRouteMatch.matches] until it reach the leaf
/// [RouteMatch].
+ ///
+ /// Throws a [StateError] if [matches] is empty.
RouteMatch get last {
if (matches.last is RouteMatch) {
return matches.last as RouteMatch;
@@ -727,6 +731,18 @@
return (matches.last as ShellRouteMatch)._lastLeaf;
}
+ /// The last leaf route or null if [matches] is empty
+ ///
+ /// If the last RouteMatchBase from [matches] is a ShellRouteMatch, it
+ /// recursively goes into its [ShellRouteMatch.matches] until it reach the leaf
+ /// [RouteMatch].
+ RouteMatch? get lastOrNull {
+ if (matches.isEmpty) {
+ return null;
+ }
+ return last;
+ }
+
/// Returns true if the current match intends to display an error screen.
bool get isError => error != null;
diff --git a/packages/go_router/lib/src/state.dart b/packages/go_router/lib/src/state.dart
index 2a0e3b0..09f1cce 100644
--- a/packages/go_router/lib/src/state.dart
+++ b/packages/go_router/lib/src/state.dart
@@ -7,6 +7,7 @@
import 'configuration.dart';
import 'misc/errors.dart';
+import 'route.dart';
/// The route state during routing.
///
@@ -25,6 +26,7 @@
this.extra,
this.error,
required this.pageKey,
+ this.topRoute,
});
final RouteConfiguration _configuration;
@@ -74,6 +76,13 @@
/// ```
final ValueKey<String> pageKey;
+ /// The current matched top route associated with this state.
+ ///
+ /// If this state represents a [ShellRoute], the top [GoRoute] will be the current
+ /// matched location associated with the [ShellRoute]. This allows the [ShellRoute]'s
+ /// associated GoRouterState to be uniquely identified using [GoRoute.name]
+ final GoRoute? topRoute;
+
/// Gets the [GoRouterState] from context.
///
/// The returned [GoRouterState] will depends on which [GoRoute] or
diff --git a/packages/go_router/pubspec.yaml b/packages/go_router/pubspec.yaml
index 7ecbbbc..7701072 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: 13.0.1
+version: 13.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/go_router_state_test.dart b/packages/go_router/test/go_router_state_test.dart
index f27eb17..7a1b73d 100644
--- a/packages/go_router/test/go_router_state_test.dart
+++ b/packages/go_router/test/go_router_state_test.dart
@@ -186,5 +186,98 @@
expect(registry.registry.length, 1);
expect(find.byKey(key), findsNothing);
});
+
+ testWidgets('GoRouterState topRoute accessible from StatefulShellRoute',
+ (WidgetTester tester) async {
+ final GlobalKey<NavigatorState> rootNavigatorKey =
+ GlobalKey<NavigatorState>();
+ final GlobalKey<NavigatorState> shellNavigatorKey =
+ GlobalKey<NavigatorState>();
+ final List<RouteBase> routes = <RouteBase>[
+ ShellRoute(
+ navigatorKey: shellNavigatorKey,
+ builder: (BuildContext context, GoRouterState state, Widget child) {
+ return Scaffold(
+ body: Column(
+ children: <Widget>[
+ const Text('Screen 0'),
+ Expanded(child: child),
+ ],
+ ),
+ );
+ },
+ routes: <RouteBase>[
+ GoRoute(
+ name: 'root',
+ path: '/',
+ builder: (BuildContext context, GoRouterState state) {
+ return const Scaffold(
+ body: Text('Screen 1'),
+ );
+ },
+ routes: <RouteBase>[
+ StatefulShellRoute.indexedStack(
+ parentNavigatorKey: rootNavigatorKey,
+ builder: (
+ BuildContext context,
+ GoRouterState state,
+ StatefulNavigationShell navigationShell,
+ ) {
+ final String? routeName =
+ GoRouterState.of(context).topRoute?.name;
+ final String title = switch (routeName) {
+ 'a' => 'A',
+ 'b' => 'B',
+ _ => 'Unknown',
+ };
+ return Column(
+ children: <Widget>[
+ Text(title),
+ Expanded(child: navigationShell),
+ ],
+ );
+ },
+ branches: <StatefulShellBranch>[
+ StatefulShellBranch(
+ routes: <RouteBase>[
+ GoRoute(
+ name: 'a',
+ path: 'a',
+ builder: (BuildContext context, GoRouterState state) {
+ return const Scaffold(
+ body: Text('Screen 2'),
+ );
+ },
+ ),
+ ],
+ ),
+ StatefulShellBranch(
+ routes: <RouteBase>[
+ GoRoute(
+ name: 'b',
+ path: 'b',
+ builder: (BuildContext context, GoRouterState state) {
+ return const Scaffold(
+ body: Text('Screen 2'),
+ );
+ },
+ ),
+ ],
+ )
+ ],
+ ),
+ ],
+ )
+ ],
+ ),
+ ];
+ final GoRouter router = await createRouter(routes, tester,
+ initialLocation: '/a', navigatorKey: rootNavigatorKey);
+ expect(find.text('A'), findsOneWidget);
+
+ router.go('/b');
+ await tester.pumpAndSettle();
+ expect(find.text('B'), findsOneWidget);
+ });
});
}