diff --git a/packages/go_router/CHANGELOG.md b/packages/go_router/CHANGELOG.md index 31a7b524d73..d8ce441d998 100644 --- a/packages/go_router/CHANGELOG.md +++ b/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 @@ -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. diff --git a/packages/go_router/example/lib/on_exit.dart b/packages/go_router/example/lib/on_exit.dart new file mode 100644 index 00000000000..fba83a7d1fd --- /dev/null +++ b/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: [ + GoRoute( + path: '/', + builder: (BuildContext context, GoRouterState state) { + return const HomeScreen(); + }, + routes: [ + GoRoute( + path: 'details', + builder: (BuildContext context, GoRouterState state) { + return const DetailsScreen(); + }, + onExit: (BuildContext context) async { + final bool? confirmed = await showDialog( + context: context, + builder: (_) { + return AlertDialog( + content: const Text('Are you sure to leave this page?'), + actions: [ + 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: [ + 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: [ + 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'), + ), + ); + } +} diff --git a/packages/go_router/example/test/on_exit_test.dart b/packages/go_router/example/test/on_exit_test.dart new file mode 100644 index 00000000000..86659d9449f --- /dev/null +++ b/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); + }); +} diff --git a/packages/go_router/lib/src/builder.dart b/packages/go_router/lib/src/builder.dart index 1e46417b30f..0f4e4ab8845 100644 --- a/packages/go_router/lib/src/builder.dart +++ b/packages/go_router/lib/src/builder.dart @@ -592,10 +592,11 @@ class _PagePopContext { /// This assumes always pop the last route match for the page. bool onPopPage(Route route, dynamic result) { final Page page = route.settings as Page; - 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; } } diff --git a/packages/go_router/lib/src/delegate.dart b/packages/go_router/lib/src/delegate.dart index 3a341be7912..8e9befe3555 100644 --- a/packages/go_router/lib/src/delegate.dart +++ b/packages/go_router/lib/src/delegate.dart @@ -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'; @@ -97,20 +98,42 @@ class GoRouterDelegate extends RouterDelegate bool _handlePopPageWithRouteMatch( Route 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. @@ -131,15 +154,83 @@ class GoRouterDelegate extends RouterDelegate } /// 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 setNewRoutePath(RouteMatchList configuration) { - if (currentConfiguration != configuration) { - currentConfiguration = configuration; - notifyListeners(); + if (currentConfiguration == configuration) { + return SynchronousFuture(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 exitingGoRoutes = currentConfiguration.matches + .sublist(indexOfFirstDiff) + .map((RouteMatch match) => match.route) + .whereType() + .toList(); + return _callOnExitStartsAt(exitingGoRoutes.length - 1, + navigatorContext: navigatorContext, routes: exitingGoRoutes) + .then((bool exit) { + if (!exit) { + return SynchronousFuture(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 _callOnExitStartsAt(int index, + {required BuildContext navigatorContext, required List routes}) { + if (index < 0) { + return SynchronousFuture(true); + } + final GoRoute goRoute = routes[index]; + if (goRoute.onExit == null) { + return _callOnExitStartsAt(index - 1, + navigatorContext: navigatorContext, routes: routes); + } + + Future handleOnExitResult(bool exit) { + if (exit) { + return _callOnExitStartsAt(index - 1, + navigatorContext: navigatorContext, routes: routes); + } + return SynchronousFuture(false); + } + + final FutureOr exitFuture = goRoute.onExit!(navigatorContext); + if (exitFuture is bool) { + return handleOnExitResult(exitFuture); + } + return exitFuture.then(handleOnExitResult); + } + + Future _setCurrentConfiguration(RouteMatchList configuration) { + currentConfiguration = configuration; + notifyListeners(); return SynchronousFuture(null); } } diff --git a/packages/go_router/lib/src/route.dart b/packages/go_router/lib/src/route.dart index 7c02236ea02..8d587e167a3 100644 --- a/packages/go_router/lib/src/route.dart +++ b/packages/go_router/lib/src/route.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:async'; + import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; @@ -57,6 +59,12 @@ typedef StatefulShellRoutePageBuilder = Page Function( typedef NavigatorBuilder = Widget Function( List? observers, String? restorationScopeId); +/// Signature for function used in [RouteBase.onExit]. +/// +/// If the return value is true or the future resolve to true, the route will +/// exit as usual. Otherwise, the operation will abort. +typedef ExitCallback = FutureOr Function(BuildContext context); + /// The base class for [GoRoute] and [ShellRoute]. /// /// Routes are defined in a tree such that parent routes must match the @@ -201,11 +209,14 @@ class GoRoute extends RouteBase { this.pageBuilder, super.parentNavigatorKey, this.redirect, + this.onExit, super.routes = const [], }) : assert(path.isNotEmpty, 'GoRoute path cannot be empty'), assert(name == null || name.isNotEmpty, 'GoRoute name cannot be empty'), assert(pageBuilder != null || builder != null || redirect != null, 'builder, pageBuilder, or redirect must be provided'), + assert(onExit == null || pageBuilder != null || builder != null, + 'if onExit is provided, one of pageBuilder or builder must be provided'), super._() { // cache the path regexp and parameters _pathRE = patternToRegExp(path, pathParameters); @@ -368,6 +379,54 @@ class GoRoute extends RouteBase { /// re-evaluation will be triggered if the [InheritedWidget] changes. final GoRouterRedirect? redirect; + /// Called when this route is removed from GoRouter's route history. + /// + /// Some example this callback may be called: + /// * This route is removed as the result of [GoRouter.pop]. + /// * This route is no longer in the route history after a [GoRouter.go]. + /// + /// This method can be useful it one wants to launch a dialog for user to + /// confirm if they want to exit the screen. + /// + /// ``` + /// final GoRouter _router = GoRouter( + /// routes: [ + /// GoRoute( + /// path: '/', + /// onExit: (BuildContext context) => showDialog( + /// context: context, + /// builder: (BuildContext context) { + /// return AlertDialog( + /// title: const Text('Do you want to exit this page?'), + /// actions: [ + /// TextButton( + /// style: TextButton.styleFrom( + /// textStyle: Theme.of(context).textTheme.labelLarge, + /// ), + /// child: const Text('Go Back'), + /// onPressed: () { + /// Navigator.of(context).pop(false); + /// }, + /// ), + /// TextButton( + /// style: TextButton.styleFrom( + /// textStyle: Theme.of(context).textTheme.labelLarge, + /// ), + /// child: const Text('Confirm'), + /// onPressed: () { + /// Navigator.of(context).pop(true); + /// }, + /// ), + /// ], + /// ); + /// }, + /// ), + /// ), + /// ], + /// ); + /// ``` + final ExitCallback? onExit; + // TODO(chunhtai): move all regex related help methods to path_utils.dart. /// Match this route against a location. RegExpMatch? matchPatternAsPrefix(String loc) => diff --git a/packages/go_router/pubspec.yaml b/packages/go_router/pubspec.yaml index 83bf1197a33..128569f1f1a 100644 --- a/packages/go_router/pubspec.yaml +++ b/packages/go_router/pubspec.yaml @@ -1,7 +1,7 @@ name: go_router description: A declarative router for Flutter based on Navigation 2 supporting deep linking, data-driven routes and more -version: 10.1.4 +version: 10.2.0 repository: https://github.com/flutter/packages/tree/main/packages/go_router issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+go_router%22 diff --git a/packages/go_router/test/on_exit_test.dart b/packages/go_router/test/on_exit_test.dart new file mode 100644 index 00000000000..cd8789a64af --- /dev/null +++ b/packages/go_router/test/on_exit_test.dart @@ -0,0 +1,169 @@ +// 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 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +import 'test_helpers.dart'; + +void main() { + testWidgets('back button works synchronously', (WidgetTester tester) async { + bool allow = false; + final UniqueKey home = UniqueKey(); + final UniqueKey page1 = UniqueKey(); + final List routes = [ + GoRoute( + path: '/', + builder: (BuildContext context, GoRouterState state) => + DummyScreen(key: home), + routes: [ + GoRoute( + path: '1', + builder: (BuildContext context, GoRouterState state) => + DummyScreen(key: page1), + onExit: (BuildContext context) { + return allow; + }, + ) + ], + ), + ]; + + final GoRouter router = + await createRouter(routes, tester, initialLocation: '/1'); + expect(find.byKey(page1), findsOneWidget); + + router.pop(); + await tester.pumpAndSettle(); + expect(find.byKey(page1), findsOneWidget); + + allow = true; + router.pop(); + await tester.pumpAndSettle(); + expect(find.byKey(home), findsOneWidget); + }); + + testWidgets('context.go works synchronously', (WidgetTester tester) async { + bool allow = false; + final UniqueKey home = UniqueKey(); + final UniqueKey page1 = UniqueKey(); + final List routes = [ + GoRoute( + path: '/', + builder: (BuildContext context, GoRouterState state) => + DummyScreen(key: home), + ), + GoRoute( + path: '/1', + builder: (BuildContext context, GoRouterState state) => + DummyScreen(key: page1), + onExit: (BuildContext context) { + return allow; + }, + ) + ]; + + final GoRouter router = + await createRouter(routes, tester, initialLocation: '/1'); + expect(find.byKey(page1), findsOneWidget); + + router.go('/'); + await tester.pumpAndSettle(); + expect(find.byKey(page1), findsOneWidget); + + allow = true; + router.go('/'); + await tester.pumpAndSettle(); + expect(find.byKey(home), findsOneWidget); + }); + + testWidgets('back button works asynchronously', (WidgetTester tester) async { + Completer allow = Completer(); + final UniqueKey home = UniqueKey(); + final UniqueKey page1 = UniqueKey(); + final List routes = [ + GoRoute( + path: '/', + builder: (BuildContext context, GoRouterState state) => + DummyScreen(key: home), + routes: [ + GoRoute( + path: '1', + builder: (BuildContext context, GoRouterState state) => + DummyScreen(key: page1), + onExit: (BuildContext context) async { + return allow.future; + }, + ) + ], + ), + ]; + + final GoRouter router = + await createRouter(routes, tester, initialLocation: '/1'); + expect(find.byKey(page1), findsOneWidget); + + router.pop(); + await tester.pumpAndSettle(); + expect(find.byKey(page1), findsOneWidget); + + allow.complete(false); + await tester.pumpAndSettle(); + expect(find.byKey(page1), findsOneWidget); + + allow = Completer(); + router.pop(); + await tester.pumpAndSettle(); + expect(find.byKey(page1), findsOneWidget); + + allow.complete(true); + await tester.pumpAndSettle(); + expect(find.byKey(home), findsOneWidget); + }); + + testWidgets('context.go works asynchronously', (WidgetTester tester) async { + Completer allow = Completer(); + final UniqueKey home = UniqueKey(); + final UniqueKey page1 = UniqueKey(); + final List routes = [ + GoRoute( + path: '/', + builder: (BuildContext context, GoRouterState state) => + DummyScreen(key: home), + ), + GoRoute( + path: '/1', + builder: (BuildContext context, GoRouterState state) => + DummyScreen(key: page1), + onExit: (BuildContext context) async { + return allow.future; + }, + ) + ]; + + final GoRouter router = + await createRouter(routes, tester, initialLocation: '/1'); + expect(find.byKey(page1), findsOneWidget); + + router.go('/'); + await tester.pumpAndSettle(); + expect(find.byKey(page1), findsOneWidget); + + allow.complete(false); + await tester.pumpAndSettle(); + expect(find.byKey(page1), findsOneWidget); + + allow = Completer(); + router.go('/'); + await tester.pumpAndSettle(); + expect(find.byKey(page1), findsOneWidget); + + allow.complete(true); + await tester.pumpAndSettle(); + expect(find.byKey(home), findsOneWidget); + }); +}