Skip to content

Commit

Permalink
[go_router] Adds on exit (#4699)
Browse files Browse the repository at this point in the history
  • Loading branch information
chunhtai committed Sep 18, 2023
1 parent cb01eda commit 0023d01
Show file tree
Hide file tree
Showing 8 changed files with 511 additions and 16 deletions.
6 changes: 5 additions & 1 deletion packages/go_router/CHANGELOG.md
@@ -1,3 +1,7 @@
## 10.2.0

- Adds `onExit` to GoRoute.

## 10.1.4

- Fixes RouteInformationParser that does not restore full RouteMatchList if
Expand Down Expand Up @@ -77,7 +81,7 @@

- Makes namedLocation and route name related APIs case sensitive.

## 8.0.2
## 8.0.2

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

Expand Down
139 changes: 139 additions & 0 deletions packages/go_router/example/lib/on_exit.dart
@@ -0,0 +1,139 @@
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

/// This sample app demonstrates how to use GoRoute.onExit.
void main() => runApp(const MyApp());

/// The route configuration.
final GoRouter _router = GoRouter(
routes: <RouteBase>[
GoRoute(
path: '/',
builder: (BuildContext context, GoRouterState state) {
return const HomeScreen();
},
routes: <RouteBase>[
GoRoute(
path: 'details',
builder: (BuildContext context, GoRouterState state) {
return const DetailsScreen();
},
onExit: (BuildContext context) async {
final bool? confirmed = await showDialog<bool>(
context: context,
builder: (_) {
return AlertDialog(
content: const Text('Are you sure to leave this page?'),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Confirm'),
),
],
);
},
);
return confirmed ?? false;
},
),
GoRoute(
path: 'settings',
builder: (BuildContext context, GoRouterState state) {
return const SettingsScreen();
},
),
],
),
],
);

/// The main app.
class MyApp extends StatelessWidget {
/// Constructs a [MyApp]
const MyApp({super.key});

@override
Widget build(BuildContext context) {
return MaterialApp.router(
routerConfig: _router,
);
}
}

/// The home screen
class HomeScreen extends StatelessWidget {
/// Constructs a [HomeScreen]
const HomeScreen({super.key});

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Home Screen')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
ElevatedButton(
onPressed: () => context.go('/details'),
child: const Text('Go to the Details screen'),
),
],
),
),
);
}
}

/// The details screen
class DetailsScreen extends StatelessWidget {
/// Constructs a [DetailsScreen]
const DetailsScreen({super.key});

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Details Screen')),
body: Center(
child: Column(
children: <Widget>[
TextButton(
onPressed: () {
context.pop();
},
child: const Text('go back'),
),
TextButton(
onPressed: () {
context.go('/settings');
},
child: const Text('go to settings'),
),
],
)),
);
}
}

/// The settings screen
class SettingsScreen extends StatelessWidget {
/// Constructs a [SettingsScreen]
const SettingsScreen({super.key});

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Settings Screen')),
body: const Center(
child: Text('Settings'),
),
);
}
}
32 changes: 32 additions & 0 deletions packages/go_router/example/test/on_exit_test.dart
@@ -0,0 +1,32 @@
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router_examples/on_exit.dart' as example;

void main() {
testWidgets('example works', (WidgetTester tester) async {
await tester.pumpWidget(const example.MyApp());

await tester.tap(find.text('Go to the Details screen'));
await tester.pumpAndSettle();

await tester.tap(find.byType(BackButton));
await tester.pumpAndSettle();

expect(find.text('Are you sure to leave this page?'), findsOneWidget);
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(find.byType(example.DetailsScreen), findsOneWidget);

await tester.tap(find.byType(BackButton));
await tester.pumpAndSettle();

expect(find.text('Are you sure to leave this page?'), findsOneWidget);
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
expect(find.byType(example.HomeScreen), findsOneWidget);
});
}
9 changes: 5 additions & 4 deletions packages/go_router/lib/src/builder.dart
Expand Up @@ -592,10 +592,11 @@ class _PagePopContext {
/// This assumes always pop the last route match for the page.
bool onPopPage(Route<dynamic> route, dynamic result) {
final Page<Object?> page = route.settings as Page<Object?>;

final RouteMatch match = _routeMatchesLookUp[page]!.last;
_routeMatchesLookUp[page]!.removeLast();

return onPopPageWithRouteMatch(route, result, match);
if (onPopPageWithRouteMatch(route, result, match)) {
_routeMatchesLookUp[page]!.removeLast();
return true;
}
return false;
}
}
111 changes: 101 additions & 10 deletions packages/go_router/lib/src/delegate.dart
Expand Up @@ -3,6 +3,7 @@
// found in the LICENSE file.

import 'dart:async';
import 'dart:math' as math;

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

bool _handlePopPageWithRouteMatch(
Route<Object?> route, Object? result, RouteMatch? match) {
if (!route.didPop(result)) {
return false;
if (route.willHandlePopInternally) {
final bool popped = route.didPop(result);
assert(!popped);
return popped;
}
assert(match != null);
final RouteBase routeBase = match!.route;
if (routeBase is! GoRoute || routeBase.onExit == null) {
route.didPop(result);
_completeRouteMatch(result, match);
return true;
}

// The _handlePopPageWithRouteMatch is called during draw frame, schedule
// a microtask in case the onExit callback want to launch dialog or other
// navigator operations.
scheduleMicrotask(() async {
final bool onExitResult =
await routeBase.onExit!(navigatorKey.currentContext!);
if (onExitResult) {
_completeRouteMatch(result, match);
}
});
return false;
}

void _completeRouteMatch(Object? result, RouteMatch match) {
if (match is ImperativeRouteMatch) {
match.complete(result);
}
currentConfiguration = currentConfiguration.remove(match!);
currentConfiguration = currentConfiguration.remove(match);
notifyListeners();
assert(() {
_debugAssertMatchListNotEmpty();
return true;
}());
return true;
}

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

/// For use by the Router architecture as part of the RouterDelegate.
// This class avoids using async to make sure the route is processed
// synchronously if possible.
@override
Future<void> setNewRoutePath(RouteMatchList configuration) {
if (currentConfiguration != configuration) {
currentConfiguration = configuration;
notifyListeners();
if (currentConfiguration == configuration) {
return SynchronousFuture<void>(null);
}

assert(configuration.isNotEmpty || configuration.isError);

final BuildContext? navigatorContext = navigatorKey.currentContext;
// If navigator is not built or disposed, the GoRoute.onExit is irrelevant.
if (navigatorContext != null) {
final int compareUntil = math.min(
currentConfiguration.matches.length,
configuration.matches.length,
);
int indexOfFirstDiff = 0;
for (; indexOfFirstDiff < compareUntil; indexOfFirstDiff++) {
if (currentConfiguration.matches[indexOfFirstDiff] !=
configuration.matches[indexOfFirstDiff]) {
break;
}
}
if (indexOfFirstDiff < currentConfiguration.matches.length) {
final List<GoRoute> exitingGoRoutes = currentConfiguration.matches
.sublist(indexOfFirstDiff)
.map<RouteBase>((RouteMatch match) => match.route)
.whereType<GoRoute>()
.toList();
return _callOnExitStartsAt(exitingGoRoutes.length - 1,
navigatorContext: navigatorContext, routes: exitingGoRoutes)
.then<void>((bool exit) {
if (!exit) {
return SynchronousFuture<void>(null);
}
return _setCurrentConfiguration(configuration);
});
}
}
assert(currentConfiguration.isNotEmpty || currentConfiguration.isError);
// Use [SynchronousFuture] so that the initial url is processed
// synchronously and remove unwanted initial animations on deep-linking

return _setCurrentConfiguration(configuration);
}

/// Calls [GoRoute.onExit] starting from the index
///
/// The returned future resolves to true if all routes below the index all
/// return true. Otherwise, the returned future resolves to false.
static Future<bool> _callOnExitStartsAt(int index,
{required BuildContext navigatorContext, required List<GoRoute> routes}) {
if (index < 0) {
return SynchronousFuture<bool>(true);
}
final GoRoute goRoute = routes[index];
if (goRoute.onExit == null) {
return _callOnExitStartsAt(index - 1,
navigatorContext: navigatorContext, routes: routes);
}

Future<bool> handleOnExitResult(bool exit) {
if (exit) {
return _callOnExitStartsAt(index - 1,
navigatorContext: navigatorContext, routes: routes);
}
return SynchronousFuture<bool>(false);
}

final FutureOr<bool> exitFuture = goRoute.onExit!(navigatorContext);
if (exitFuture is bool) {
return handleOnExitResult(exitFuture);
}
return exitFuture.then<bool>(handleOnExitResult);
}

Future<void> _setCurrentConfiguration(RouteMatchList configuration) {
currentConfiguration = configuration;
notifyListeners();
return SynchronousFuture<void>(null);
}
}
Expand Down

0 comments on commit 0023d01

Please sign in to comment.