diff --git a/packages/flutter/lib/src/services/system_channels.dart b/packages/flutter/lib/src/services/system_channels.dart index 43e6bf29f2..61fcb88b9a 100644 --- a/packages/flutter/lib/src/services/system_channels.dart +++ b/packages/flutter/lib/src/services/system_channels.dart @@ -32,10 +32,19 @@ class SystemChannels { /// The following methods are used for the opposite direction data flow. The /// framework notifies the engine about the route changes. /// - /// * `routeUpdated`, which is called when current route has changed. + /// * `selectSingleEntryHistory`, which enables a single-entry history mode. /// - /// * `routeInformationUpdated`, which is called by the [Router] when the - /// application navigate to a new location. + /// * `selectMultiEntryHistory`, which enables a multiple-entry history mode. + /// + /// * `routeInformationUpdated`, which is called when the application + /// navigates to a new location, and which takes two arguments, `location` + /// (a URL) and `state` (an object). + /// + /// * `routeUpdated`, a deprecated API which can be called in the same + /// situations as `routeInformationUpdated` but whose arguments are + /// `routeName` (a URL) and `previousRouteName` (which is ignored). + /// + /// These APIs are exposed by the [SystemNavigator] class. /// /// See also: /// diff --git a/packages/flutter/lib/src/services/system_navigator.dart b/packages/flutter/lib/src/services/system_navigator.dart index c6793e3ca2..90bab91e13 100644 --- a/packages/flutter/lib/src/services/system_navigator.dart +++ b/packages/flutter/lib/src/services/system_navigator.dart @@ -33,15 +33,50 @@ class SystemNavigator { await SystemChannels.platform.invokeMethod('SystemNavigator.pop', animated); } + /// Selects the single-entry history mode. + /// + /// On web, this switches the browser history model to one that only tracks a + /// single entry, so that calling [routeInformationUpdated] replaces the + /// current entry. + /// + /// Currently, this is ignored on other platforms. + /// + /// See also: + /// + /// * [selectMultiEntryHistory], which enables the browser history to have + /// multiple entries. + static Future selectSingleEntryHistory() { + return SystemChannels.navigation.invokeMethod('selectSingleEntryHistory'); + } + + /// Selects the multiple-entry history mode. + /// + /// On web, this switches the browser history model to one that tracks alll + /// updates to [routeInformationUpdated] to form a history stack. This is the + /// default. + /// + /// Currently, this is ignored on other platforms. + /// + /// See also: + /// + /// * [selectSingleEntryHistory], which forces the history to only have one + /// entry. + static Future selectMultiEntryHistory() { + return SystemChannels.navigation.invokeMethod('selectMultiEntryHistory'); + } + /// Notifies the platform for a route information change. /// - /// On Web, creates a new browser history entry and update URL with the route - /// information. - static void routeInformationUpdated({ + /// On web, creates a new browser history entry and update URL with the route + /// information. Whether the history holds one entry or multiple entries is + /// determined by [selectSingleEntryHistory] and [selectMultiEntryHistory]. + /// + /// Currently, this is ignored on other platforms. + static Future routeInformationUpdated({ required String location, Object? state, }) { - SystemChannels.navigation.invokeMethod( + return SystemChannels.navigation.invokeMethod( 'routeInformationUpdated', { 'location': location, @@ -50,14 +85,22 @@ class SystemNavigator { ); } - /// Notifies the platform of a route change. + /// Notifies the platform of a route change, and selects single-entry history + /// mode. /// - /// On Web, updates the URL bar with the [routeName]. - static void routeUpdated({ + /// This is equivalent to calling [selectSingleEntryHistory] and + /// [routeInformationUpdated] together. + /// + /// The `previousRouteName` argument is ignored. + @Deprecated( + 'Use routeInformationUpdated instead. ' + 'This feature was deprecated after v2.3.0-1.0.pre.' + ) + static Future routeUpdated({ String? routeName, String? previousRouteName, }) { - SystemChannels.navigation.invokeMethod( + return SystemChannels.navigation.invokeMethod( 'routeUpdated', { 'previousRouteName': previousRouteName, diff --git a/packages/flutter/lib/src/widgets/navigator.dart b/packages/flutter/lib/src/widgets/navigator.dart index ed5041c272..f5eb39b5d3 100644 --- a/packages/flutter/lib/src/widgets/navigator.dart +++ b/packages/flutter/lib/src/widgets/navigator.dart @@ -1624,6 +1624,13 @@ class Navigator extends StatefulWidget { /// route update message to the engine when it detects top-most route changes. /// The messages are used by the web engine to update the browser URL bar. /// + /// If the property is set to true when the [Navigator] is first created, + /// single-entry history mode is requested using + /// [SystemNavigator.selectSingleEntryHistory]. This means this property + /// should not be used at the same time as [PlatformRouteInformationProvider] + /// is used with a [Router] (including when used with [MaterialApp.router], + /// for example). + /// /// If there are multiple navigators in the widget tree, at most one of them /// can set this property to true (typically, the top-most one created from /// the [WidgetsApp]). Otherwise, the web engine may receive multiple route @@ -3397,6 +3404,10 @@ class NavigatorState extends State with TickerProviderStateMixin, Res .getElementForInheritedWidgetOfExactType() ?.widget as HeroControllerScope?; _updateHeroController(heroControllerScope?.controller); + + if (widget.reportsRouteUpdateToEngine) { + SystemNavigator.selectSingleEntryHistory(); + } } // Use [_nextPagelessRestorationScopeId] to get the next id. @@ -4062,17 +4073,14 @@ class NavigatorState extends State with TickerProviderStateMixin, Res // notifications. _flushRouteAnnouncement(); - // Announces route name changes. + // Announce route name changes. if (widget.reportsRouteUpdateToEngine) { final _RouteEntry? lastEntry = _history.cast<_RouteEntry?>().lastWhere( (_RouteEntry? e) => e != null && _RouteEntry.isPresentPredicate(e), orElse: () => null, ); final String? routeName = lastEntry?.route.settings.name; - if (routeName != _lastAnnouncedRouteName) { - SystemNavigator.routeUpdated( - routeName: routeName, - previousRouteName: _lastAnnouncedRouteName, - ); + if (routeName != null && routeName != _lastAnnouncedRouteName) { + SystemNavigator.routeInformationUpdated(location: routeName); _lastAnnouncedRouteName = routeName; } } diff --git a/packages/flutter/lib/src/widgets/router.dart b/packages/flutter/lib/src/widgets/router.dart index 528fafc429..d8d0d1a649 100644 --- a/packages/flutter/lib/src/widgets/router.dart +++ b/packages/flutter/lib/src/widgets/router.dart @@ -1318,6 +1318,11 @@ abstract class RouteInformationProvider extends ValueListenable log = []; + final List log = []; + Future verify(AsyncCallback test, List expectations) async { + log.clear(); + await test(); + expect(log, expectations); + } + + test('System navigator control test - platform messages', () async { SystemChannels.platform.setMockMethodCallHandler((MethodCall methodCall) async { log.add(methodCall); }); - await SystemNavigator.pop(); + await verify(() => SystemNavigator.pop(), [ + isMethodCall('SystemNavigator.pop', arguments: null), + ]); - expect(log, hasLength(1)); - expect(log.single, isMethodCall('SystemNavigator.pop', arguments: null)); + SystemChannels.platform.setMockMethodCallHandler(null); + }); + + test('System navigator control test - navigation messages', () async { + SystemChannels.navigation.setMockMethodCallHandler((MethodCall methodCall) async { + log.add(methodCall); + }); + + await verify(() => SystemNavigator.selectSingleEntryHistory(), [ + isMethodCall('selectSingleEntryHistory', arguments: null), + ]); + + await verify(() => SystemNavigator.selectMultiEntryHistory(), [ + isMethodCall('selectMultiEntryHistory', arguments: null), + ]); + + await verify(() => SystemNavigator.routeInformationUpdated(location: 'a'), [ + isMethodCall('routeInformationUpdated', arguments: { 'location': 'a', 'state': null }), + ]); + + await verify(() => SystemNavigator.routeInformationUpdated(location: 'a', state: true), [ + isMethodCall('routeInformationUpdated', arguments: { 'location': 'a', 'state': true }), + ]); + + await verify(() => SystemNavigator.routeUpdated(routeName: 'a', previousRouteName: 'b'), [ + isMethodCall('routeUpdated', arguments: { 'routeName': 'a', 'previousRouteName': 'b' }), + ]); + + SystemChannels.navigation.setMockMethodCallHandler(null); }); } diff --git a/packages/flutter/test/widgets/route_notification_messages_test.dart b/packages/flutter/test/widgets/route_notification_messages_test.dart index ea6188875f..3aa1e21dca 100644 --- a/packages/flutter/test/widgets/route_notification_messages_test.dart +++ b/packages/flutter/test/widgets/route_notification_messages_test.dart @@ -63,46 +63,46 @@ void main() { routes: routes, )); - expect(log, hasLength(1)); - expect( - log.last, - isMethodCall( - 'routeUpdated', + expect(log, [ + isMethodCall('selectSingleEntryHistory', arguments: null), + isMethodCall('routeInformationUpdated', arguments: { - 'previousRouteName': null, - 'routeName': '/', + 'location': '/', + 'state': null, }, ), - ); + ]); + log.clear(); await tester.tap(find.text('/')); await tester.pump(); await tester.pump(const Duration(seconds: 1)); - expect(log, hasLength(2)); + expect(log, hasLength(1)); expect( log.last, isMethodCall( - 'routeUpdated', + 'routeInformationUpdated', arguments: { - 'previousRouteName': '/', - 'routeName': '/A', + 'location': '/A', + 'state': null, }, ), ); + log.clear(); await tester.tap(find.text('A')); await tester.pump(); await tester.pump(const Duration(seconds: 1)); - expect(log, hasLength(3)); + expect(log, hasLength(1)); expect( log.last, isMethodCall( - 'routeUpdated', + 'routeInformationUpdated', arguments: { - 'previousRouteName': '/A', - 'routeName': '/', + 'location': '/', + 'state': null, }, ), ); @@ -168,46 +168,46 @@ void main() { routes: routes, )); - expect(log, hasLength(1)); - expect( - log.last, - isMethodCall( - 'routeUpdated', + expect(log, [ + isMethodCall('selectSingleEntryHistory', arguments: null), + isMethodCall('routeInformationUpdated', arguments: { - 'previousRouteName': null, - 'routeName': '/', + 'location': '/', + 'state': null, }, ), - ); + ]); + log.clear(); await tester.tap(find.text('/')); await tester.pump(); await tester.pump(const Duration(seconds: 1)); - expect(log, hasLength(2)); + expect(log, hasLength(1)); expect( log.last, isMethodCall( - 'routeUpdated', + 'routeInformationUpdated', arguments: { - 'previousRouteName': '/', - 'routeName': '/A', + 'location': '/A', + 'state': null, }, ), ); + log.clear(); await tester.tap(find.text('A')); await tester.pump(); await tester.pump(const Duration(seconds: 1)); - expect(log, hasLength(3)); + expect(log, hasLength(1)); expect( log.last, isMethodCall( - 'routeUpdated', + 'routeInformationUpdated', arguments: { - 'previousRouteName': '/A', - 'routeName': '/B', + 'location': '/B', + 'state': null, }, ), ); @@ -237,27 +237,22 @@ void main() { }, )); - expect(log, hasLength(1)); - expect( - log.last, - isMethodCall('routeUpdated', arguments: { - 'previousRouteName': null, - 'routeName': '/home', - }), - ); + expect(log, [ + isMethodCall('selectSingleEntryHistory', arguments: null), + isMethodCall('routeInformationUpdated', + arguments: { + 'location': '/home', + 'state': null, + }, + ), + ]); + log.clear(); await tester.tap(find.text('Home')); await tester.pump(); await tester.pump(const Duration(seconds: 1)); - expect(log, hasLength(2)); - expect( - log.last, - isMethodCall('routeUpdated', arguments: { - 'previousRouteName': '/home', - 'routeName': null, - }), - ); + expect(log, isEmpty); }); testWidgets('PlatformRouteInformationProvider reports URL', (WidgetTester tester) async { @@ -294,16 +289,13 @@ void main() { await tester.pump(); expect(find.text('update'), findsOneWidget); - expect(log, hasLength(1)); - // TODO(chunhtai): check routeInformationUpdated instead once the engine - // side is done. - expect( - log.last, + expect(log, [ + isMethodCall('selectMultiEntryHistory', arguments: null), isMethodCall('routeInformationUpdated', arguments: { 'location': 'update', 'state': 'state', }), - ); + ]); }); }