Skip to content

Commit e730c1d

Browse files
authored
[go_router] Fix ShellRoutes break iOS swipe back navigation (#9968)
Adds `PopScope` which prevents iOS back gesture from popping `ShellRoute` when there are active sub-routes. Closes flutter/flutter#120353. ## Pre-Review Checklist **Note**: The Flutter team is currently trialing the use of [Gemini Code Assist for GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code). Comments from the `gemini-code-assist` bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed. [^1]: Regular contributors who have demonstrated familiarity with the repository guidelines only need to comment if the PR is not auto-exempted by repo tooling.
1 parent 3413b65 commit e730c1d

File tree

6 files changed

+370
-15
lines changed

6 files changed

+370
-15
lines changed

packages/go_router/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 16.2.3
2+
3+
- Fixes an issue where iOS back gesture pops entire ShellRoute instead of the active sub-route.
4+
15
## 16.2.2
26

37
- Fixes broken links in readme.

packages/go_router/lib/src/builder.dart

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -293,20 +293,27 @@ class _CustomNavigatorState extends State<_CustomNavigator> {
293293
List<NavigatorObserver>? observers,
294294
String? restorationScopeId,
295295
) {
296-
return _CustomNavigator(
297-
// The state needs to persist across rebuild.
298-
key: GlobalObjectKey(navigatorKey.hashCode),
299-
navigatorRestorationId: restorationScopeId,
300-
navigatorKey: navigatorKey,
301-
matches: match.matches,
302-
matchList: matchList,
303-
configuration: widget.configuration,
304-
observers: observers ?? const <NavigatorObserver>[],
305-
onPopPageWithRouteMatch: widget.onPopPageWithRouteMatch,
306-
// This is used to recursively build pages under this shell route.
307-
errorBuilder: widget.errorBuilder,
308-
errorPageBuilder: widget.errorPageBuilder,
309-
requestFocus: widget.requestFocus,
296+
return PopScope(
297+
// Prevent ShellRoute from being popped, for example
298+
// by an iOS back gesture, when the route has active sub-routes.
299+
// TODO(LukasMirbt): Remove when minimum flutter version includes
300+
// https://github.com/flutter/flutter/pull/152330.
301+
canPop: match.matches.length == 1,
302+
child: _CustomNavigator(
303+
// The state needs to persist across rebuild.
304+
key: GlobalObjectKey(navigatorKey.hashCode),
305+
navigatorRestorationId: restorationScopeId,
306+
navigatorKey: navigatorKey,
307+
matches: match.matches,
308+
matchList: matchList,
309+
configuration: widget.configuration,
310+
observers: observers ?? const <NavigatorObserver>[],
311+
onPopPageWithRouteMatch: widget.onPopPageWithRouteMatch,
312+
// This is used to recursively build pages under this shell route.
313+
errorBuilder: widget.errorBuilder,
314+
errorPageBuilder: widget.errorPageBuilder,
315+
requestFocus: widget.requestFocus,
316+
),
310317
);
311318
},
312319
);

packages/go_router/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
name: go_router
22
description: A declarative router for Flutter based on Navigation 2 supporting
33
deep linking, data-driven routes and more
4-
version: 16.2.2
4+
version: 16.2.3
55
repository: https://github.com/flutter/packages/tree/main/packages/go_router
66
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+go_router%22
77

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
// Copyright 2013 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter/foundation.dart';
6+
import 'package:flutter/material.dart';
7+
import 'package:flutter_test/flutter_test.dart';
8+
import 'package:go_router/go_router.dart';
9+
10+
import 'test_helpers.dart';
11+
12+
// Regression test for https://github.com/flutter/flutter/issues/120353
13+
void main() {
14+
group('iOS back gesture inside a ShellRoute', () {
15+
testWidgets('pops the top sub-route '
16+
'when there is an active sub-route', (WidgetTester tester) async {
17+
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
18+
19+
await tester.pumpWidget(const _TestApp());
20+
expect(find.text('Home'), findsOneWidget);
21+
22+
await tester.tap(find.byType(FilledButton));
23+
await tester.pumpAndSettle();
24+
expect(find.text('Post'), findsOneWidget);
25+
26+
await tester.tap(find.byType(FilledButton));
27+
await tester.pumpAndSettle();
28+
expect(find.text('Comment'), findsOneWidget);
29+
30+
await simulateIosBackGesture(tester);
31+
await tester.pumpAndSettle();
32+
expect(find.text('Post'), findsOneWidget);
33+
34+
debugDefaultTargetPlatformOverride = null;
35+
});
36+
37+
testWidgets('pops ShellRoute '
38+
'when there are no active sub-routes', (WidgetTester tester) async {
39+
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
40+
41+
await tester.pumpWidget(const _TestApp());
42+
expect(find.text('Home'), findsOneWidget);
43+
44+
await tester.tap(find.byType(FilledButton));
45+
await tester.pumpAndSettle();
46+
expect(find.text('Post'), findsOneWidget);
47+
48+
await simulateIosBackGesture(tester);
49+
await tester.pumpAndSettle();
50+
expect(find.text('Home'), findsOneWidget);
51+
52+
debugDefaultTargetPlatformOverride = null;
53+
});
54+
});
55+
56+
group('Android back button inside a ShellRoute', () {
57+
testWidgets('pops the top sub-route '
58+
'when there is an active sub-route', (WidgetTester tester) async {
59+
await tester.pumpWidget(const _TestApp());
60+
expect(find.text('Home'), findsOneWidget);
61+
62+
await tester.tap(find.byType(FilledButton));
63+
await tester.pumpAndSettle();
64+
expect(find.text('Post'), findsOneWidget);
65+
66+
await tester.tap(find.byType(FilledButton));
67+
await tester.pumpAndSettle();
68+
expect(find.text('Comment'), findsOneWidget);
69+
70+
await simulateAndroidBackButton(tester);
71+
await tester.pumpAndSettle();
72+
expect(find.text('Post'), findsOneWidget);
73+
});
74+
75+
testWidgets('pops ShellRoute '
76+
'when there are no active sub-routes', (WidgetTester tester) async {
77+
await tester.pumpWidget(const _TestApp());
78+
expect(find.text('Home'), findsOneWidget);
79+
80+
await tester.tap(find.byType(FilledButton));
81+
await tester.pumpAndSettle();
82+
expect(find.text('Post'), findsOneWidget);
83+
84+
await simulateAndroidBackButton(tester);
85+
await tester.pumpAndSettle();
86+
expect(find.text('Home'), findsOneWidget);
87+
});
88+
});
89+
}
90+
91+
class _TestApp extends StatefulWidget {
92+
const _TestApp();
93+
94+
@override
95+
State<_TestApp> createState() => _TestAppState();
96+
}
97+
98+
class _TestAppState extends State<_TestApp> {
99+
final GoRouter _router = GoRouter(
100+
routes: <GoRoute>[
101+
GoRoute(
102+
path: '/',
103+
builder: (BuildContext context, GoRouterState state) {
104+
return Scaffold(
105+
appBar: AppBar(title: const Text('Home')),
106+
body: Center(
107+
child: FilledButton(
108+
onPressed: () {
109+
GoRouter.of(context).go('/post');
110+
},
111+
child: const Text('Go to Post'),
112+
),
113+
),
114+
);
115+
},
116+
routes: <RouteBase>[
117+
ShellRoute(
118+
builder: (BuildContext context, GoRouterState state, Widget child) {
119+
return child;
120+
},
121+
routes: <GoRoute>[
122+
GoRoute(
123+
path: '/post',
124+
builder: (BuildContext context, GoRouterState state) {
125+
return Scaffold(
126+
appBar: AppBar(title: const Text('Post')),
127+
body: Center(
128+
child: FilledButton(
129+
onPressed: () {
130+
GoRouter.of(context).go('/post/comment');
131+
},
132+
child: const Text('Comment'),
133+
),
134+
),
135+
);
136+
},
137+
routes: <GoRoute>[
138+
GoRoute(
139+
path: 'comment',
140+
builder: (BuildContext context, GoRouterState state) {
141+
return Scaffold(
142+
appBar: AppBar(title: const Text('Comment')),
143+
);
144+
},
145+
),
146+
],
147+
),
148+
],
149+
),
150+
],
151+
),
152+
],
153+
);
154+
155+
@override
156+
void dispose() {
157+
_router.dispose();
158+
super.dispose();
159+
}
160+
161+
@override
162+
Widget build(BuildContext context) {
163+
return MaterialApp.router(routerConfig: _router);
164+
}
165+
}

0 commit comments

Comments
 (0)