diff --git a/packages/go_router/CHANGELOG.md b/packages/go_router/CHANGELOG.md index fee39de46aeb..9d70c491e7a4 100644 --- a/packages/go_router/CHANGELOG.md +++ b/packages/go_router/CHANGELOG.md @@ -1,3 +1,7 @@ +## 18.1.0 + +- Adds `clipBehavior` to `ShellRoute` and `StatefulShellBranch`, forwarded to the nested `Navigator`. Set it to `Clip.none` to let sub-routes paint outside the bounds of the shell, for example to render a box shadow. + ## 18.0.0 - Migrates to material_ui and cupertino_ui. diff --git a/packages/go_router/lib/src/builder.dart b/packages/go_router/lib/src/builder.dart index e58662646681..bef8098cf26a 100644 --- a/packages/go_router/lib/src/builder.dart +++ b/packages/go_router/lib/src/builder.dart @@ -118,6 +118,7 @@ class RouteBuilder { errorBuilder: errorBuilder, errorPageBuilder: errorPageBuilder, requestFocus: requestFocus, + clipBehavior: Clip.hardEdge, ), ); } @@ -137,6 +138,7 @@ class _CustomNavigator extends StatefulWidget { required this.errorBuilder, required this.errorPageBuilder, required this.requestFocus, + required this.clipBehavior, }); final GlobalKey navigatorKey; @@ -157,6 +159,9 @@ class _CustomNavigator extends StatefulWidget { final GoRouterPageBuilder? errorPageBuilder; final bool requestFocus; + /// The clip behavior forwarded to the [Navigator] built by this widget. + final Clip clipBehavior; + @override State createState() => _CustomNavigatorState(); } @@ -292,8 +297,9 @@ class _CustomNavigatorState extends State<_CustomNavigator> { ShellRouteMatch match, RouteMatchList matchList, List? observers, - String? restorationScopeId, - ) { + String? restorationScopeId, { + Clip clipBehavior = Clip.hardEdge, + }) { return PopScope( // Prevent ShellRoute from being popped, for example // by an iOS back gesture, when the route has active sub-routes. @@ -315,6 +321,7 @@ class _CustomNavigatorState extends State<_CustomNavigator> { errorBuilder: widget.errorBuilder, errorPageBuilder: widget.errorPageBuilder, requestFocus: widget.requestFocus, + clipBehavior: clipBehavior, ), ); }, @@ -454,6 +461,7 @@ class _CustomNavigatorState extends State<_CustomNavigator> { pages: _pages!, observers: widget.observers, onPopPage: _handlePopPage, + clipBehavior: widget.clipBehavior, ), ), ); diff --git a/packages/go_router/lib/src/route.dart b/packages/go_router/lib/src/route.dart index 3f36a7967c6f..6b98be4f8cb0 100644 --- a/packages/go_router/lib/src/route.dart +++ b/packages/go_router/lib/src/route.dart @@ -56,8 +56,9 @@ typedef NavigatorBuilder = ShellRouteMatch match, RouteMatchList matchList, List? observers, - String? restorationScopeId, - ); + String? restorationScopeId, { + Clip clipBehavior, + }); /// Signature for function used in [RouteBase.onExit]. /// @@ -600,6 +601,7 @@ class ShellRouteContext { List? observers, bool notifyRootObserver, String? restorationScopeId, + Clip clipBehavior, ) { final effectiveObservers = [...?observers]; @@ -616,6 +618,7 @@ class ShellRouteContext { routeMatchList, effectiveObservers, restorationScopeId, + clipBehavior: clipBehavior, ); } } @@ -728,6 +731,7 @@ class ShellRoute extends ShellRouteBase { super.parentNavigatorKey, GlobalKey? navigatorKey, this.restorationScopeId, + this.clipBehavior = Clip.hardEdge, }) : assert(routes.isNotEmpty), navigatorKey = navigatorKey ?? GlobalKey(), super._() { @@ -765,6 +769,7 @@ class ShellRoute extends ShellRouteBase { observers, notifyRootObserver, restorationScopeId, + clipBehavior, ); return builder!(context, state, navigator); } @@ -783,6 +788,7 @@ class ShellRoute extends ShellRouteBase { observers, notifyRootObserver, restorationScopeId, + clipBehavior, ); return pageBuilder!(context, state, navigator); } @@ -804,6 +810,18 @@ class ShellRoute extends ShellRouteBase { /// its history. final String? restorationScopeId; + /// The clip behavior of the [Navigator] built for this route. + /// + /// The nested Navigator clips its contents by default, so that the route + /// transitions of its sub-routes are not painted outside the bounds the + /// shell lays out for them. Set this to [Clip.none] when a sub-route needs + /// to paint outside those bounds, for example to render a box shadow or an + /// overflowing menu. Note that this also allows route transition animations + /// to paint outside the bounds of the shell. + /// + /// Defaults to [Clip.hardEdge]. + final Clip clipBehavior; + @override GlobalKey navigatorKeyForSubRoute(RouteBase subRoute) { assert(routes.contains(subRoute)); @@ -1127,6 +1145,7 @@ class StatefulShellBranch { this.restorationScopeId, this.observers, this.preload = false, + this.clipBehavior = Clip.hardEdge, }) : navigatorKey = navigatorKey ?? GlobalKey() { assert(() { ShellRouteBase._debugCheckSubRouteParentNavigatorKeys(routes, this.navigatorKey); @@ -1162,6 +1181,16 @@ class StatefulShellBranch { /// The observers parameter is used by the [Navigator] built for this branch. final List? observers; + /// The clip behavior of the [Navigator] built for this branch. + /// + /// Each branch of a [StatefulShellRoute] builds its own [Navigator], so the + /// clip behavior is configured per branch rather than on the shell route. + /// + /// See [ShellRoute.clipBehavior] for a description of the behavior. + /// + /// Defaults to [Clip.hardEdge]. + final Clip clipBehavior; + /// Whether this route branch should be eagerly loaded when navigating to the /// associated StatefulShellRoute for the first time. /// @@ -1409,6 +1438,7 @@ class StatefulNavigationShellState extends State with R branch.observers, route.notifyRootObserver, branch.restorationScopeId, + branch.clipBehavior, ); } @@ -1438,6 +1468,7 @@ class StatefulNavigationShellState extends State with R matchList, branch.observers, branch.restorationScopeId, + clipBehavior: branch.clipBehavior, ); final _StatefulShellBranchState branchState = _branchStateFor(branch, false); diff --git a/packages/go_router/pubspec.yaml b/packages/go_router/pubspec.yaml index 2f36afd5095f..1bd36c35e39b 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: 18.0.0 +version: 18.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/shell_route_clip_behavior_test.dart b/packages/go_router/test/shell_route_clip_behavior_test.dart new file mode 100644 index 000000000000..7549a07d8664 --- /dev/null +++ b/packages/go_router/test/shell_route_clip_behavior_test.dart @@ -0,0 +1,148 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:material_ui/material_ui.dart'; + +import 'test_helpers.dart'; + +/// Reads the clip behavior of the [Navigator] identified by [navigatorKey]. +/// +/// Offstage widgets are included so that the Navigators of inactive +/// [StatefulShellBranch]es can be inspected too. +Clip clipBehaviorOf(WidgetTester tester, GlobalKey navigatorKey) => + tester.widget(find.byKey(navigatorKey, skipOffstage: false)).clipBehavior; + +void main() { + group('ShellRoute', () { + testWidgets('clips the nested Navigator by default', (WidgetTester tester) async { + final navigatorKey = GlobalKey(debugLabel: 'shell'); + await createRouter([ + ShellRoute( + navigatorKey: navigatorKey, + builder: (_, _, Widget child) => child, + routes: [GoRoute(path: '/', builder: (_, _) => const Text('Home'))], + ), + ], tester); + + expect(clipBehaviorOf(tester, navigatorKey), Clip.hardEdge); + }); + + testWidgets('forwards clipBehavior to the nested Navigator', (WidgetTester tester) async { + final navigatorKey = GlobalKey(debugLabel: 'shell'); + await createRouter([ + ShellRoute( + navigatorKey: navigatorKey, + clipBehavior: Clip.none, + builder: (_, _, Widget child) => child, + routes: [GoRoute(path: '/', builder: (_, _) => const Text('Home'))], + ), + ], tester); + + expect(clipBehaviorOf(tester, navigatorKey), Clip.none); + }); + + testWidgets('forwards clipBehavior to the nested Navigator when using pageBuilder', ( + WidgetTester tester, + ) async { + final navigatorKey = GlobalKey(debugLabel: 'shell'); + await createRouter([ + ShellRoute( + navigatorKey: navigatorKey, + clipBehavior: Clip.antiAlias, + pageBuilder: (_, _, Widget child) => MaterialPage(child: child), + routes: [GoRoute(path: '/', builder: (_, _) => const Text('Home'))], + ), + ], tester); + + expect(clipBehaviorOf(tester, navigatorKey), Clip.antiAlias); + }); + }); + + group('StatefulShellBranch', () { + testWidgets('clips the branch Navigator by default', (WidgetTester tester) async { + final navigatorKey = GlobalKey(debugLabel: 'branch'); + await createRouter([ + StatefulShellRoute.indexedStack( + builder: (_, _, StatefulNavigationShell shell) => shell, + branches: [ + StatefulShellBranch( + navigatorKey: navigatorKey, + routes: [GoRoute(path: '/', builder: (_, _) => const Text('A'))], + ), + ], + ), + ], tester); + + expect(clipBehaviorOf(tester, navigatorKey), Clip.hardEdge); + }); + + testWidgets('forwards clipBehavior per branch', (WidgetTester tester) async { + final keyA = GlobalKey(debugLabel: 'a'); + final keyB = GlobalKey(debugLabel: 'b'); + final root = GlobalKey(debugLabel: 'root'); + await createRouter( + [ + StatefulShellRoute.indexedStack( + builder: (_, _, StatefulNavigationShell shell) => shell, + branches: [ + StatefulShellBranch( + navigatorKey: keyA, + clipBehavior: Clip.none, + routes: [GoRoute(path: '/a', builder: (_, _) => const Text('A'))], + ), + StatefulShellBranch( + navigatorKey: keyB, + routes: [GoRoute(path: '/b', builder: (_, _) => const Text('B'))], + ), + ], + ), + ], + tester, + navigatorKey: root, + initialLocation: '/a', + ); + + expect(clipBehaviorOf(tester, keyA), Clip.none); + + root.currentContext!.go('/b'); + await tester.pumpAndSettle(); + + // Each branch keeps its own clip behavior; the loaded branches stay in + // the tree because StatefulShellRoute preserves their state. + expect(clipBehaviorOf(tester, keyA), Clip.none); + expect(clipBehaviorOf(tester, keyB), Clip.hardEdge); + }); + + testWidgets('forwards clipBehavior to preloaded branches', (WidgetTester tester) async { + final keyA = GlobalKey(debugLabel: 'a'); + final keyB = GlobalKey(debugLabel: 'b'); + await createRouter( + [ + StatefulShellRoute.indexedStack( + builder: (_, _, StatefulNavigationShell shell) => shell, + branches: [ + StatefulShellBranch( + navigatorKey: keyA, + routes: [GoRoute(path: '/a', builder: (_, _) => const Text('A'))], + ), + StatefulShellBranch( + navigatorKey: keyB, + preload: true, + clipBehavior: Clip.none, + routes: [GoRoute(path: '/b', builder: (_, _) => const Text('B'))], + ), + ], + ), + ], + tester, + initialLocation: '/a', + ); + await tester.pumpAndSettle(); + + expect(clipBehaviorOf(tester, keyB), Clip.none); + }); + }); +}