Skip to content

Commit 1a2594d

Browse files
committed
Fix issue where ShellRoutes break iOS swipe back navigation
1 parent 24588c6 commit 1a2594d

File tree

5 files changed

+298
-15
lines changed

5 files changed

+298
-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.2
2+
3+
- Fixes an issue where iOS back gesture pops entire ShellRoute instead of the active sub-route.
4+
15
## 16.2.1
26

37
- Adds state restoration topic to documentation.

packages/go_router/lib/src/builder.dart

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -293,20 +293,25 @@ 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+
canPop: match.matches.length == 1,
300+
child: _CustomNavigator(
301+
// The state needs to persist across rebuild.
302+
key: GlobalObjectKey(navigatorKey.hashCode),
303+
navigatorRestorationId: restorationScopeId,
304+
navigatorKey: navigatorKey,
305+
matches: match.matches,
306+
matchList: matchList,
307+
configuration: widget.configuration,
308+
observers: observers ?? const <NavigatorObserver>[],
309+
onPopPageWithRouteMatch: widget.onPopPageWithRouteMatch,
310+
// This is used to recursively build pages under this shell route.
311+
errorBuilder: widget.errorBuilder,
312+
errorPageBuilder: widget.errorPageBuilder,
313+
requestFocus: widget.requestFocus,
314+
),
310315
);
311316
},
312317
);

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.1
4+
version: 16.2.2
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: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
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+
// Regression test for https://github.com/flutter/flutter/issues/120353
11+
void main() {
12+
group('iOS back gesture inside a ShellRoute', () {
13+
Future<void> backGesture(WidgetTester tester) async {
14+
await tester.dragFrom(const Offset(0, 300), const Offset(500, 300));
15+
}
16+
17+
testWidgets('pops the top sub-route '
18+
'when there is an active sub-route', (WidgetTester tester) async {
19+
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
20+
21+
await tester.pumpWidget(const _TestApp());
22+
expect(find.text('Home'), findsOneWidget);
23+
24+
await tester.tap(find.byType(FilledButton));
25+
await tester.pumpAndSettle();
26+
expect(find.text('Post'), findsOneWidget);
27+
28+
await tester.tap(find.byType(FilledButton));
29+
await tester.pumpAndSettle();
30+
expect(find.text('Comment'), findsOneWidget);
31+
32+
await backGesture(tester);
33+
await tester.pumpAndSettle();
34+
expect(find.text('Post'), findsOneWidget);
35+
36+
debugDefaultTargetPlatformOverride = null;
37+
});
38+
39+
testWidgets('pops ShellRoute '
40+
'when there are no active sub-routes', (WidgetTester tester) async {
41+
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
42+
43+
await tester.pumpWidget(const _TestApp());
44+
expect(find.text('Home'), findsOneWidget);
45+
46+
await tester.tap(find.byType(FilledButton));
47+
await tester.pumpAndSettle();
48+
expect(find.text('Post'), findsOneWidget);
49+
50+
await backGesture(tester);
51+
await tester.pumpAndSettle();
52+
expect(find.text('Home'), findsOneWidget);
53+
54+
debugDefaultTargetPlatformOverride = null;
55+
});
56+
});
57+
}
58+
59+
class _TestApp extends StatefulWidget {
60+
const _TestApp();
61+
62+
@override
63+
State<_TestApp> createState() => _TestAppState();
64+
}
65+
66+
class _TestAppState extends State<_TestApp> {
67+
final GoRouter _router = GoRouter(
68+
routes: <GoRoute>[
69+
GoRoute(
70+
path: '/',
71+
builder: (BuildContext context, GoRouterState state) {
72+
return Scaffold(
73+
appBar: AppBar(title: const Text('Home')),
74+
body: Center(
75+
child: FilledButton(
76+
onPressed: () {
77+
GoRouter.of(context).go('/post');
78+
},
79+
child: const Text('Go to Post'),
80+
),
81+
),
82+
);
83+
},
84+
routes: <RouteBase>[
85+
ShellRoute(
86+
builder: (BuildContext context, GoRouterState state, Widget child) {
87+
return child;
88+
},
89+
routes: <GoRoute>[
90+
GoRoute(
91+
path: '/post',
92+
builder: (BuildContext context, GoRouterState state) {
93+
return Scaffold(
94+
appBar: AppBar(title: const Text('Post')),
95+
body: Center(
96+
child: FilledButton(
97+
onPressed: () {
98+
GoRouter.of(context).go('/post/comment');
99+
},
100+
child: const Text('Comment'),
101+
),
102+
),
103+
);
104+
},
105+
routes: <GoRoute>[
106+
GoRoute(
107+
path: 'comment',
108+
builder: (BuildContext context, GoRouterState state) {
109+
return Scaffold(
110+
appBar: AppBar(title: const Text('Comment')),
111+
);
112+
},
113+
),
114+
],
115+
),
116+
],
117+
),
118+
],
119+
),
120+
],
121+
);
122+
123+
@override
124+
void dispose() {
125+
_router.dispose();
126+
super.dispose();
127+
}
128+
129+
@override
130+
Widget build(BuildContext context) {
131+
return MaterialApp.router(routerConfig: _router);
132+
}
133+
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
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+
// Regression test for https://github.com/flutter/flutter/issues/120353
11+
void main() {
12+
group('iOS back gesture inside a StatefulShellRoute', () {
13+
Future<void> backGesture(WidgetTester tester) async {
14+
await tester.dragFrom(const Offset(0, 300), const Offset(500, 300));
15+
}
16+
17+
testWidgets('pops the top sub-route '
18+
'when there is an active sub-route', (WidgetTester tester) async {
19+
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
20+
21+
await tester.pumpWidget(const _TestApp());
22+
expect(find.text('Home'), findsOneWidget);
23+
24+
await tester.tap(find.byType(FilledButton));
25+
await tester.pumpAndSettle();
26+
expect(find.text('Post'), findsOneWidget);
27+
28+
await tester.tap(find.byType(FilledButton));
29+
await tester.pumpAndSettle();
30+
expect(find.text('Comment'), findsOneWidget);
31+
32+
await backGesture(tester);
33+
await tester.pumpAndSettle();
34+
expect(find.text('Post'), findsOneWidget);
35+
36+
debugDefaultTargetPlatformOverride = null;
37+
});
38+
39+
testWidgets('pops StatefulShellRoute '
40+
'when there are no active sub-routes', (WidgetTester tester) async {
41+
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
42+
43+
await tester.pumpWidget(const _TestApp());
44+
expect(find.text('Home'), findsOneWidget);
45+
46+
await tester.tap(find.byType(FilledButton));
47+
await tester.pumpAndSettle();
48+
expect(find.text('Post'), findsOneWidget);
49+
50+
await backGesture(tester);
51+
await tester.pumpAndSettle();
52+
expect(find.text('Home'), findsOneWidget);
53+
54+
debugDefaultTargetPlatformOverride = null;
55+
});
56+
});
57+
}
58+
59+
class _TestApp extends StatefulWidget {
60+
const _TestApp();
61+
62+
@override
63+
State<_TestApp> createState() => _TestAppState();
64+
}
65+
66+
class _TestAppState extends State<_TestApp> {
67+
final GoRouter _router = GoRouter(
68+
routes: <GoRoute>[
69+
GoRoute(
70+
path: '/',
71+
builder: (BuildContext context, GoRouterState state) {
72+
return Scaffold(
73+
appBar: AppBar(title: const Text('Home')),
74+
body: Center(
75+
child: FilledButton(
76+
onPressed: () {
77+
GoRouter.of(context).go('/post');
78+
},
79+
child: const Text('Go to Post'),
80+
),
81+
),
82+
);
83+
},
84+
routes: <RouteBase>[
85+
StatefulShellRoute.indexedStack(
86+
builder: (
87+
BuildContext context,
88+
GoRouterState state,
89+
StatefulNavigationShell navigationShell,
90+
) {
91+
return navigationShell;
92+
},
93+
branches: <StatefulShellBranch>[
94+
StatefulShellBranch(
95+
routes: <GoRoute>[
96+
GoRoute(
97+
path: '/post',
98+
builder: (BuildContext context, GoRouterState state) {
99+
return Scaffold(
100+
appBar: AppBar(title: const Text('Post')),
101+
body: Center(
102+
child: FilledButton(
103+
onPressed: () {
104+
GoRouter.of(context).go('/post/comment');
105+
},
106+
child: const Text('Comment'),
107+
),
108+
),
109+
);
110+
},
111+
routes: <GoRoute>[
112+
GoRoute(
113+
path: 'comment',
114+
builder: (BuildContext context, GoRouterState state) {
115+
return Scaffold(
116+
appBar: AppBar(title: const Text('Comment')),
117+
);
118+
},
119+
),
120+
],
121+
),
122+
],
123+
),
124+
],
125+
),
126+
],
127+
),
128+
],
129+
);
130+
131+
@override
132+
void dispose() {
133+
_router.dispose();
134+
super.dispose();
135+
}
136+
137+
@override
138+
Widget build(BuildContext context) {
139+
return MaterialApp.router(routerConfig: _router);
140+
}
141+
}

0 commit comments

Comments
 (0)