Skip to content

Commit

Permalink
[go_router] Add topRoute to GoRouterState (#5736)
Browse files Browse the repository at this point in the history
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: #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].*
  • Loading branch information
getBoolean committed Jan 28, 2024
1 parent 35ac30e commit 516648a
Show file tree
Hide file tree
Showing 8 changed files with 431 additions and 1 deletion.
5 changes: 5 additions & 0 deletions 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.
Expand Down
305 changes: 305 additions & 0 deletions 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,
),
),
);
}
}
1 change: 1 addition & 0 deletions packages/go_router/lib/src/builder.dart
Expand Up @@ -377,6 +377,7 @@ class _CustomNavigatorState extends State<_CustomNavigator> {
pathParameters: matchList.pathParameters,
error: matchList.error,
pageKey: ValueKey<String>('${matchList.uri}(error)'),
topRoute: matchList.lastOrNull?.route,
);
}

Expand Down
1 change: 1 addition & 0 deletions packages/go_router/lib/src/configuration.dart
Expand Up @@ -216,6 +216,7 @@ class RouteConfiguration {
matchedLocation: matchList.uri.path,
extra: matchList.extra,
pageKey: const ValueKey<String>('topLevel'),
topRoute: matchList.lastOrNull?.route,
);
}

Expand Down
16 changes: 16 additions & 0 deletions packages/go_router/lib/src/match.dart
Expand Up @@ -325,6 +325,7 @@ class RouteMatch extends RouteMatchBase {
name: route.name,
path: route.path,
extra: matches.extra,
topRoute: matches.lastOrNull?.route,
);
}
}
Expand Down Expand Up @@ -382,6 +383,7 @@ class ShellRouteMatch extends RouteMatchBase {
pathParameters: matches.pathParameters,
pageKey: pageKey,
extra: matches.extra,
topRoute: matches.lastOrNull?.route,
);
}

Expand Down Expand Up @@ -720,13 +722,27 @@ class RouteMatchList with Diagnosticable {
/// 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;
}
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;

Expand Down

0 comments on commit 516648a

Please sign in to comment.