Skip to content
Merged
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
30 changes: 30 additions & 0 deletions packages/go_router/lib/src/builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,28 @@ typedef _ErrorBuilderForAppType = Widget Function(BuildContext context, GoRouter
typedef PopPageWithRouteMatchCallback =
bool Function(Route<dynamic> route, dynamic result, RouteMatchBase match);

/// The clip behavior of the [Navigator] identified by [navigatorKey].
///
/// A [ShellRoute] builds a single Navigator and carries the clip behavior
/// itself, while a [StatefulShellRoute] builds one per branch, so the branch
/// owning [navigatorKey] carries it. [ShellRouteBase] cannot be subclassed
/// outside of this library, so those are the only two cases.
Clip _clipBehaviorFor(ShellRouteBase route, GlobalKey<NavigatorState> navigatorKey) {
switch (route) {
case ShellRoute():
return route.clipBehavior;
case StatefulShellRoute():
for (final StatefulShellBranch branch in route.branches) {
if (branch.navigatorKey == navigatorKey) {
return branch.clipBehavior;
}
}
return Clip.hardEdge;
default:
return Clip.hardEdge;
}
}

/// Builds the top-level Navigator for GoRouter.
class RouteBuilder {
/// [RouteBuilder] constructor.
Expand Down Expand Up @@ -138,6 +160,7 @@ class _CustomNavigator extends StatefulWidget {
required this.errorPageBuilder,
required this.requestFocus,
this.isShellNavigator = false,
this.clipBehavior = Clip.hardEdge,
});

final GlobalKey<NavigatorState> navigatorKey;
Expand Down Expand Up @@ -171,6 +194,11 @@ class _CustomNavigator extends StatefulWidget {
final GoRouterPageBuilder? errorPageBuilder;
final bool requestFocus;

/// The clip behavior of the [Navigator] built by this widget.
///
/// {@macro go_router.ShellRoute.clipBehavior}
final Clip clipBehavior;

@override
State<StatefulWidget> createState() => _CustomNavigatorState();
}
Expand Down Expand Up @@ -330,6 +358,7 @@ class _CustomNavigatorState extends State<_CustomNavigator> {
errorPageBuilder: widget.errorPageBuilder,
requestFocus: widget.requestFocus,
isShellNavigator: true,
clipBehavior: _clipBehaviorFor(match.route, navigatorKey),
),
);
},
Expand Down Expand Up @@ -465,6 +494,7 @@ class _CustomNavigatorState extends State<_CustomNavigator> {
pages: _pages!,
observers: widget.observers,
onPopPage: _handlePopPage,
clipBehavior: widget.clipBehavior,
);
return GoRouterStateRegistryScope(
registry: _registry,
Expand Down
25 changes: 25 additions & 0 deletions packages/go_router/lib/src/route.dart
Original file line number Diff line number Diff line change
Expand Up @@ -728,6 +728,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 @@ -804,6 +805,20 @@ class ShellRoute extends ShellRouteBase {
/// its history.
final String? restorationScopeId;

/// The clip behavior of the [Navigator] built for this route.
Comment thread
m1roxx marked this conversation as resolved.
///
/// {@template go_router.ShellRoute.clipBehavior}
/// 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].
/// {@endtemplate}
final Clip clipBehavior;

Comment thread
m1roxx marked this conversation as resolved.
@override
GlobalKey<NavigatorState> navigatorKeyForSubRoute(RouteBase subRoute) {
assert(routes.contains(subRoute));
Expand All @@ -814,6 +829,7 @@ class ShellRoute extends ShellRouteBase {
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<GlobalKey<NavigatorState>>('navigatorKey', navigatorKey));
properties.add(EnumProperty<Clip>('clipBehavior', clipBehavior, defaultValue: Clip.hardEdge));
}
}

Expand Down Expand Up @@ -1127,6 +1143,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 +1179,14 @@ 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.
///
/// {@macro go_router.ShellRoute.clipBehavior}
final Clip clipBehavior;

/// Whether this route branch should be eagerly loaded when navigating to the
/// associated StatefulShellRoute for the first time.
///
Expand Down
4 changes: 4 additions & 0 deletions packages/go_router/lib/src/route_data.dart
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,7 @@ abstract class ShellRouteData extends RouteData {
bool notifyRootObserver = true,
List<NavigatorObserver>? observers,
String? restorationScopeId,
Clip clipBehavior = Clip.hardEdge,
}) {
T factoryImpl(GoRouterState state) {
return (_stateObjectExpando[state] ??= factory(state)) as T;
Expand All @@ -330,6 +331,7 @@ abstract class ShellRouteData extends RouteData {
observers: observers,
restorationScopeId: restorationScopeId,
redirect: redirect,
clipBehavior: clipBehavior,
);
}

Expand Down Expand Up @@ -440,6 +442,7 @@ abstract class StatefulShellBranchData {
String? initialLocation,
String? restorationScopeId,
bool preload = false,
Clip clipBehavior = Clip.hardEdge,
}) {
return StatefulShellBranch(
routes: routes,
Expand All @@ -448,6 +451,7 @@ abstract class StatefulShellBranchData {
initialLocation: initialLocation,
restorationScopeId: restorationScopeId,
preload: preload,
clipBehavior: clipBehavior,
);
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
changelog: |
- Adds `clipBehavior` to `ShellRoute` and `StatefulShellBranch`, forwarded to the nested `Navigator`.
version: minor
208 changes: 208 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,208 @@
// 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/foundation.dart';
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;

class _ShellRouteData extends ShellRouteData {
const _ShellRouteData();

@override
Widget builder(BuildContext context, GoRouterState state, Widget navigator) => navigator;
}

/// Reads the description of the `clipBehavior` diagnostics property of the
/// given [route], or null if the property is hidden at its default value.
String? clipBehaviorDiagnosticOf(ShellRoute route) {
final builder = DiagnosticPropertiesBuilder();
route.debugFillProperties(builder);
return builder.properties
.where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info))
.where((DiagnosticsNode node) => node.name == 'clipBehavior')
.map((DiagnosticsNode node) => node.toDescription())
.firstOrNull;
}

void main() {
group('ShellRoute', () {
test('includes a non-default clipBehavior in debugFillProperties', () {
ShellRoute shellRoute({Clip clipBehavior = Clip.hardEdge}) => ShellRoute(
clipBehavior: clipBehavior,
builder: (_, _, Widget child) => child,
routes: <RouteBase>[GoRoute(path: '/', builder: (_, _) => const Text('Home'))],
);

expect(clipBehaviorDiagnosticOf(shellRoute()), isNull);
expect(clipBehaviorDiagnosticOf(shellRoute(clipBehavior: Clip.none)), 'none');
});

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);
});
});

group('typed routes', () {
test(r'ShellRouteData.$route forwards clipBehavior', () {
final routes = <RouteBase>[GoRoute(path: '/', builder: (_, _) => const Text('Home'))];

expect(
ShellRouteData.$route(factory: (_) => const _ShellRouteData(), routes: routes).clipBehavior,
Clip.hardEdge,
);
expect(
ShellRouteData.$route(
factory: (_) => const _ShellRouteData(),
clipBehavior: Clip.none,
routes: routes,
).clipBehavior,
Clip.none,
);
});

test(r'StatefulShellBranchData.$branch forwards clipBehavior', () {
final routes = <RouteBase>[GoRoute(path: '/', builder: (_, _) => const Text('Home'))];

expect(StatefulShellBranchData.$branch(routes: routes).clipBehavior, Clip.hardEdge);
expect(
StatefulShellBranchData.$branch(routes: routes, clipBehavior: Clip.none).clipBehavior,
Clip.none,
);
});
});
}
Loading