diff --git a/packages/go_router/CHANGELOG.md b/packages/go_router/CHANGELOG.md index e551d260067c..36d5e8401876 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 000000000000..35021297dfca --- /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 _rootNavigatorKey = + GlobalKey(debugLabel: 'root'); +final GlobalKey _shellNavigatorKey = + GlobalKey(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: [ + /// 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: [ + /// 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: [ + // 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: [ + // 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: [ + // 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( + 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 a7bbc87a3b58..bcd79c48c136 100644 --- a/packages/go_router/lib/src/builder.dart +++ b/packages/go_router/lib/src/builder.dart @@ -377,6 +377,7 @@ class _CustomNavigatorState extends State<_CustomNavigator> { pathParameters: matchList.pathParameters, error: matchList.error, pageKey: ValueKey('${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 e7ab54c0ed6b..09764747aae7 100644 --- a/packages/go_router/lib/src/configuration.dart +++ b/packages/go_router/lib/src/configuration.dart @@ -216,6 +216,7 @@ class RouteConfiguration { matchedLocation: matchList.uri.path, extra: matchList.extra, pageKey: const ValueKey('topLevel'), + topRoute: matchList.lastOrNull?.route, ); } diff --git a/packages/go_router/lib/src/match.dart b/packages/go_router/lib/src/match.dart index fd8361026f68..fba08f838656 100644 --- a/packages/go_router/lib/src/match.dart +++ b/packages/go_router/lib/src/match.dart @@ -325,6 +325,7 @@ class RouteMatch extends RouteMatchBase { name: route.name, path: route.path, extra: matches.extra, + topRoute: matches.lastOrNull?.route, ); } } @@ -382,6 +383,7 @@ class ShellRouteMatch extends RouteMatchBase { pathParameters: matches.pathParameters, pageKey: pageKey, extra: matches.extra, + topRoute: matches.lastOrNull?.route, ); } @@ -720,6 +722,8 @@ 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; @@ -727,6 +731,18 @@ class RouteMatchList with Diagnosticable { 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 2a0e3b0eb629..09f1cceaf1d0 100644 --- a/packages/go_router/lib/src/state.dart +++ b/packages/go_router/lib/src/state.dart @@ -7,6 +7,7 @@ import 'package:meta/meta.dart'; import 'configuration.dart'; import 'misc/errors.dart'; +import 'route.dart'; /// The route state during routing. /// @@ -25,6 +26,7 @@ class GoRouterState { this.extra, this.error, required this.pageKey, + this.topRoute, }); final RouteConfiguration _configuration; @@ -74,6 +76,13 @@ class GoRouterState { /// ``` final ValueKey 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 7ecbbbc2a0b0..770107232ee2 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 f27eb175cb3d..7a1b73dd93e6 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 @@ void main() { expect(registry.registry.length, 1); expect(find.byKey(key), findsNothing); }); + + testWidgets('GoRouterState topRoute accessible from StatefulShellRoute', + (WidgetTester tester) async { + final GlobalKey rootNavigatorKey = + GlobalKey(); + final GlobalKey shellNavigatorKey = + GlobalKey(); + final List routes = [ + ShellRoute( + navigatorKey: shellNavigatorKey, + builder: (BuildContext context, GoRouterState state, Widget child) { + return Scaffold( + body: Column( + children: [ + const Text('Screen 0'), + Expanded(child: child), + ], + ), + ); + }, + routes: [ + GoRoute( + name: 'root', + path: '/', + builder: (BuildContext context, GoRouterState state) { + return const Scaffold( + body: Text('Screen 1'), + ); + }, + routes: [ + 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: [ + Text(title), + Expanded(child: navigationShell), + ], + ); + }, + branches: [ + StatefulShellBranch( + routes: [ + GoRoute( + name: 'a', + path: 'a', + builder: (BuildContext context, GoRouterState state) { + return const Scaffold( + body: Text('Screen 2'), + ); + }, + ), + ], + ), + StatefulShellBranch( + routes: [ + 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); + }); }); }