Skip to content

Commit 0023d01

Browse files
authored
[go_router] Adds on exit (flutter#4699)
related flutter#102408
1 parent cb01eda commit 0023d01

File tree

8 files changed

+511
-16
lines changed

8 files changed

+511
-16
lines changed

packages/go_router/CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 10.2.0
2+
3+
- Adds `onExit` to GoRoute.
4+
15
## 10.1.4
26

37
- Fixes RouteInformationParser that does not restore full RouteMatchList if
@@ -77,7 +81,7 @@
7781

7882
- Makes namedLocation and route name related APIs case sensitive.
7983

80-
## 8.0.2
84+
## 8.0.2
8185

8286
- Fixes a bug in `debugLogDiagnostics` to support StatefulShellRoute.
8387

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
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/material.dart';
6+
import 'package:go_router/go_router.dart';
7+
8+
/// This sample app demonstrates how to use GoRoute.onExit.
9+
void main() => runApp(const MyApp());
10+
11+
/// The route configuration.
12+
final GoRouter _router = GoRouter(
13+
routes: <RouteBase>[
14+
GoRoute(
15+
path: '/',
16+
builder: (BuildContext context, GoRouterState state) {
17+
return const HomeScreen();
18+
},
19+
routes: <RouteBase>[
20+
GoRoute(
21+
path: 'details',
22+
builder: (BuildContext context, GoRouterState state) {
23+
return const DetailsScreen();
24+
},
25+
onExit: (BuildContext context) async {
26+
final bool? confirmed = await showDialog<bool>(
27+
context: context,
28+
builder: (_) {
29+
return AlertDialog(
30+
content: const Text('Are you sure to leave this page?'),
31+
actions: <Widget>[
32+
TextButton(
33+
onPressed: () => Navigator.of(context).pop(false),
34+
child: const Text('Cancel'),
35+
),
36+
TextButton(
37+
onPressed: () => Navigator.of(context).pop(true),
38+
child: const Text('Confirm'),
39+
),
40+
],
41+
);
42+
},
43+
);
44+
return confirmed ?? false;
45+
},
46+
),
47+
GoRoute(
48+
path: 'settings',
49+
builder: (BuildContext context, GoRouterState state) {
50+
return const SettingsScreen();
51+
},
52+
),
53+
],
54+
),
55+
],
56+
);
57+
58+
/// The main app.
59+
class MyApp extends StatelessWidget {
60+
/// Constructs a [MyApp]
61+
const MyApp({super.key});
62+
63+
@override
64+
Widget build(BuildContext context) {
65+
return MaterialApp.router(
66+
routerConfig: _router,
67+
);
68+
}
69+
}
70+
71+
/// The home screen
72+
class HomeScreen extends StatelessWidget {
73+
/// Constructs a [HomeScreen]
74+
const HomeScreen({super.key});
75+
76+
@override
77+
Widget build(BuildContext context) {
78+
return Scaffold(
79+
appBar: AppBar(title: const Text('Home Screen')),
80+
body: Center(
81+
child: Column(
82+
mainAxisAlignment: MainAxisAlignment.center,
83+
children: <Widget>[
84+
ElevatedButton(
85+
onPressed: () => context.go('/details'),
86+
child: const Text('Go to the Details screen'),
87+
),
88+
],
89+
),
90+
),
91+
);
92+
}
93+
}
94+
95+
/// The details screen
96+
class DetailsScreen extends StatelessWidget {
97+
/// Constructs a [DetailsScreen]
98+
const DetailsScreen({super.key});
99+
100+
@override
101+
Widget build(BuildContext context) {
102+
return Scaffold(
103+
appBar: AppBar(title: const Text('Details Screen')),
104+
body: Center(
105+
child: Column(
106+
children: <Widget>[
107+
TextButton(
108+
onPressed: () {
109+
context.pop();
110+
},
111+
child: const Text('go back'),
112+
),
113+
TextButton(
114+
onPressed: () {
115+
context.go('/settings');
116+
},
117+
child: const Text('go to settings'),
118+
),
119+
],
120+
)),
121+
);
122+
}
123+
}
124+
125+
/// The settings screen
126+
class SettingsScreen extends StatelessWidget {
127+
/// Constructs a [SettingsScreen]
128+
const SettingsScreen({super.key});
129+
130+
@override
131+
Widget build(BuildContext context) {
132+
return Scaffold(
133+
appBar: AppBar(title: const Text('Settings Screen')),
134+
body: const Center(
135+
child: Text('Settings'),
136+
),
137+
);
138+
}
139+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
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/material.dart';
6+
import 'package:flutter_test/flutter_test.dart';
7+
import 'package:go_router_examples/on_exit.dart' as example;
8+
9+
void main() {
10+
testWidgets('example works', (WidgetTester tester) async {
11+
await tester.pumpWidget(const example.MyApp());
12+
13+
await tester.tap(find.text('Go to the Details screen'));
14+
await tester.pumpAndSettle();
15+
16+
await tester.tap(find.byType(BackButton));
17+
await tester.pumpAndSettle();
18+
19+
expect(find.text('Are you sure to leave this page?'), findsOneWidget);
20+
await tester.tap(find.text('Cancel'));
21+
await tester.pumpAndSettle();
22+
expect(find.byType(example.DetailsScreen), findsOneWidget);
23+
24+
await tester.tap(find.byType(BackButton));
25+
await tester.pumpAndSettle();
26+
27+
expect(find.text('Are you sure to leave this page?'), findsOneWidget);
28+
await tester.tap(find.text('Confirm'));
29+
await tester.pumpAndSettle();
30+
expect(find.byType(example.HomeScreen), findsOneWidget);
31+
});
32+
}

packages/go_router/lib/src/builder.dart

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -592,10 +592,11 @@ class _PagePopContext {
592592
/// This assumes always pop the last route match for the page.
593593
bool onPopPage(Route<dynamic> route, dynamic result) {
594594
final Page<Object?> page = route.settings as Page<Object?>;
595-
596595
final RouteMatch match = _routeMatchesLookUp[page]!.last;
597-
_routeMatchesLookUp[page]!.removeLast();
598-
599-
return onPopPageWithRouteMatch(route, result, match);
596+
if (onPopPageWithRouteMatch(route, result, match)) {
597+
_routeMatchesLookUp[page]!.removeLast();
598+
return true;
599+
}
600+
return false;
600601
}
601602
}

packages/go_router/lib/src/delegate.dart

Lines changed: 101 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// found in the LICENSE file.
44

55
import 'dart:async';
6+
import 'dart:math' as math;
67

78
import 'package:flutter/foundation.dart';
89
import 'package:flutter/widgets.dart';
@@ -97,20 +98,42 @@ class GoRouterDelegate extends RouterDelegate<RouteMatchList>
9798

9899
bool _handlePopPageWithRouteMatch(
99100
Route<Object?> route, Object? result, RouteMatch? match) {
100-
if (!route.didPop(result)) {
101-
return false;
101+
if (route.willHandlePopInternally) {
102+
final bool popped = route.didPop(result);
103+
assert(!popped);
104+
return popped;
102105
}
103106
assert(match != null);
107+
final RouteBase routeBase = match!.route;
108+
if (routeBase is! GoRoute || routeBase.onExit == null) {
109+
route.didPop(result);
110+
_completeRouteMatch(result, match);
111+
return true;
112+
}
113+
114+
// The _handlePopPageWithRouteMatch is called during draw frame, schedule
115+
// a microtask in case the onExit callback want to launch dialog or other
116+
// navigator operations.
117+
scheduleMicrotask(() async {
118+
final bool onExitResult =
119+
await routeBase.onExit!(navigatorKey.currentContext!);
120+
if (onExitResult) {
121+
_completeRouteMatch(result, match);
122+
}
123+
});
124+
return false;
125+
}
126+
127+
void _completeRouteMatch(Object? result, RouteMatch match) {
104128
if (match is ImperativeRouteMatch) {
105129
match.complete(result);
106130
}
107-
currentConfiguration = currentConfiguration.remove(match!);
131+
currentConfiguration = currentConfiguration.remove(match);
108132
notifyListeners();
109133
assert(() {
110134
_debugAssertMatchListNotEmpty();
111135
return true;
112136
}());
113-
return true;
114137
}
115138

116139
/// For use by the Router architecture as part of the RouterDelegate.
@@ -131,15 +154,83 @@ class GoRouterDelegate extends RouterDelegate<RouteMatchList>
131154
}
132155

133156
/// For use by the Router architecture as part of the RouterDelegate.
157+
// This class avoids using async to make sure the route is processed
158+
// synchronously if possible.
134159
@override
135160
Future<void> setNewRoutePath(RouteMatchList configuration) {
136-
if (currentConfiguration != configuration) {
137-
currentConfiguration = configuration;
138-
notifyListeners();
161+
if (currentConfiguration == configuration) {
162+
return SynchronousFuture<void>(null);
163+
}
164+
165+
assert(configuration.isNotEmpty || configuration.isError);
166+
167+
final BuildContext? navigatorContext = navigatorKey.currentContext;
168+
// If navigator is not built or disposed, the GoRoute.onExit is irrelevant.
169+
if (navigatorContext != null) {
170+
final int compareUntil = math.min(
171+
currentConfiguration.matches.length,
172+
configuration.matches.length,
173+
);
174+
int indexOfFirstDiff = 0;
175+
for (; indexOfFirstDiff < compareUntil; indexOfFirstDiff++) {
176+
if (currentConfiguration.matches[indexOfFirstDiff] !=
177+
configuration.matches[indexOfFirstDiff]) {
178+
break;
179+
}
180+
}
181+
if (indexOfFirstDiff < currentConfiguration.matches.length) {
182+
final List<GoRoute> exitingGoRoutes = currentConfiguration.matches
183+
.sublist(indexOfFirstDiff)
184+
.map<RouteBase>((RouteMatch match) => match.route)
185+
.whereType<GoRoute>()
186+
.toList();
187+
return _callOnExitStartsAt(exitingGoRoutes.length - 1,
188+
navigatorContext: navigatorContext, routes: exitingGoRoutes)
189+
.then<void>((bool exit) {
190+
if (!exit) {
191+
return SynchronousFuture<void>(null);
192+
}
193+
return _setCurrentConfiguration(configuration);
194+
});
195+
}
139196
}
140-
assert(currentConfiguration.isNotEmpty || currentConfiguration.isError);
141-
// Use [SynchronousFuture] so that the initial url is processed
142-
// synchronously and remove unwanted initial animations on deep-linking
197+
198+
return _setCurrentConfiguration(configuration);
199+
}
200+
201+
/// Calls [GoRoute.onExit] starting from the index
202+
///
203+
/// The returned future resolves to true if all routes below the index all
204+
/// return true. Otherwise, the returned future resolves to false.
205+
static Future<bool> _callOnExitStartsAt(int index,
206+
{required BuildContext navigatorContext, required List<GoRoute> routes}) {
207+
if (index < 0) {
208+
return SynchronousFuture<bool>(true);
209+
}
210+
final GoRoute goRoute = routes[index];
211+
if (goRoute.onExit == null) {
212+
return _callOnExitStartsAt(index - 1,
213+
navigatorContext: navigatorContext, routes: routes);
214+
}
215+
216+
Future<bool> handleOnExitResult(bool exit) {
217+
if (exit) {
218+
return _callOnExitStartsAt(index - 1,
219+
navigatorContext: navigatorContext, routes: routes);
220+
}
221+
return SynchronousFuture<bool>(false);
222+
}
223+
224+
final FutureOr<bool> exitFuture = goRoute.onExit!(navigatorContext);
225+
if (exitFuture is bool) {
226+
return handleOnExitResult(exitFuture);
227+
}
228+
return exitFuture.then<bool>(handleOnExitResult);
229+
}
230+
231+
Future<void> _setCurrentConfiguration(RouteMatchList configuration) {
232+
currentConfiguration = configuration;
233+
notifyListeners();
143234
return SynchronousFuture<void>(null);
144235
}
145236
}

0 commit comments

Comments
 (0)