Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/go_router/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
12 changes: 10 additions & 2 deletions packages/go_router/lib/src/builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ class RouteBuilder {
errorBuilder: errorBuilder,
errorPageBuilder: errorPageBuilder,
requestFocus: requestFocus,
clipBehavior: Clip.hardEdge,
),
);
}
Expand All @@ -137,6 +138,7 @@ class _CustomNavigator extends StatefulWidget {
required this.errorBuilder,
required this.errorPageBuilder,
required this.requestFocus,
required this.clipBehavior,
});

final GlobalKey<NavigatorState> navigatorKey;
Expand All @@ -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<StatefulWidget> createState() => _CustomNavigatorState();
}
Expand Down Expand Up @@ -292,8 +297,9 @@ class _CustomNavigatorState extends State<_CustomNavigator> {
ShellRouteMatch match,
RouteMatchList matchList,
List<NavigatorObserver>? 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.
Expand All @@ -315,6 +321,7 @@ class _CustomNavigatorState extends State<_CustomNavigator> {
errorBuilder: widget.errorBuilder,
errorPageBuilder: widget.errorPageBuilder,
requestFocus: widget.requestFocus,
clipBehavior: clipBehavior,
),
);
},
Expand Down Expand Up @@ -454,6 +461,7 @@ class _CustomNavigatorState extends State<_CustomNavigator> {
pages: _pages!,
observers: widget.observers,
onPopPage: _handlePopPage,
clipBehavior: widget.clipBehavior,
),
),
);
Expand Down
35 changes: 33 additions & 2 deletions packages/go_router/lib/src/route.dart
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,9 @@ typedef NavigatorBuilder =
ShellRouteMatch match,
RouteMatchList matchList,
List<NavigatorObserver>? observers,
String? restorationScopeId,
);
String? restorationScopeId, {
Clip clipBehavior,
});
Comment on lines +59 to +61

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In the NavigatorBuilder typedef, the newly added optional named parameter clipBehavior is non-nullable but does not have a default value specified. Although the implementation in builder.dart provides a default value, it is highly recommended to specify the default value Clip.hardEdge directly in the typedef as well. This ensures that any caller of NavigatorBuilder (such as custom or mock implementations in tests) can statically resolve the default value correctly without relying on the implementation details.

Suggested change
String? restorationScopeId, {
Clip clipBehavior,
});
String? restorationScopeId, {
Clip clipBehavior = Clip.hardEdge,
});


/// Signature for function used in [RouteBase.onExit].
///
Expand Down Expand Up @@ -600,6 +601,7 @@ class ShellRouteContext {
List<NavigatorObserver>? observers,
bool notifyRootObserver,
String? restorationScopeId,
Clip clipBehavior,
) {
final effectiveObservers = <NavigatorObserver>[...?observers];

Expand All @@ -616,6 +618,7 @@ class ShellRouteContext {
routeMatchList,
effectiveObservers,
restorationScopeId,
clipBehavior: clipBehavior,
);
}
}
Expand Down Expand Up @@ -728,6 +731,7 @@ class ShellRoute extends ShellRouteBase {
super.parentNavigatorKey,
GlobalKey<NavigatorState>? navigatorKey,
this.restorationScopeId,
this.clipBehavior = Clip.hardEdge,
}) : assert(routes.isNotEmpty),
navigatorKey = navigatorKey ?? GlobalKey<NavigatorState>(),
super._() {
Expand Down Expand Up @@ -765,6 +769,7 @@ class ShellRoute extends ShellRouteBase {
observers,
notifyRootObserver,
restorationScopeId,
clipBehavior,
);
return builder!(context, state, navigator);
}
Expand All @@ -783,6 +788,7 @@ class ShellRoute extends ShellRouteBase {
observers,
notifyRootObserver,
restorationScopeId,
clipBehavior,
);
return pageBuilder!(context, state, navigator);
}
Expand All @@ -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<NavigatorState> navigatorKeyForSubRoute(RouteBase subRoute) {
assert(routes.contains(subRoute));
Expand Down Expand Up @@ -1127,6 +1145,7 @@ class StatefulShellBranch {
this.restorationScopeId,
this.observers,
this.preload = false,
this.clipBehavior = Clip.hardEdge,
}) : navigatorKey = navigatorKey ?? GlobalKey<NavigatorState>() {
assert(() {
ShellRouteBase._debugCheckSubRouteParentNavigatorKeys(routes, this.navigatorKey);
Expand Down Expand Up @@ -1162,6 +1181,16 @@ class StatefulShellBranch {
/// The observers parameter is used by the [Navigator] built for this branch.
final List<NavigatorObserver>? 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.
///
Expand Down Expand Up @@ -1409,6 +1438,7 @@ class StatefulNavigationShellState extends State<StatefulNavigationShell> with R
branch.observers,
route.notifyRootObserver,
branch.restorationScopeId,
branch.clipBehavior,
);
}

Expand Down Expand Up @@ -1438,6 +1468,7 @@ class StatefulNavigationShellState extends State<StatefulNavigationShell> with R
matchList,
branch.observers,
branch.restorationScopeId,
clipBehavior: branch.clipBehavior,
);

final _StatefulShellBranchState branchState = _branchStateFor(branch, false);
Expand Down
2 changes: 1 addition & 1 deletion packages/go_router/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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

Expand Down
148 changes: 148 additions & 0 deletions packages/go_router/test/shell_route_clip_behavior_test.dart
Original file line number Diff line number Diff line change
@@ -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<NavigatorState> navigatorKey) =>
tester.widget<Navigator>(find.byKey(navigatorKey, skipOffstage: false)).clipBehavior;

void main() {
group('ShellRoute', () {
testWidgets('clips the nested Navigator by default', (WidgetTester tester) async {
final navigatorKey = GlobalKey<NavigatorState>(debugLabel: 'shell');
await createRouter(<RouteBase>[
ShellRoute(
navigatorKey: navigatorKey,
builder: (_, _, Widget child) => child,
routes: <RouteBase>[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<NavigatorState>(debugLabel: 'shell');
await createRouter(<RouteBase>[
ShellRoute(
navigatorKey: navigatorKey,
clipBehavior: Clip.none,
builder: (_, _, Widget child) => child,
routes: <RouteBase>[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<NavigatorState>(debugLabel: 'shell');
await createRouter(<RouteBase>[
ShellRoute(
navigatorKey: navigatorKey,
clipBehavior: Clip.antiAlias,
pageBuilder: (_, _, Widget child) => MaterialPage<void>(child: child),
routes: <RouteBase>[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<NavigatorState>(debugLabel: 'branch');
await createRouter(<RouteBase>[
StatefulShellRoute.indexedStack(
builder: (_, _, StatefulNavigationShell shell) => shell,
branches: <StatefulShellBranch>[
StatefulShellBranch(
navigatorKey: navigatorKey,
routes: <RouteBase>[GoRoute(path: '/', builder: (_, _) => const Text('A'))],
),
],
),
], tester);

expect(clipBehaviorOf(tester, navigatorKey), Clip.hardEdge);
});

testWidgets('forwards clipBehavior per branch', (WidgetTester tester) async {
final keyA = GlobalKey<NavigatorState>(debugLabel: 'a');
final keyB = GlobalKey<NavigatorState>(debugLabel: 'b');
final root = GlobalKey<NavigatorState>(debugLabel: 'root');
await createRouter(
<RouteBase>[
StatefulShellRoute.indexedStack(
builder: (_, _, StatefulNavigationShell shell) => shell,
branches: <StatefulShellBranch>[
StatefulShellBranch(
navigatorKey: keyA,
clipBehavior: Clip.none,
routes: <RouteBase>[GoRoute(path: '/a', builder: (_, _) => const Text('A'))],
),
StatefulShellBranch(
navigatorKey: keyB,
routes: <RouteBase>[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<NavigatorState>(debugLabel: 'a');
final keyB = GlobalKey<NavigatorState>(debugLabel: 'b');
await createRouter(
<RouteBase>[
StatefulShellRoute.indexedStack(
builder: (_, _, StatefulNavigationShell shell) => shell,
branches: <StatefulShellBranch>[
StatefulShellBranch(
navigatorKey: keyA,
routes: <RouteBase>[GoRoute(path: '/a', builder: (_, _) => const Text('A'))],
),
StatefulShellBranch(
navigatorKey: keyB,
preload: true,
clipBehavior: Clip.none,
routes: <RouteBase>[GoRoute(path: '/b', builder: (_, _) => const Text('B'))],
),
],
),
],
tester,
initialLocation: '/a',
);
await tester.pumpAndSettle();

expect(clipBehaviorOf(tester, keyB), Clip.none);
});
});
}