Skip to content

Commit 516648a

Browse files
authored
[go_router] Add topRoute to GoRouterState (flutter#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: flutter#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].*
1 parent 35ac30e commit 516648a

File tree

8 files changed

+431
-1
lines changed

8 files changed

+431
-1
lines changed

packages/go_router/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
## 13.1.0
2+
3+
- Adds `topRoute` to `GoRouterState`
4+
- Adds `lastOrNull` to `RouteMatchList`
5+
16
## 13.0.1
27

38
* Fixes new lint warnings.
Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
// Copyright 2013 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter/material.dart';
6+
import 'package:go_router/go_router.dart';
7+
8+
final GlobalKey<NavigatorState> _rootNavigatorKey =
9+
GlobalKey<NavigatorState>(debugLabel: 'root');
10+
final GlobalKey<NavigatorState> _shellNavigatorKey =
11+
GlobalKey<NavigatorState>(debugLabel: 'shell');
12+
13+
// This scenario demonstrates how to set up nested navigation using ShellRoute,
14+
// which is a pattern where an additional Navigator is placed in the widget tree
15+
// to be used instead of the root navigator. This allows deep-links to display
16+
// pages along with other UI components such as a BottomNavigationBar.
17+
//
18+
// This example demonstrates how use topRoute in a ShellRoute to create the
19+
// title in the AppBar above the child, which is different for each GoRoute.
20+
21+
void main() {
22+
runApp(ShellRouteExampleApp());
23+
}
24+
25+
/// An example demonstrating how to use [ShellRoute]
26+
class ShellRouteExampleApp extends StatelessWidget {
27+
/// Creates a [ShellRouteExampleApp]
28+
ShellRouteExampleApp({super.key});
29+
30+
final GoRouter _router = GoRouter(
31+
navigatorKey: _rootNavigatorKey,
32+
initialLocation: '/a',
33+
debugLogDiagnostics: true,
34+
routes: <RouteBase>[
35+
/// Application shell
36+
ShellRoute(
37+
navigatorKey: _shellNavigatorKey,
38+
builder: (BuildContext context, GoRouterState state, Widget child) {
39+
final String? routeName = GoRouterState.of(context).topRoute?.name;
40+
// This title could also be created using a route's path parameters in GoRouterState
41+
final String title = switch (routeName) {
42+
'a' => 'A Screen',
43+
'a.details' => 'A Details',
44+
'b' => 'B Screen',
45+
'b.details' => 'B Details',
46+
'c' => 'C Screen',
47+
'c.details' => 'C Details',
48+
_ => 'Unknown',
49+
};
50+
return ScaffoldWithNavBar(title: title, child: child);
51+
},
52+
routes: <RouteBase>[
53+
/// The first screen to display in the bottom navigation bar.
54+
GoRoute(
55+
// The name of this route used to determine the title in the ShellRoute.
56+
name: 'a',
57+
path: '/a',
58+
builder: (BuildContext context, GoRouterState state) {
59+
return const ScreenA();
60+
},
61+
routes: <RouteBase>[
62+
// The details screen to display stacked on the inner Navigator.
63+
// This will cover screen A but not the application shell.
64+
GoRoute(
65+
// The name of this route used to determine the title in the ShellRoute.
66+
name: 'a.details',
67+
path: 'details',
68+
builder: (BuildContext context, GoRouterState state) {
69+
return const DetailsScreen(label: 'A');
70+
},
71+
),
72+
],
73+
),
74+
75+
/// Displayed when the second item in the the bottom navigation bar is
76+
/// selected.
77+
GoRoute(
78+
// The name of this route used to determine the title in the ShellRoute.
79+
name: 'b',
80+
path: '/b',
81+
builder: (BuildContext context, GoRouterState state) {
82+
return const ScreenB();
83+
},
84+
routes: <RouteBase>[
85+
// The details screen to display stacked on the inner Navigator.
86+
// This will cover screen B but not the application shell.
87+
GoRoute(
88+
// The name of this route used to determine the title in the ShellRoute.
89+
name: 'b.details',
90+
path: 'details',
91+
builder: (BuildContext context, GoRouterState state) {
92+
return const DetailsScreen(label: 'B');
93+
},
94+
),
95+
],
96+
),
97+
98+
/// The third screen to display in the bottom navigation bar.
99+
GoRoute(
100+
// The name of this route used to determine the title in the ShellRoute.
101+
name: 'c',
102+
path: '/c',
103+
builder: (BuildContext context, GoRouterState state) {
104+
return const ScreenC();
105+
},
106+
routes: <RouteBase>[
107+
// The details screen to display stacked on the inner Navigator.
108+
// This will cover screen C but not the application shell.
109+
GoRoute(
110+
// The name of this route used to determine the title in the ShellRoute.
111+
name: 'c.details',
112+
path: 'details',
113+
builder: (BuildContext context, GoRouterState state) {
114+
return const DetailsScreen(label: 'C');
115+
},
116+
),
117+
],
118+
),
119+
],
120+
),
121+
],
122+
);
123+
124+
@override
125+
Widget build(BuildContext context) {
126+
return MaterialApp.router(
127+
title: 'Flutter Demo',
128+
theme: ThemeData(
129+
primarySwatch: Colors.blue,
130+
),
131+
routerConfig: _router,
132+
);
133+
}
134+
}
135+
136+
/// Builds the "shell" for the app by building a Scaffold with a
137+
/// BottomNavigationBar, where [child] is placed in the body of the Scaffold.
138+
class ScaffoldWithNavBar extends StatelessWidget {
139+
/// Constructs an [ScaffoldWithNavBar].
140+
const ScaffoldWithNavBar({
141+
super.key,
142+
required this.title,
143+
required this.child,
144+
});
145+
146+
/// The title to display in the AppBar.
147+
final String title;
148+
149+
/// The widget to display in the body of the Scaffold.
150+
/// In this sample, it is a Navigator.
151+
final Widget child;
152+
153+
@override
154+
Widget build(BuildContext context) {
155+
return Scaffold(
156+
body: child,
157+
appBar: AppBar(
158+
title: Text(title),
159+
leading: _buildLeadingButton(context),
160+
),
161+
bottomNavigationBar: BottomNavigationBar(
162+
items: const <BottomNavigationBarItem>[
163+
BottomNavigationBarItem(
164+
icon: Icon(Icons.home),
165+
label: 'A Screen',
166+
),
167+
BottomNavigationBarItem(
168+
icon: Icon(Icons.business),
169+
label: 'B Screen',
170+
),
171+
BottomNavigationBarItem(
172+
icon: Icon(Icons.notification_important_rounded),
173+
label: 'C Screen',
174+
),
175+
],
176+
currentIndex: _calculateSelectedIndex(context),
177+
onTap: (int idx) => _onItemTapped(idx, context),
178+
),
179+
);
180+
}
181+
182+
/// Builds the app bar leading button using the current location [Uri].
183+
///
184+
/// The [Scaffold]'s default back button cannot be used because it doesn't
185+
/// have the context of the current child.
186+
Widget? _buildLeadingButton(BuildContext context) {
187+
final RouteMatchList currentConfiguration =
188+
GoRouter.of(context).routerDelegate.currentConfiguration;
189+
final RouteMatch lastMatch = currentConfiguration.last;
190+
final Uri location = lastMatch is ImperativeRouteMatch
191+
? lastMatch.matches.uri
192+
: currentConfiguration.uri;
193+
final bool canPop = location.pathSegments.length > 1;
194+
return canPop ? BackButton(onPressed: GoRouter.of(context).pop) : null;
195+
}
196+
197+
static int _calculateSelectedIndex(BuildContext context) {
198+
final String location = GoRouterState.of(context).uri.toString();
199+
if (location.startsWith('/a')) {
200+
return 0;
201+
}
202+
if (location.startsWith('/b')) {
203+
return 1;
204+
}
205+
if (location.startsWith('/c')) {
206+
return 2;
207+
}
208+
return 0;
209+
}
210+
211+
void _onItemTapped(int index, BuildContext context) {
212+
switch (index) {
213+
case 0:
214+
GoRouter.of(context).go('/a');
215+
case 1:
216+
GoRouter.of(context).go('/b');
217+
case 2:
218+
GoRouter.of(context).go('/c');
219+
}
220+
}
221+
}
222+
223+
/// The first screen in the bottom navigation bar.
224+
class ScreenA extends StatelessWidget {
225+
/// Constructs a [ScreenA] widget.
226+
const ScreenA({super.key});
227+
228+
@override
229+
Widget build(BuildContext context) {
230+
return Scaffold(
231+
body: Center(
232+
child: TextButton(
233+
onPressed: () {
234+
GoRouter.of(context).go('/a/details');
235+
},
236+
child: const Text('View A details'),
237+
),
238+
),
239+
);
240+
}
241+
}
242+
243+
/// The second screen in the bottom navigation bar.
244+
class ScreenB extends StatelessWidget {
245+
/// Constructs a [ScreenB] widget.
246+
const ScreenB({super.key});
247+
248+
@override
249+
Widget build(BuildContext context) {
250+
return Scaffold(
251+
body: Center(
252+
child: TextButton(
253+
onPressed: () {
254+
GoRouter.of(context).go('/b/details');
255+
},
256+
child: const Text('View B details'),
257+
),
258+
),
259+
);
260+
}
261+
}
262+
263+
/// The third screen in the bottom navigation bar.
264+
class ScreenC extends StatelessWidget {
265+
/// Constructs a [ScreenC] widget.
266+
const ScreenC({super.key});
267+
268+
@override
269+
Widget build(BuildContext context) {
270+
return Scaffold(
271+
body: Center(
272+
child: TextButton(
273+
onPressed: () {
274+
GoRouter.of(context).go('/c/details');
275+
},
276+
child: const Text('View C details'),
277+
),
278+
),
279+
);
280+
}
281+
}
282+
283+
/// The details screen for either the A, B or C screen.
284+
class DetailsScreen extends StatelessWidget {
285+
/// Constructs a [DetailsScreen].
286+
const DetailsScreen({
287+
required this.label,
288+
super.key,
289+
});
290+
291+
/// The label to display in the center of the screen.
292+
final String label;
293+
294+
@override
295+
Widget build(BuildContext context) {
296+
return Scaffold(
297+
body: Center(
298+
child: Text(
299+
'Details for $label',
300+
style: Theme.of(context).textTheme.headlineMedium,
301+
),
302+
),
303+
);
304+
}
305+
}

packages/go_router/lib/src/builder.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,7 @@ class _CustomNavigatorState extends State<_CustomNavigator> {
377377
pathParameters: matchList.pathParameters,
378378
error: matchList.error,
379379
pageKey: ValueKey<String>('${matchList.uri}(error)'),
380+
topRoute: matchList.lastOrNull?.route,
380381
);
381382
}
382383

packages/go_router/lib/src/configuration.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ class RouteConfiguration {
216216
matchedLocation: matchList.uri.path,
217217
extra: matchList.extra,
218218
pageKey: const ValueKey<String>('topLevel'),
219+
topRoute: matchList.lastOrNull?.route,
219220
);
220221
}
221222

packages/go_router/lib/src/match.dart

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,7 @@ class RouteMatch extends RouteMatchBase {
325325
name: route.name,
326326
path: route.path,
327327
extra: matches.extra,
328+
topRoute: matches.lastOrNull?.route,
328329
);
329330
}
330331
}
@@ -382,6 +383,7 @@ class ShellRouteMatch extends RouteMatchBase {
382383
pathParameters: matches.pathParameters,
383384
pageKey: pageKey,
384385
extra: matches.extra,
386+
topRoute: matches.lastOrNull?.route,
385387
);
386388
}
387389

@@ -720,13 +722,27 @@ class RouteMatchList with Diagnosticable {
720722
/// If the last RouteMatchBase from [matches] is a ShellRouteMatch, it
721723
/// recursively goes into its [ShellRouteMatch.matches] until it reach the leaf
722724
/// [RouteMatch].
725+
///
726+
/// Throws a [StateError] if [matches] is empty.
723727
RouteMatch get last {
724728
if (matches.last is RouteMatch) {
725729
return matches.last as RouteMatch;
726730
}
727731
return (matches.last as ShellRouteMatch)._lastLeaf;
728732
}
729733

734+
/// The last leaf route or null if [matches] is empty
735+
///
736+
/// If the last RouteMatchBase from [matches] is a ShellRouteMatch, it
737+
/// recursively goes into its [ShellRouteMatch.matches] until it reach the leaf
738+
/// [RouteMatch].
739+
RouteMatch? get lastOrNull {
740+
if (matches.isEmpty) {
741+
return null;
742+
}
743+
return last;
744+
}
745+
730746
/// Returns true if the current match intends to display an error screen.
731747
bool get isError => error != null;
732748

0 commit comments

Comments
 (0)