From f9fd71bc78161bf87cb07e098efa723c9d9e038f Mon Sep 17 00:00:00 2001 From: chunhtai <47866232+chunhtai@users.noreply.github.com> Date: Fri, 7 Aug 2020 20:26:05 -0700 Subject: [PATCH] Implement Router widget and widgets app api (#60299) --- packages/flutter/lib/src/cupertino/app.dart | 186 ++- packages/flutter/lib/src/material/app.dart | 252 +++- .../lib/src/services/system_channels.dart | 16 +- .../lib/src/services/system_navigator.dart | 33 + packages/flutter/lib/src/widgets/app.dart | 196 ++- packages/flutter/lib/src/widgets/binding.dart | 35 +- .../flutter/lib/src/widgets/navigator.dart | 7 +- .../widgets/route_notification_messages.dart | 41 - packages/flutter/lib/src/widgets/router.dart | 1248 +++++++++++++++++ packages/flutter/lib/widgets.dart | 1 + packages/flutter/test/cupertino/app_test.dart | 99 ++ packages/flutter/test/material/app_test.dart | 99 ++ packages/flutter/test/widgets/app_test.dart | 112 ++ .../flutter/test/widgets/binding_test.dart | 42 + .../route_notification_messages_test.dart | 112 ++ .../flutter/test/widgets/router_test.dart | 705 ++++++++++ 16 files changed, 2997 insertions(+), 187 deletions(-) delete mode 100644 packages/flutter/lib/src/widgets/route_notification_messages.dart create mode 100644 packages/flutter/lib/src/widgets/router.dart create mode 100644 packages/flutter/test/widgets/router_test.dart diff --git a/packages/flutter/lib/src/cupertino/app.dart b/packages/flutter/lib/src/cupertino/app.dart index 8481128d3b..6e1f713a90 100644 --- a/packages/flutter/lib/src/cupertino/app.dart +++ b/packages/flutter/lib/src/cupertino/app.dart @@ -104,6 +104,50 @@ class CupertinoApp extends StatefulWidget { assert(checkerboardOffscreenLayers != null), assert(showSemanticsDebugger != null), assert(debugShowCheckedModeBanner != null), + routeInformationProvider = null, + routeInformationParser = null, + routerDelegate = null, + backButtonDispatcher = null, + super(key: key); + + /// Creates a [CupertinoApp] that uses the [Router] instead of a [Navigator]. + const CupertinoApp.router({ + Key key, + this.routeInformationProvider, + @required this.routeInformationParser, + @required this.routerDelegate, + this.backButtonDispatcher, + this.theme, + this.builder, + this.title = '', + this.onGenerateTitle, + this.color, + this.locale, + this.localizationsDelegates, + this.localeListResolutionCallback, + this.localeResolutionCallback, + this.supportedLocales = const [Locale('en', 'US')], + this.showPerformanceOverlay = false, + this.checkerboardRasterCacheImages = false, + this.checkerboardOffscreenLayers = false, + this.showSemanticsDebugger = false, + this.debugShowCheckedModeBanner = true, + this.shortcuts, + this.actions, + }) : assert(title != null), + assert(showPerformanceOverlay != null), + assert(checkerboardRasterCacheImages != null), + assert(checkerboardOffscreenLayers != null), + assert(showSemanticsDebugger != null), + assert(debugShowCheckedModeBanner != null), + navigatorObservers = null, + navigatorKey = null, + onGenerateRoute = null, + home = null, + onGenerateInitialRoutes = null, + onUnknownRoute = null, + routes = null, + initialRoute = null, super(key: key); /// {@macro flutter.widgets.widgetsApp.navigatorKey} @@ -143,6 +187,18 @@ class CupertinoApp extends StatefulWidget { /// {@macro flutter.widgets.widgetsApp.navigatorObservers} final List navigatorObservers; + /// {@macro flutter.widgets.widgetsApp.routeInformationProvider} + final RouteInformationProvider routeInformationProvider; + + /// {@macro flutter.widgets.widgetsApp.routeInformationParser} + final RouteInformationParser routeInformationParser; + + /// {@macro flutter.widgets.widgetsApp.routerDelegate} + final RouterDelegate routerDelegate; + + /// {@macro flutter.widgets.widgetsApp.backButtonDispatcher} + final BackButtonDispatcher backButtonDispatcher; + /// {@macro flutter.widgets.widgetsApp.builder} final TransitionBuilder builder; @@ -286,6 +342,7 @@ class _AlwaysCupertinoScrollBehavior extends ScrollBehavior { class _CupertinoAppState extends State { HeroController _heroController; + bool get _usesRouter => widget.routerDelegate != null; @override void initState() { @@ -304,6 +361,83 @@ class _CupertinoAppState extends State { yield DefaultCupertinoLocalizations.delegate; } + Widget _inspectorSelectButtonBuilder(BuildContext context, VoidCallback onPressed) { + return CupertinoButton.filled( + child: const Icon( + CupertinoIcons.search, + size: 28.0, + color: CupertinoColors.white, + ), + padding: EdgeInsets.zero, + onPressed: onPressed, + ); + } + + WidgetsApp _buildWidgetApp(BuildContext context) { + final CupertinoThemeData effectiveThemeData = CupertinoTheme.of(context); + final Color color = CupertinoDynamicColor.resolve(widget.color ?? effectiveThemeData.primaryColor, context); + + if (_usesRouter) { + return WidgetsApp.router( + key: GlobalObjectKey(this), + routeInformationProvider: widget.routeInformationProvider, + routeInformationParser: widget.routeInformationParser, + routerDelegate: widget.routerDelegate, + backButtonDispatcher: widget.backButtonDispatcher, + builder: widget.builder, + title: widget.title, + onGenerateTitle: widget.onGenerateTitle, + textStyle: effectiveThemeData.textTheme.textStyle, + color: color, + locale: widget.locale, + localizationsDelegates: _localizationsDelegates, + localeResolutionCallback: widget.localeResolutionCallback, + localeListResolutionCallback: widget.localeListResolutionCallback, + supportedLocales: widget.supportedLocales, + showPerformanceOverlay: widget.showPerformanceOverlay, + checkerboardRasterCacheImages: widget.checkerboardRasterCacheImages, + checkerboardOffscreenLayers: widget.checkerboardOffscreenLayers, + showSemanticsDebugger: widget.showSemanticsDebugger, + debugShowCheckedModeBanner: widget.debugShowCheckedModeBanner, + inspectorSelectButtonBuilder: _inspectorSelectButtonBuilder, + shortcuts: widget.shortcuts, + actions: widget.actions, + ); + } + return WidgetsApp( + key: GlobalObjectKey(this), + navigatorKey: widget.navigatorKey, + navigatorObservers: widget.navigatorObservers, + pageRouteBuilder: (RouteSettings settings, WidgetBuilder builder) { + return CupertinoPageRoute(settings: settings, builder: builder); + }, + home: widget.home, + routes: widget.routes, + initialRoute: widget.initialRoute, + onGenerateRoute: widget.onGenerateRoute, + onGenerateInitialRoutes: widget.onGenerateInitialRoutes, + onUnknownRoute: widget.onUnknownRoute, + builder: widget.builder, + title: widget.title, + onGenerateTitle: widget.onGenerateTitle, + textStyle: effectiveThemeData.textTheme.textStyle, + color: color, + locale: widget.locale, + localizationsDelegates: _localizationsDelegates, + localeResolutionCallback: widget.localeResolutionCallback, + localeListResolutionCallback: widget.localeListResolutionCallback, + supportedLocales: widget.supportedLocales, + showPerformanceOverlay: widget.showPerformanceOverlay, + checkerboardRasterCacheImages: widget.checkerboardRasterCacheImages, + checkerboardOffscreenLayers: widget.checkerboardOffscreenLayers, + showSemanticsDebugger: widget.showSemanticsDebugger, + debugShowCheckedModeBanner: widget.debugShowCheckedModeBanner, + inspectorSelectButtonBuilder: _inspectorSelectButtonBuilder, + shortcuts: widget.shortcuts, + actions: widget.actions, + ); + } + @override Widget build(BuildContext context) { final CupertinoThemeData effectiveThemeData = widget.theme ?? const CupertinoThemeData(); @@ -314,53 +448,11 @@ class _CupertinoAppState extends State { data: CupertinoUserInterfaceLevelData.base, child: CupertinoTheme( data: effectiveThemeData, - child: Builder( - builder: (BuildContext context) { - return HeroControllerScope( - controller: _heroController, - child: WidgetsApp( - key: GlobalObjectKey(this), - navigatorKey: widget.navigatorKey, - navigatorObservers: widget.navigatorObservers, - pageRouteBuilder: (RouteSettings settings, WidgetBuilder builder) => - CupertinoPageRoute(settings: settings, builder: builder), - home: widget.home, - routes: widget.routes, - initialRoute: widget.initialRoute, - onGenerateRoute: widget.onGenerateRoute, - onGenerateInitialRoutes: widget.onGenerateInitialRoutes, - onUnknownRoute: widget.onUnknownRoute, - builder: widget.builder, - title: widget.title, - onGenerateTitle: widget.onGenerateTitle, - textStyle: CupertinoTheme.of(context).textTheme.textStyle, - color: CupertinoDynamicColor.resolve(widget.color ?? effectiveThemeData.primaryColor, context), - locale: widget.locale, - localizationsDelegates: _localizationsDelegates, - localeResolutionCallback: widget.localeResolutionCallback, - localeListResolutionCallback: widget.localeListResolutionCallback, - supportedLocales: widget.supportedLocales, - showPerformanceOverlay: widget.showPerformanceOverlay, - checkerboardRasterCacheImages: widget.checkerboardRasterCacheImages, - checkerboardOffscreenLayers: widget.checkerboardOffscreenLayers, - showSemanticsDebugger: widget.showSemanticsDebugger, - debugShowCheckedModeBanner: widget.debugShowCheckedModeBanner, - inspectorSelectButtonBuilder: (BuildContext context, VoidCallback onPressed) { - return CupertinoButton.filled( - child: const Icon( - CupertinoIcons.search, - size: 28.0, - color: CupertinoColors.white, - ), - padding: EdgeInsets.zero, - onPressed: onPressed, - ); - }, - shortcuts: widget.shortcuts, - actions: widget.actions, - ), - ); - }, + child: HeroControllerScope( + controller: _heroController, + child: Builder( + builder: _buildWidgetApp, + ), ), ), ), diff --git a/packages/flutter/lib/src/material/app.dart b/packages/flutter/lib/src/material/app.dart index b1f5db5df1..a1c7851c20 100644 --- a/packages/flutter/lib/src/material/app.dart +++ b/packages/flutter/lib/src/material/app.dart @@ -7,6 +7,7 @@ import 'dart:ui' as ui; import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; @@ -205,6 +206,58 @@ class MaterialApp extends StatefulWidget { assert(checkerboardOffscreenLayers != null), assert(showSemanticsDebugger != null), assert(debugShowCheckedModeBanner != null), + routeInformationProvider = null, + routeInformationParser = null, + routerDelegate = null, + backButtonDispatcher = null, + super(key: key); + + /// Creates a [MaterialApp] that uses the [Router] instead of a [Navigator]. + const MaterialApp.router({ + Key key, + this.routeInformationProvider, + @required this.routeInformationParser, + @required this.routerDelegate, + this.backButtonDispatcher, + this.builder, + this.title = '', + this.onGenerateTitle, + this.color, + this.theme, + this.darkTheme, + this.highContrastTheme, + this.highContrastDarkTheme, + this.themeMode = ThemeMode.system, + this.locale, + this.localizationsDelegates, + this.localeListResolutionCallback, + this.localeResolutionCallback, + this.supportedLocales = const [Locale('en', 'US')], + this.debugShowMaterialGrid = false, + this.showPerformanceOverlay = false, + this.checkerboardRasterCacheImages = false, + this.checkerboardOffscreenLayers = false, + this.showSemanticsDebugger = false, + this.debugShowCheckedModeBanner = true, + this.shortcuts, + this.actions, + }) : assert(routeInformationParser != null), + assert(routerDelegate != null), + assert(title != null), + assert(debugShowMaterialGrid != null), + assert(showPerformanceOverlay != null), + assert(checkerboardRasterCacheImages != null), + assert(checkerboardOffscreenLayers != null), + assert(showSemanticsDebugger != null), + assert(debugShowCheckedModeBanner != null), + navigatorObservers = null, + navigatorKey = null, + onGenerateRoute = null, + home = null, + onGenerateInitialRoutes = null, + onUnknownRoute = null, + routes = null, + initialRoute = null, super(key: key); /// {@macro flutter.widgets.widgetsApp.navigatorKey} @@ -238,6 +291,18 @@ class MaterialApp extends StatefulWidget { /// {@macro flutter.widgets.widgetsApp.navigatorObservers} final List navigatorObservers; + /// {@macro flutter.widgets.widgetsApp.routeInformationProvider} + final RouteInformationProvider routeInformationProvider; + + /// {@macro flutter.widgets.widgetsApp.routeInformationParser} + final RouteInformationParser routeInformationParser; + + /// {@macro flutter.widgets.widgetsApp.routerDelegate} + final RouterDelegate routerDelegate; + + /// {@macro flutter.widgets.widgetsApp.backButtonDispatcher} + final BackButtonDispatcher backButtonDispatcher; + /// {@macro flutter.widgets.widgetsApp.builder} /// /// Material specific features such as [showDialog] and [showMenu], and widgets @@ -611,6 +676,8 @@ class _MaterialScrollBehavior extends ScrollBehavior { class _MaterialAppState extends State { HeroController _heroController; + bool get _usesRouter => widget.routerDelegate != null; + @override void initState() { super.initState(); @@ -629,75 +696,77 @@ class _MaterialAppState extends State { yield DefaultCupertinoLocalizations.delegate; } - @override - Widget build(BuildContext context) { - Widget result = HeroControllerScope( - controller: _heroController, - child: WidgetsApp( + Widget _inspectorSelectButtonBuilder(BuildContext context, VoidCallback onPressed) { + return FloatingActionButton( + child: const Icon(Icons.search), + onPressed: onPressed, + mini: true, + ); + } + + Widget _materialBuilder(BuildContext context, Widget child) { + // Resolve which theme to use based on brightness and high contrast. + final ThemeMode mode = widget.themeMode ?? ThemeMode.system; + final Brightness platformBrightness = MediaQuery.platformBrightnessOf(context); + final bool useDarkTheme = mode == ThemeMode.dark + || (mode == ThemeMode.system && platformBrightness == ui.Brightness.dark); + final bool highContrast = MediaQuery.highContrastOf(context); + ThemeData theme; + + if (useDarkTheme && highContrast && widget.highContrastDarkTheme != null) { + theme = widget.highContrastDarkTheme; + } else if (useDarkTheme && widget.darkTheme != null) { + theme = widget.darkTheme; + } else if (highContrast && widget.highContrastTheme != null) { + theme = widget.highContrastTheme; + } + theme ??= widget.theme ?? ThemeData.light(); + + return AnimatedTheme( + data: theme, + isMaterialAppTheme: true, + child: widget.builder != null + ? Builder( + builder: (BuildContext context) { + // Why are we surrounding a builder with a builder? + // + // The widget.builder may contain code that invokes + // Theme.of(), which should return the theme we selected + // above in AnimatedTheme. However, if we invoke + // widget.builder() directly as the child of AnimatedTheme + // then there is no Context separating them, and the + // widget.builder() will not find the theme. Therefore, we + // surround widget.builder with yet another builder so that + // a context separates them and Theme.of() correctly + // resolves to the theme we passed to AnimatedTheme. + return widget.builder(context, child); + }, + ) + : child, + ); + } + + Widget _buildWidgetApp(BuildContext context) { + // The color property is always pulled from the light theme, even if dark + // mode is activated. This was done to simplify the technical details + // of switching themes and it was deemed acceptable because this color + // property is only used on old Android OSes to color the app bar in + // Android's switcher UI. + // + // blue is the primary color of the default theme. + final Color materialColor = widget.color ?? widget.theme?.primaryColor ?? Colors.blue; + if (_usesRouter) { + return WidgetsApp.router( key: GlobalObjectKey(this), - navigatorKey: widget.navigatorKey, - navigatorObservers: widget.navigatorObservers, - pageRouteBuilder: (RouteSettings settings, WidgetBuilder builder) { - return MaterialPageRoute(settings: settings, builder: builder); - }, - home: widget.home, - routes: widget.routes, - initialRoute: widget.initialRoute, - onGenerateRoute: widget.onGenerateRoute, - onGenerateInitialRoutes: widget.onGenerateInitialRoutes, - onUnknownRoute: widget.onUnknownRoute, - builder: (BuildContext context, Widget child) { - // Resolve which theme to use based on brightness and high contrast. - final ThemeMode mode = widget.themeMode ?? ThemeMode.system; - final Brightness platformBrightness = MediaQuery.platformBrightnessOf(context); - final bool useDarkTheme = mode == ThemeMode.dark - || (mode == ThemeMode.system && platformBrightness == ui.Brightness.dark); - final bool highContrast = MediaQuery.highContrastOf(context); - ThemeData theme; - - if (useDarkTheme && highContrast && widget.highContrastDarkTheme != null) { - theme = widget.highContrastDarkTheme; - } else if (useDarkTheme && widget.darkTheme != null) { - theme = widget.darkTheme; - } else if (highContrast && widget.highContrastTheme != null) { - theme = widget.highContrastTheme; - } - theme ??= widget.theme ?? ThemeData.light(); - - return AnimatedTheme( - data: theme, - isMaterialAppTheme: true, - child: widget.builder != null - ? Builder( - builder: (BuildContext context) { - // Why are we surrounding a builder with a builder? - // - // The widget.builder may contain code that invokes - // Theme.of(), which should return the theme we selected - // above in AnimatedTheme. However, if we invoke - // widget.builder() directly as the child of AnimatedTheme - // then there is no Context separating them, and the - // widget.builder() will not find the theme. Therefore, we - // surround widget.builder with yet another builder so that - // a context separates them and Theme.of() correctly - // resolves to the theme we passed to AnimatedTheme. - return widget.builder(context, child); - }, - ) - : child, - ); - }, + routeInformationProvider: widget.routeInformationProvider, + routeInformationParser: widget.routeInformationParser, + routerDelegate: widget.routerDelegate, + backButtonDispatcher: widget.backButtonDispatcher, + builder: _materialBuilder, title: widget.title, onGenerateTitle: widget.onGenerateTitle, textStyle: _errorTextStyle, - // The color property is always pulled from the light theme, even if dark - // mode is activated. This was done to simplify the technical details - // of switching themes and it was deemed acceptable because this color - // property is only used on old Android OSes to color the app bar in - // Android's switcher UI. - // - // blue is the primary color of the default theme - color: widget.color ?? widget.theme?.primaryColor ?? Colors.blue, + color: materialColor, locale: widget.locale, localizationsDelegates: _localizationsDelegates, localeResolutionCallback: widget.localeResolutionCallback, @@ -708,17 +777,49 @@ class _MaterialAppState extends State { checkerboardOffscreenLayers: widget.checkerboardOffscreenLayers, showSemanticsDebugger: widget.showSemanticsDebugger, debugShowCheckedModeBanner: widget.debugShowCheckedModeBanner, - inspectorSelectButtonBuilder: (BuildContext context, VoidCallback onPressed) { - return FloatingActionButton( - child: const Icon(Icons.search), - onPressed: onPressed, - mini: true, - ); - }, + inspectorSelectButtonBuilder: _inspectorSelectButtonBuilder, shortcuts: widget.shortcuts, actions: widget.actions, - ), + ); + } + + return WidgetsApp( + key: GlobalObjectKey(this), + navigatorKey: widget.navigatorKey, + navigatorObservers: widget.navigatorObservers, + pageRouteBuilder: (RouteSettings settings, WidgetBuilder builder) { + return MaterialPageRoute(settings: settings, builder: builder); + }, + home: widget.home, + routes: widget.routes, + initialRoute: widget.initialRoute, + onGenerateRoute: widget.onGenerateRoute, + onGenerateInitialRoutes: widget.onGenerateInitialRoutes, + onUnknownRoute: widget.onUnknownRoute, + builder: _materialBuilder, + title: widget.title, + onGenerateTitle: widget.onGenerateTitle, + textStyle: _errorTextStyle, + color: materialColor, + locale: widget.locale, + localizationsDelegates: _localizationsDelegates, + localeResolutionCallback: widget.localeResolutionCallback, + localeListResolutionCallback: widget.localeListResolutionCallback, + supportedLocales: widget.supportedLocales, + showPerformanceOverlay: widget.showPerformanceOverlay, + checkerboardRasterCacheImages: widget.checkerboardRasterCacheImages, + checkerboardOffscreenLayers: widget.checkerboardOffscreenLayers, + showSemanticsDebugger: widget.showSemanticsDebugger, + debugShowCheckedModeBanner: widget.debugShowCheckedModeBanner, + inspectorSelectButtonBuilder: _inspectorSelectButtonBuilder, + shortcuts: widget.shortcuts, + actions: widget.actions, ); + } + + @override + Widget build(BuildContext context) { + Widget result = _buildWidgetApp(context); assert(() { if (widget.debugShowMaterialGrid) { @@ -735,7 +836,10 @@ class _MaterialAppState extends State { return ScrollConfiguration( behavior: _MaterialScrollBehavior(), - child: result, + child: HeroControllerScope( + controller: _heroController, + child: result, + ) ); } } diff --git a/packages/flutter/lib/src/services/system_channels.dart b/packages/flutter/lib/src/services/system_channels.dart index 3a17e21338..c75bc4062c 100644 --- a/packages/flutter/lib/src/services/system_channels.dart +++ b/packages/flutter/lib/src/services/system_channels.dart @@ -26,16 +26,18 @@ class SystemChannels { /// * `pushRoute`, which is called with a single string argument when the /// operating system instructs the application to open a particular page. /// + /// * `pushRouteInformation`, which is called with a map, which contains a + /// location string and a state object, when the operating system instructs + /// the application to open a particular page. These parameters are stored + /// under the key `location` and `state` in the map. + /// /// The following methods are used for the opposite direction data flow. The /// framework notifies the engine about the route changes. /// - /// * `routePushed`, which is called when a route is pushed. (e.g. A modal - /// replaces the entire screen.) + /// * `routeUpdated`, which is called when current route has changed. /// - /// * `routePopped`, which is called when a route is popped. (e.g. A dialog, - /// such as time picker is closed.) - /// - /// * `routeReplaced`, which is called when a route is replaced. + /// * `routeInformationUpdated`, which is called by the [Router] when the + /// application navigate to a new location. /// /// See also: /// @@ -46,7 +48,7 @@ class SystemChannels { /// [Navigator.push], [Navigator.pushReplacement], [Navigator.pop] and /// [Navigator.replace], utilize this channel's methods to send route /// change information from framework to engine. - static const MethodChannel navigation = MethodChannel( + static const MethodChannel navigation = OptionalMethodChannel( 'flutter/navigation', JSONMethodCodec(), ); diff --git a/packages/flutter/lib/src/services/system_navigator.dart b/packages/flutter/lib/src/services/system_navigator.dart index 8efdfef614..61d5510abe 100644 --- a/packages/flutter/lib/src/services/system_navigator.dart +++ b/packages/flutter/lib/src/services/system_navigator.dart @@ -35,4 +35,37 @@ class SystemNavigator { static Future pop({bool? animated}) async { await SystemChannels.platform.invokeMethod('SystemNavigator.pop', animated); } + + /// 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({ + required String location, + Object? state + }) { + SystemChannels.navigation.invokeMethod( + 'routeInformationUpdated', + { + 'location': location, + 'state': state, + }, + ); + } + + /// Notifies the platform of a route change. + /// + /// On Web, updates the URL bar with the [routeName]. + static void routeUpdated({ + String? routeName, + String? previousRouteName + }) { + SystemChannels.navigation.invokeMethod( + 'routeUpdated', + { + 'previousRouteName': previousRouteName, + 'routeName': routeName, + }, + ); + } } diff --git a/packages/flutter/lib/src/widgets/app.dart b/packages/flutter/lib/src/widgets/app.dart index 5ab614a26e..a55d973975 100644 --- a/packages/flutter/lib/src/widgets/app.dart +++ b/packages/flutter/lib/src/widgets/app.dart @@ -22,6 +22,7 @@ import 'media_query.dart'; import 'navigator.dart'; import 'pages.dart'; import 'performance_overlay.dart'; +import 'router.dart'; import 'scrollable.dart'; import 'semantics_debugger.dart'; import 'shortcuts.dart'; @@ -260,6 +261,62 @@ class WidgetsApp extends StatefulWidget { assert(showSemanticsDebugger != null), assert(debugShowCheckedModeBanner != null), assert(debugShowWidgetInspector != null), + routeInformationProvider = null, + routeInformationParser = null, + routerDelegate = null, + backButtonDispatcher = null, + super(key: key); + + /// Creates a [WidgetsApp] that uses the [Router] instead of a [Navigator]. + WidgetsApp.router({ + Key key, + this.routeInformationProvider, + @required this.routeInformationParser, + @required this.routerDelegate, + BackButtonDispatcher backButtonDispatcher, + this.builder, + this.title = '', + this.onGenerateTitle, + this.textStyle, + @required this.color, + this.locale, + this.localizationsDelegates, + this.localeListResolutionCallback, + this.localeResolutionCallback, + this.supportedLocales = const [Locale('en', 'US')], + this.showPerformanceOverlay = false, + this.checkerboardRasterCacheImages = false, + this.checkerboardOffscreenLayers = false, + this.showSemanticsDebugger = false, + this.debugShowWidgetInspector = false, + this.debugShowCheckedModeBanner = true, + this.inspectorSelectButtonBuilder, + this.shortcuts, + this.actions, + }) : assert( + routeInformationParser != null && + routerDelegate != null, + 'The routeInformationParser and routerDelegate cannot be null.' + ), + assert(title != null), + assert(color != null), + assert(supportedLocales != null && supportedLocales.isNotEmpty), + assert(showPerformanceOverlay != null), + assert(checkerboardRasterCacheImages != null), + assert(checkerboardOffscreenLayers != null), + assert(showSemanticsDebugger != null), + assert(debugShowCheckedModeBanner != null), + assert(debugShowWidgetInspector != null), + navigatorObservers = null, + backButtonDispatcher = backButtonDispatcher ?? RootBackButtonDispatcher(), + navigatorKey = null, + onGenerateRoute = null, + pageRouteBuilder = null, + home = null, + onGenerateInitialRoutes = null, + onUnknownRoute = null, + routes = null, + initialRoute = null, super(key: key); /// {@template flutter.widgets.widgetsApp.navigatorKey} @@ -321,6 +378,71 @@ class WidgetsApp extends StatefulWidget { /// or a [CupertinoPageRoute] should be used for building page transitions. final PageRouteFactory pageRouteBuilder; + /// {@template flutter.widgets.widgetsApp.routeInformationParser} + /// A delegate to parse the route information from the + /// [routeInformationProvider] into a generic data type to be processed by + /// the [routerDelegate] at a later stage. + /// + /// This object will be used by the underlying [Router]. + /// + /// The generic type `T` must match the generic type of the [routerDelegate]. + /// + /// See also: + /// + /// * [Router.routeInformationParser]: which receives this object when this + /// widget builds the [Router]. + /// {@endtemplate} + final RouteInformationParser routeInformationParser; + + /// {@template flutter.widgets.widgetsApp.routerDelegate} + /// A delegate that configures a widget, typically a [Navigator], with + /// parsed result from the [routeInformationParser]. + /// + /// This object will be used by the underlying [Router]. + /// + /// The generic type `T` must match the generic type of the + /// [routeInformationParser]. + /// + /// See also: + /// + /// * [Router.routerDelegate]: which receives this object when this widget + /// builds the [Router]. + /// {@endtemplate} + final RouterDelegate routerDelegate; + + /// {@template flutter.widgets.widgetsApp.backButtonDispatcher} + /// A delegate that decide whether to handle the Android back button intent. + /// + /// This object will be used by the underlying [Router]. + /// + /// If this is not provided, the widgets app will create a + /// [RootBackButtonDispatcher] by default. + /// + /// See also: + /// + /// * [Router.backButtonDispatcher]: which receives this object when this + /// widget builds the [Router]. + /// {@endtemplate} + final BackButtonDispatcher backButtonDispatcher; + + /// {@template flutter.widgets.widgetsApp.routeInformationProvider} + /// A object that provides route information through the + /// [RouteInformationProvider.value] and notifies its listener when its value + /// changes. + /// + /// This object will be used by the underlying [Router]. + /// + /// If this is not provided, the widgets app will create a + /// [PlatformRouteInformationProvider] with initial route name equals to + /// the [Window.defaultRouteName] by default. + /// + /// See also: + /// + /// * [Router.routeInformationProvider]: which receives this object when this + /// widget builds the [Router]. + /// {@endtemplate} + final RouteInformationProvider routeInformationProvider; + /// {@template flutter.widgets.widgetsApp.home} /// The widget for the default route of the app ([Navigator.defaultRouteName], /// which is `/`). @@ -960,10 +1082,21 @@ class WidgetsApp extends StatefulWidget { class _WidgetsAppState extends State with WidgetsBindingObserver { // STATE LIFECYCLE + // If window.defaultRouteName isn't '/', we should assume it was set + // intentionally via `setInitialRoute`, and should override whatever is in + // [widget.initialRoute]. + String get _initialRouteName => WidgetsBinding.instance.window.defaultRouteName != Navigator.defaultRouteName + ? WidgetsBinding.instance.window.defaultRouteName + : widget.initialRoute ?? WidgetsBinding.instance.window.defaultRouteName; + @override void initState() { super.initState(); - _updateNavigator(); + if (_usesRouter) { + _updateRouter(); + } else { + _updateNavigator(); + } _locale = _resolveLocales(WidgetsBinding.instance.window.locales, widget.supportedLocales); WidgetsBinding.instance.addObserver(this); } @@ -971,16 +1104,37 @@ class _WidgetsAppState extends State with WidgetsBindingObserver { @override void didUpdateWidget(WidgetsApp oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.navigatorKey != oldWidget.navigatorKey) + if (oldWidget.routeInformationProvider != widget.routeInformationProvider) { + _updateRouter(); + } + if (widget.navigatorKey != oldWidget.navigatorKey) { _updateNavigator(); + } } @override void dispose() { WidgetsBinding.instance.removeObserver(this); + _defaultRouteInformationProvider?.dispose(); super.dispose(); } + bool get _usesRouter => widget.routerDelegate != null; + + // ROUTER + RouteInformationProvider get _effectiveRouteInformationProvider => widget.routeInformationProvider ?? _defaultRouteInformationProvider; + PlatformRouteInformationProvider _defaultRouteInformationProvider; + + void _updateRouter() { + _defaultRouteInformationProvider?.dispose(); + if (widget.routeInformationProvider == null) + _defaultRouteInformationProvider = PlatformRouteInformationProvider( + initialRouteInformation: RouteInformation( + location: _initialRouteName, + ), + ); + } + // NAVIGATOR GlobalKey _navigator; @@ -1050,6 +1204,11 @@ class _WidgetsAppState extends State with WidgetsBindingObserver { @override Future didPopRoute() async { assert(mounted); + // The back button dispatcher should handle the pop route if we use a + // router. + if (_usesRouter) + return false; + final NavigatorState navigator = _navigator?.currentState; if (navigator == null) return false; @@ -1059,6 +1218,11 @@ class _WidgetsAppState extends State with WidgetsBindingObserver { @override Future didPushRoute(String route) async { assert(mounted); + // The route name provider should handle the push route if we uses a + // router. + if (_usesRouter) + return false; + final NavigatorState navigator = _navigator?.currentState; if (navigator == null) return false; @@ -1291,16 +1455,20 @@ class _WidgetsAppState extends State with WidgetsBindingObserver { @override Widget build(BuildContext context) { - Widget navigator; - if (_navigator != null) { - navigator = Navigator( + Widget routing; + if (_usesRouter) { + assert(_effectiveRouteInformationProvider != null); + routing = Router( + routeInformationProvider: _effectiveRouteInformationProvider, + routeInformationParser: widget.routeInformationParser, + routerDelegate: widget.routerDelegate, + backButtonDispatcher: widget.backButtonDispatcher, + ); + } else { + assert(_navigator != null); + routing = Navigator( key: _navigator, - // If window.defaultRouteName isn't '/', we should assume it was set - // intentionally via `setInitialRoute`, and should override whatever - // is in [widget.initialRoute]. - initialRoute: WidgetsBinding.instance.window.defaultRouteName != Navigator.defaultRouteName - ? WidgetsBinding.instance.window.defaultRouteName - : widget.initialRoute ?? WidgetsBinding.instance.window.defaultRouteName, + initialRoute: _initialRouteName, onGenerateRoute: _onGenerateRoute, onGenerateInitialRoutes: widget.onGenerateInitialRoutes == null ? Navigator.defaultGenerateInitialRoutes @@ -1317,12 +1485,12 @@ class _WidgetsAppState extends State with WidgetsBindingObserver { if (widget.builder != null) { result = Builder( builder: (BuildContext context) { - return widget.builder(context, navigator); + return widget.builder(context, routing); }, ); } else { - assert(navigator != null); - result = navigator; + assert(routing != null); + result = routing; } if (widget.textStyle != null) { diff --git a/packages/flutter/lib/src/widgets/binding.dart b/packages/flutter/lib/src/widgets/binding.dart index e5f253f90a..71296d8b7a 100644 --- a/packages/flutter/lib/src/widgets/binding.dart +++ b/packages/flutter/lib/src/widgets/binding.dart @@ -18,6 +18,7 @@ import 'app.dart'; import 'debug.dart'; import 'focus_manager.dart'; import 'framework.dart'; +import 'router.dart'; import 'widget_inspector.dart'; export 'dart:ui' show AppLifecycleState, Locale; @@ -95,7 +96,7 @@ abstract class WidgetsBindingObserver { /// [SystemChannels.navigation]. Future didPopRoute() => Future.value(false); - /// Called when the host tells the app to push a new route onto the + /// Called when the host tells the application to push a new route onto the /// navigator. /// /// Observers are expected to return true if they were able to @@ -106,6 +107,22 @@ abstract class WidgetsBindingObserver { /// [SystemChannels.navigation]. Future didPushRoute(String route) => Future.value(false); + /// Called when the host tells the application to push a new + /// [RouteInformation] and a restoration state onto the router. + /// + /// Observers are expected to return true if they were able to + /// handle the notification. Observers are notified in registration + /// order until one returns true. + /// + /// This method exposes the `pushRouteInformation` notification from + /// [SystemChannels.navigation]. + /// + /// The default implementation is to call the [didPushRoute] directly with the + /// [RouteInformation.location]. + Future didPushRouteInformation(RouteInformation routeInformation) { + return didPushRoute(routeInformation.location); + } + /// Called when the application's dimensions change. For example, /// when a phone is rotated. /// @@ -654,12 +671,28 @@ mixin WidgetsBinding on BindingBase, ServicesBinding, SchedulerBinding, GestureB } } + Future _handlePushRouteInformation(Map routeArguments) async { + for (final WidgetsBindingObserver observer in List.from(_observers)) { + if ( + await observer.didPushRouteInformation( + RouteInformation( + location: routeArguments['location'] as String, + state: routeArguments['state'] as Object, + ) + ) + ) + return; + } + } + Future _handleNavigationInvocation(MethodCall methodCall) { switch (methodCall.method) { case 'popRoute': return handlePopRoute(); case 'pushRoute': return handlePushRoute(methodCall.arguments as String); + case 'pushRouteInformation': + return _handlePushRouteInformation(methodCall.arguments as Map); } return Future.value(); } diff --git a/packages/flutter/lib/src/widgets/navigator.dart b/packages/flutter/lib/src/widgets/navigator.dart index c3e9204c14..dd0e17f4a9 100644 --- a/packages/flutter/lib/src/widgets/navigator.dart +++ b/packages/flutter/lib/src/widgets/navigator.dart @@ -21,7 +21,6 @@ import 'focus_scope.dart'; import 'framework.dart'; import 'heroes.dart'; import 'overlay.dart'; -import 'route_notification_messages.dart'; import 'routes.dart'; import 'ticker_provider.dart'; @@ -3308,8 +3307,10 @@ class NavigatorState extends State with TickerProviderStateMixin { _RouteEntry.isPresentPredicate, orElse: () => null); final String routeName = lastEntry?.route?.settings?.name; if (routeName != _lastAnnouncedRouteName) { - RouteNotificationMessages.maybeNotifyRouteChange( - routeName, _lastAnnouncedRouteName); + SystemNavigator.routeUpdated( + routeName: routeName, + previousRouteName: _lastAnnouncedRouteName + ); _lastAnnouncedRouteName = routeName; } } diff --git a/packages/flutter/lib/src/widgets/route_notification_messages.dart b/packages/flutter/lib/src/widgets/route_notification_messages.dart deleted file mode 100644 index 8048315c75..0000000000 --- a/packages/flutter/lib/src/widgets/route_notification_messages.dart +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright 2014 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. - -// @dart = 2.8 - -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; - -/// Messages for route change notifications. -class RouteNotificationMessages { - // This class is not meant to be instantiated or extended; this constructor - // prevents instantiation and extension. - // ignore: unused_element - RouteNotificationMessages._(); - - /// When the engine is Web notify the platform for a route change. - static void maybeNotifyRouteChange(String routeName, String previousRouteName) { - if(kIsWeb) { - _notifyRouteChange(routeName, previousRouteName); - } else { - // No op. - } - } - - /// Notifies the platform of a route change. - /// - /// See also: - /// - /// * [SystemChannels.navigation], which handles subsequent navigation - /// requests. - static void _notifyRouteChange(String routeName, String previousRouteName) { - SystemChannels.navigation.invokeMethod( - 'routeUpdated', - { - 'previousRouteName': previousRouteName, - 'routeName': routeName, - }, - ); - } -} diff --git a/packages/flutter/lib/src/widgets/router.dart b/packages/flutter/lib/src/widgets/router.dart new file mode 100644 index 0000000000..d2e3132d11 --- /dev/null +++ b/packages/flutter/lib/src/widgets/router.dart @@ -0,0 +1,1248 @@ +// Copyright 2014 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. + +// @dart = 2.8 + +import 'dart:async'; +import 'dart:collection'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; + +import 'basic.dart'; +import 'binding.dart'; +import 'framework.dart'; +import 'navigator.dart'; + +/// A piece of routing information. +/// +/// The route information consists of a location string of the application and +/// a state object that configures the application in that location. +/// +/// This information flows two ways, from the [RouteInformationProvider] to the +/// [Router] or from the [Router] to [RouteInformationProvider]. +/// +/// In the former case, the [RouteInformationProvider] notifies the [Router] +/// widget when a new [RouteInformation] is available. The [Router] widget takes +/// these information and navigates accordingly. +/// +/// The latter case should only happen in a web application where the [Router] +/// reports route change back to web engine. +class RouteInformation { + /// Creates a route information. + const RouteInformation({this.location, this.state}); + + /// The location of the application. + /// + /// The string is usually in the format of multiple string identifiers with + /// slashes in between. ex: `/`, `/path`, `/path/to/the/app`. + /// + /// It is equivalent to the URL in a web application. + final String location; + + /// The state of the application in the [location]. + /// + /// The app can have different states even in the same location. For example + /// the text inside a [TextField] or the scroll position in a [ScrollView], + /// these widget states can be stored in the [state]. + /// + /// It's only used in the web application currently. In a web application, + /// this property is stored into browser history entry when the [Router] + /// report this route information back to the web engine through the + /// [PlatformRouteInformationProvider], so we can get the url along with state + /// back when the user click the forward or backward buttons. + /// + /// The state must be serializable. + final Object state; +} + +/// The dispatcher for opening and closing pages of an application. +/// +/// This widget listens for routing information from the operating system (e.g. +/// an initial route provided on app startup, a new route obtained when an +/// intent is received, or a notification that the user hit the system back +/// button), parses route information into data of type `T`, and then converts +/// that data into [Page] objects that it passes to a [Navigator]. +/// +/// Additionally, every single part of that previous sentence can be overridden +/// and configured as desired. +/// +/// The [routeInformationProvider] can be overridden to change how the name of +/// the route is obtained. the [RouteInformationProvider.value] when the +/// [Router] is first created is used as the initial route, and subsequent +/// notifications from the [RouteInformationProvider] to its listeners are +/// treated as notifications that the route information has changed. +/// +/// The [backButtonDispatcher] can be overridden to change how back button +/// notifications are received. This must be a [BackButtonDispatcher], which is +/// an object where callbacks can be registered, and which can be chained +/// so that back button presses are delegated to subsidiary routers. The +/// callbacks are invoked to indicate that the user is trying to close the +/// current route (by pressing the system back button); the [Router] ensures +/// that when this callback is invoked, the message is passed to the +/// [routerDelegate] and its result is provided back to the +/// [backButtonDispatcher]. Some platforms don't have back buttons and on those +/// platforms it is completely normal that this notification is never sent. The +/// common [backButtonDispatcher] for root router is an instance of +/// [RootBackButtonDispatcher], which uses a [WidgetsBindingObserver] to listen +/// to the `popRoute` notifications from [SystemChannels.navigation]. A +/// common alternative is [ChildBackButtonDispatcher], which must be provided +/// the [BackButtonDispatcher] of its ancestor [Router] (available via +/// [Router.of]). +/// +/// The [routeInformationParser] can be overridden to change how names obtained +/// from the [routeInformationProvider] are interpreted. It must implement the +/// [RouteInformationParser] interface, specialized with the same type as the +/// [Router] itself. This type, `T`, represents the data type that the +/// [routeInformationParser] will generate. +/// +/// The [routerDelegate] can be overridden to change how the output of the +/// [routeInformationParser] is interpreted. It must implement the +/// [RouterDelegate] interface, also specialized with `T`; it takes as input +/// the data (of type `T`) from the [routeInformationParser], and is responsible +/// for providing a navigating widget to insert into the widget tree. The +/// [RouterDelegate] interface is also [Listenable]; notifications are taken +/// to mean that the [Router] needs to rebuild. +/// +/// ## Concerns regarding asynchrony +/// +/// Some of the APIs (notably those involving [RouteInformationParser] and +/// [RouterDelegate]) are asynchronous. +/// +/// When developing objects implementing these APIs, if the work can be done +/// entirely synchronously, then consider using [SynchronousFuture] for the +/// future returned from the relevant methods. This will allow the [Router] to +/// proceed in a completely synchronous way, which removes a number of +/// complications. +/// +/// Using asynchronous computation is entirely reasonable, however, and the API +/// is designed to support it. For example, maybe a set of images need to be +/// loaded before a route can be shown; waiting for those images to be loaded +/// before [RouterDelegate.setNewRoutePath] returns is a reasonable approach to +/// handle this case. +/// +/// If an asynchronous operation is ongoing when a new one is to be started, the +/// precise behavior will depend on the exact circumstances, as follows: +/// +/// If the active operation is a [routeInformationParser] parsing a new route information: +/// that operation's result, if it ever completes, will be discarded. +/// +/// If the active operation is a [routerDelegate] handling a pop request: +/// the previous pop is immediately completed with "false", claiming that the +/// previous pop was not handled (this may cause the application to close). +/// +/// If the active operation is a [routerDelegate] handling an initial route +/// or a pushed route, the result depends on the new operation. If the new +/// operation is a pop request, then the original operation's result, if it ever +/// completes, will be discarded. If the new operation is a push request, +/// however, the [routeInformationParser] will be requested to start the parsing, and +/// only if that finishes before the original [routerDelegate] request +/// completes will that original request's result be discarded. +/// +/// If the identity of the [Router] widget's delegates change while an +/// asynchronous operation is in progress, to keep matters simple, all active +/// asynchronous operations will have their results discarded. It is generally +/// considered unusual for these delegates to change during the lifetime of the +/// [Router]. +/// +/// If the [Router] itself is disposed while an an asynchronous operation is in +/// progress, all active asynchronous operations will have their results +/// discarded also. +/// +/// No explicit signals are provided to the [routeInformationParser] or +/// [routerDelegate] to indicate when any of the above happens, so it is +/// strongly recommended that [RouteInformationParser] and [RouterDelegate] +/// implementations not perform extensive computation. +/// +/// ## Application architectural design +/// +/// An application can have zero, one, or many [Router] widgets, depending on +/// its needs. +/// +/// An application might have no [Router] widgets if it has only one "screen", +/// or if the facilities provided by [Navigator] are sufficient. +/// +/// A particularly elaborate application might have multiple [Router] widgets, +/// in a tree configuration, with the first handling the entire route parsing +/// and making the result available for routers in the subtree. The routers in +/// the subtree do not participate in route information parsing but merely take the +/// result from the first router to build their sub routes. +/// +/// Most applications only need a single [Router]. +/// +/// ## URL updates for web applications +/// +/// In the web platform, it is important to keeps the URL up to date with the +/// app state. This ensures the browser constructs its history entry +/// correctly so that its forward and backward buttons continue to work. +/// +/// If the [routeInformationProvider] is a [PlatformRouteInformationProvider] +/// and a app state change leads to [Router] rebuilds, the [Router] will detect +/// such a event and retrieve the new route information from the +/// [RouterDelegate.currentConfiguration] and the +/// [RouteInformationParser.restoreRouteInformation]. If the location in the +/// new route information is different from the current location, the router +/// sends the new route information to the engine through the +/// [PlatformRouteInformationProvider.routerReportsNewRouteInformation]. +/// +/// By Providing implementations of these two methods in the subclasses and using +/// the [PlatformRouteInformationProvider], you can enable the [Router] widget to +/// update the URL in the browser automatically. +/// +/// You can force the [Router] to report the new route information back to the +/// engine even if the [RouteInformation.location] has not changed. By calling +/// the [Router.navigate], the [Router] will be forced to report the route +/// information back to the engine after running the callback. This is useful +/// when you want to support the browser backward and forward buttons without +/// changing the URL. For example, the scroll position of a scroll view may be +/// saved in the [RouteInformation.state]. If you use the [Router.navigate] to +/// update the scroll position, the browser will create a new history entry with +/// the [RouteInformation.state] that stores the new scroll position. when the +/// users click the backward button, the browser will go back to previous scroll +/// position without changing the url bar. +/// +/// You can also force the [Router] to ignore a one time route information +/// update by providing a one time app state update in a callback and pass it +/// into the [Router.neglect]. The [Router] will not report any route +/// information even if it detects location change as a result of running the +/// callback. This is particularly useful when you don't want the browser to +/// create a browser history entry for this app state update. +/// +/// You can also choose to opt out of URL updates entirely. Simply ignore the +/// [RouterDelegate.currentConfiguration] and the +/// [RouteInformationParser.restoreRouteInformation] without providing the +/// implementations will prevent the [Router] from reporting the URL back to the +/// web engine. This is not recommended in general, but You may decide to opt +/// out in these cases: +/// +/// * If you are not writing a web application. +/// +/// * If you have multiple router widgets in your app, then only one router +/// widget should update the URL (Usually the top-most one created by the +/// [WidgetsApp.router]/[MaterialApp.router]/[CupertinoApp.router]). +/// +/// * If your app does not care about the in-app navigation using the browser's +/// forward and backward buttons. +/// +/// Otherwise, we strongly recommend implementing the +/// [RouterDelegate.currentConfiguration] and the +/// [RouteInformationParser.restoreRouteInformation] to provide optimal +/// user experience in the web application. +class Router extends StatefulWidget { + /// Creates a router. + /// + /// The [routeInformationProvider] and [routeInformationParser] can be null if this + /// router does not depend on route information. A common example is a sub router + /// that builds its content completely relies on the app state. + /// + /// If the [routeInformationProvider] is not null, the [routeInformationParser] must + /// also not be null. + /// + /// The [routerDelegate] must not be null. + const Router({ + Key key, + this.routeInformationProvider, + this.routeInformationParser, + @required this.routerDelegate, + this.backButtonDispatcher, + }) : assert(routeInformationProvider == null || routeInformationParser != null), + assert(routerDelegate != null), + super(key: key); + + /// The route information provider for the router. + /// + /// The value at the time of first build will be used as the initial route. + /// The [Router] listens to this provider and rebuilds with new names when + /// it notifies. + /// + /// This can be null if this router does not rely on the route information + /// to build its content. In such case, the [routeInformationParser] can also be + /// null. + final RouteInformationProvider routeInformationProvider; + + /// The route information parser for the router. + /// + /// When the [Router] gets a new route information from the [routeInformationProvider], + /// the [Router] uses this delegate to parse the route information and produce a + /// configuration. The configuration will be used by [routerDelegate] and + /// eventually rebuilds the [Router] widget. + /// + /// Since this delegate is the primary consumer of the [routeInformationProvider], + /// it must not be null if [routeInformationProvider] is not null. + final RouteInformationParser routeInformationParser; + + /// The router delegate for the router. + /// + /// This delegate consumes the configuration from [routeInformationParser] and + /// builds a navigating widget for the [Router]. + /// + /// It is also the primary respondent for the [backButtonDispatcher]. The + /// [Router] relies on the [RouterDelegate.popRoute] to handles the back + /// button intends. + /// + /// If the [RouterDelegate.currentConfiguration] returns a non-null object, + /// this [Router] will opt for URL updates. + final RouterDelegate routerDelegate; + + /// The back button dispatcher for the router. + /// + /// The two common alternatives are the [RootBackButtonDispatcher] for root + /// router, or the [ChildBackButtonDispatcher] for other routers. + final BackButtonDispatcher backButtonDispatcher; + + /// Retrieves the immediate [Router] ancestor from the given context. + /// + /// Use this method when you need to access the delegates in the [Router]. + /// For example, you need to access the [backButtonDispatcher] of the parent + /// router to create a [ChildBackButtonDispatcher] for a nested router. + /// Another use case may be updating the value in [routeInformationProvider] + /// to navigate to a new route. + static Router of(BuildContext context) { + final _RouterScope scope = context.dependOnInheritedWidgetOfExactType<_RouterScope>(); + assert(scope != null); + return scope.routerState.widget; + } + + /// Forces the [Router] to run the [callback] and reports the route + /// information back to the engine. + /// + /// The web application relies on the [Router] to report new route information + /// in order to create browser history entry. The [Router] will only report + /// them if it detects the [RouteInformation.location] changes. Use this + /// method if you want the [Router] to report the route information even if + /// the location does not change. This can be useful when you want to + /// support the browser backward and forward button without changing the URL. + /// + /// For example, you can store certain state such as the scroll position into + /// the [RouteInformation.state]. If you use this method to update the + /// scroll position multiple times with the same URL, the browser will create + /// a stack of new history entries with the same URL but different + /// [RouteInformation.state]s that store the new scroll positions. If the user + /// click the backward button in the browser, the browser will restore the + /// scroll positions saved in history entries without changing the URL. + /// + /// See also: + /// + /// * [Router]: see the "URL updates for web applications" section for more + /// information about route information reporting. + /// * [neglect]: which forces the [Router] to not report the route + /// information even if location does change. + static void navigate(BuildContext context, VoidCallback callback) { + final _RouterScope scope = context + .getElementForInheritedWidgetOfExactType<_RouterScope>() + .widget as _RouterScope; + scope.routerState._setStateWithExplicitReportStatus(_IntentionToReportRouteInformation.must, callback); + } + + /// Forces the [Router] to to run the [callback] without reporting the route + /// information back to the engine. + /// + /// Use this method if you don't want the [Router] to report the new route + /// information even if it detects changes as a result of running the + /// [callback]. + /// + /// The web application relies on the [Router] to report new route information + /// in order to create browser history entry. The [Router] will report them + /// automatically if it detects the [RouteInformation.location] changes. You + /// can use this method if you want to navigate to a new route without + /// creating the browser history entry. + /// + /// See also: + /// + /// * [Router]: see the "URL updates for web applications" section for more + /// information about route information reporting. + /// * [navigate]: which forces the [Router] to report the route information + /// even if location does not change. + static void neglect(BuildContext context, VoidCallback callback) { + final _RouterScope scope = context + .getElementForInheritedWidgetOfExactType<_RouterScope>() + .widget as _RouterScope; + scope.routerState._setStateWithExplicitReportStatus(_IntentionToReportRouteInformation.ignore, callback); + } + + @override + State> createState() => _RouterState(); +} + +typedef _AsyncPassthrough = Future Function(Q); + +// Whether to report the route information in this build cycle. +enum _IntentionToReportRouteInformation { + // We haven't receive any signal on whether to report. + none, + // Report if route information changes. + maybe, + // Report regardless of route information changes. + must, + // Don't report regardless of route information changes. + ignore, +} + +class _RouterState extends State> { + Object _currentRouteInformationParserTransaction; + Object _currentRouterDelegateTransaction; + _IntentionToReportRouteInformation _currentIntentionToReport; + + @override + void initState() { + super.initState(); + widget.routeInformationProvider?.addListener(_handleRouteInformationProviderNotification); + widget.backButtonDispatcher?.addCallback(_handleBackButtonDispatcherNotification); + widget.routerDelegate.addListener(_handleRouterDelegateNotification); + _currentIntentionToReport = _IntentionToReportRouteInformation.none; + if (widget.routeInformationProvider != null) { + _processInitialRoute(); + } + _lastSeenLocation = widget.routeInformationProvider?.value?.location; + } + + bool _routeInformationReportingTaskScheduled = false; + + String _lastSeenLocation; + + void _scheduleRouteInformationReportingTask() { + if (_routeInformationReportingTaskScheduled) + return; + assert(_currentIntentionToReport != _IntentionToReportRouteInformation.none); + _routeInformationReportingTaskScheduled = true; + SchedulerBinding.instance.addPostFrameCallback(_reportRouteInformation); + } + + void _reportRouteInformation(Duration timestamp) { + assert(_routeInformationReportingTaskScheduled); + _routeInformationReportingTaskScheduled = false; + + switch (_currentIntentionToReport) { + case _IntentionToReportRouteInformation.none: + assert(false); + return; + + case _IntentionToReportRouteInformation.ignore: + // In the ignore case, we still want to update the _lastSeenLocation. + final RouteInformation routeInformation = _retrieveNewRouteInformation(); + if (routeInformation != null) { + _lastSeenLocation = routeInformation.location; + } + _currentIntentionToReport = _IntentionToReportRouteInformation.none; + return; + + case _IntentionToReportRouteInformation.maybe: + final RouteInformation routeInformation = _retrieveNewRouteInformation(); + if (routeInformation != null) { + if (_lastSeenLocation != routeInformation.location) { + widget.routeInformationProvider.routerReportsNewRouteInformation(routeInformation); + _lastSeenLocation = routeInformation.location; + } + } + _currentIntentionToReport = _IntentionToReportRouteInformation.none; + return; + + case _IntentionToReportRouteInformation.must: + final RouteInformation routeInformation = _retrieveNewRouteInformation(); + if (routeInformation != null) { + widget.routeInformationProvider.routerReportsNewRouteInformation(routeInformation); + _lastSeenLocation = routeInformation.location; + } + _currentIntentionToReport = _IntentionToReportRouteInformation.none; + return; + } + } + + RouteInformation _retrieveNewRouteInformation() { + final T configuration = widget.routerDelegate.currentConfiguration; + if (configuration == null) + return null; + final RouteInformation routeInformation = widget.routeInformationParser.restoreRouteInformation(configuration); + assert((){ + if (routeInformation == null) { + FlutterError.reportError( + const FlutterErrorDetails( + exception: + 'Router.routeInformationParser returns a null RouteInformation. ' + 'If you opt for route information reporting, the ' + 'routeInformationParser must not report null for a given ' + 'configuration.' + ), + ); + } + return true; + }()); + return routeInformation; + } + + void _setStateWithExplicitReportStatus( + _IntentionToReportRouteInformation status, + VoidCallback fn, + ) { + assert(status != null); + assert(status.index >= _IntentionToReportRouteInformation.must.index); + assert(() { + if (_currentIntentionToReport.index >= _IntentionToReportRouteInformation.must.index && + _currentIntentionToReport != status) { + FlutterError.reportError( + const FlutterErrorDetails( + exception: + 'Both Router.navigate and Router.neglect have been called in this ' + 'build cycle, and the Router cannot decide whether to report the ' + 'route information. Please make sure only one of them is called ' + 'within the same build cycle.' + ), + ); + } + return true; + }()); + _currentIntentionToReport = status; + _scheduleRouteInformationReportingTask(); + fn(); + } + + void _maybeNeedToReportRouteInformation() { + _currentIntentionToReport = _currentIntentionToReport != _IntentionToReportRouteInformation.none + ? _currentIntentionToReport + : _IntentionToReportRouteInformation.maybe; + _scheduleRouteInformationReportingTask(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _maybeNeedToReportRouteInformation(); + } + + @override + void didUpdateWidget(Router oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.routeInformationProvider != oldWidget.routeInformationProvider || + widget.backButtonDispatcher != oldWidget.backButtonDispatcher || + widget.routeInformationParser != oldWidget.routeInformationParser || + widget.routerDelegate != oldWidget.routerDelegate) { + _currentRouteInformationParserTransaction = Object(); + _currentRouterDelegateTransaction = Object(); + } + if (widget.routeInformationProvider != oldWidget.routeInformationProvider) { + oldWidget.routeInformationProvider?.removeListener(_handleRouteInformationProviderNotification); + widget.routeInformationProvider?.addListener(_handleRouteInformationProviderNotification); + if (oldWidget.routeInformationProvider?.value != widget.routeInformationProvider?.value) { + _handleRouteInformationProviderNotification(); + } + } + if (widget.backButtonDispatcher != oldWidget.backButtonDispatcher) { + oldWidget.backButtonDispatcher?.removeCallback(_handleBackButtonDispatcherNotification); + widget.backButtonDispatcher?.addCallback(_handleBackButtonDispatcherNotification); + } + if (widget.routerDelegate != oldWidget.routerDelegate) { + oldWidget.routerDelegate.removeListener(_handleRouterDelegateNotification); + widget.routerDelegate.addListener(_handleRouterDelegateNotification); + _maybeNeedToReportRouteInformation(); + } + } + + @override + void dispose() { + widget.routeInformationProvider?.removeListener(_handleRouteInformationProviderNotification); + widget.backButtonDispatcher?.removeCallback(_handleBackButtonDispatcherNotification); + widget.routerDelegate.removeListener(_handleRouterDelegateNotification); + _currentRouteInformationParserTransaction = null; + _currentRouterDelegateTransaction = null; + super.dispose(); + } + + void _processInitialRoute() { + _currentRouteInformationParserTransaction = Object(); + _currentRouterDelegateTransaction = Object(); + widget.routeInformationParser + .parseRouteInformation(widget.routeInformationProvider.value) + .then(_verifyRouteInformationParserStillCurrent(_currentRouteInformationParserTransaction, widget)) + .then(widget.routerDelegate.setInitialRoutePath) + .then(_verifyRouterDelegatePushStillCurrent(_currentRouterDelegateTransaction, widget)) + .then(_rebuild); + } + + void _handleRouteInformationProviderNotification() { + _currentRouteInformationParserTransaction = Object(); + _currentRouterDelegateTransaction = Object(); + widget.routeInformationParser + .parseRouteInformation(widget.routeInformationProvider.value) + .then(_verifyRouteInformationParserStillCurrent(_currentRouteInformationParserTransaction, widget)) + .then(widget.routerDelegate.setNewRoutePath) + .then(_verifyRouterDelegatePushStillCurrent(_currentRouterDelegateTransaction, widget)) + .then(_rebuild); + } + + Future _handleBackButtonDispatcherNotification() { + _currentRouteInformationParserTransaction = Object(); + _currentRouterDelegateTransaction = Object(); + return widget.routerDelegate + .popRoute() + .then(_verifyRouterDelegatePopStillCurrent(_currentRouterDelegateTransaction, widget)) + .then((bool data) { + _rebuild(); + _maybeNeedToReportRouteInformation(); + return SynchronousFuture(data); + }); + } + + static final Future _never = Completer().future; // won't ever complete + + _AsyncPassthrough _verifyRouteInformationParserStillCurrent(Object transaction, Router originalWidget) { + return (T data) { + if (transaction == _currentRouteInformationParserTransaction && + widget.routeInformationProvider == originalWidget.routeInformationProvider && + widget.backButtonDispatcher == originalWidget.backButtonDispatcher && + widget.routeInformationParser == originalWidget.routeInformationParser && + widget.routerDelegate == originalWidget.routerDelegate) { + return SynchronousFuture(data); + } + return _never as Future; + }; + } + + _AsyncPassthrough _verifyRouterDelegatePushStillCurrent(Object transaction, Router originalWidget) { + return (void data) { + if (transaction == _currentRouterDelegateTransaction && + widget.routeInformationProvider == originalWidget.routeInformationProvider && + widget.backButtonDispatcher == originalWidget.backButtonDispatcher && + widget.routeInformationParser == originalWidget.routeInformationParser && + widget.routerDelegate == originalWidget.routerDelegate) + return SynchronousFuture(data); + return _never; + }; + } + + _AsyncPassthrough _verifyRouterDelegatePopStillCurrent(Object transaction, Router originalWidget) { + return (bool data) { + if (transaction == _currentRouterDelegateTransaction && + widget.routeInformationProvider == originalWidget.routeInformationProvider && + widget.backButtonDispatcher == originalWidget.backButtonDispatcher && + widget.routeInformationParser == originalWidget.routeInformationParser && + widget.routerDelegate == originalWidget.routerDelegate) { + return SynchronousFuture(data); + } + // A rebuilt was trigger from a different source. Returns true to + // prevent bubbling. + return SynchronousFuture(true); + }; + } + + Future _rebuild([void value]) { + setState(() {/* routerDelegate is ready to rebuild */}); + return SynchronousFuture(value); + } + + void _handleRouterDelegateNotification() { + setState(() {/* routerDelegate wants to rebuild */}); + _maybeNeedToReportRouteInformation(); + } + + @override + Widget build(BuildContext context) { + return _RouterScope( + routeInformationProvider: widget.routeInformationProvider, + backButtonDispatcher: widget.backButtonDispatcher, + routeInformationParser: widget.routeInformationParser, + routerDelegate: widget.routerDelegate, + routerState: this, + child: Builder( + // We use a Builder so that the build method below + // will have a BuildContext that contains the _RouterScope. + builder: widget.routerDelegate.build, + ), + ); + } +} + +class _RouterScope extends InheritedWidget { + const _RouterScope({ + Key key, + @required this.routeInformationProvider, + @required this.backButtonDispatcher, + @required this.routeInformationParser, + @required this.routerDelegate, + @required this.routerState, + @required Widget child, + }) : assert(routeInformationProvider == null || routeInformationParser != null), + assert(routerDelegate != null), + assert(routerState != null), + super(key: key, child: child); + + final ValueListenable routeInformationProvider; + final BackButtonDispatcher backButtonDispatcher; + final RouteInformationParser routeInformationParser; + final RouterDelegate routerDelegate; + final _RouterState routerState; + + @override + bool updateShouldNotify(_RouterScope oldWidget) { + return routeInformationProvider != oldWidget.routeInformationProvider || + backButtonDispatcher != oldWidget.backButtonDispatcher || + routeInformationParser != oldWidget.routeInformationParser || + routerDelegate != oldWidget.routerDelegate || + routerState != oldWidget.routerState; + } +} + +/// A class that can be extended or mixed in that invokes a single callback, +/// which then returns a value. +/// +/// While multiple callbacks can be registered, when a notification is +/// dispatched there must be only a single callback. The return values of +/// multiple callbacks are not aggregated. +/// +/// `T` is the return value expected from the callback. +/// +/// See also: +/// +/// * [Listenable] and its subclasses, which provide a similar mechanism for +/// one-way signalling. +class _CallbackHookProvider { + final ObserverList> _callbacks = ObserverList>(); + + /// Whether a callback is currently registered. + @protected + bool get hasCallbacks => _callbacks.isNotEmpty; + + /// Register the callback to be called when the object changes. + /// + /// If other callbacks have already been registered, they must be removed + /// (with [removeCallback]) before the callback is next called. + void addCallback(ValueGetter callback) => _callbacks.add(callback); + + /// Remove a previously registered callback. + /// + /// If the given callback is not registered, the call is ignored. + void removeCallback(ValueGetter callback) => _callbacks.remove(callback); + + /// Calls the (single) registered callback and returns its result. + /// + /// If no callback is registered, or if the callback throws, returns + /// `defaultValue`. + /// + /// Call this method whenever the callback is to be invoked. If there is more + /// than one callback registered, this method will throw a [StateError]. + /// + /// Exceptions thrown by callbacks will be caught and reported using + /// [FlutterError.reportError]. + @protected + T invokeCallback(T defaultValue) { + if (_callbacks.isEmpty) + return defaultValue; + try { + return _callbacks.single(); + } catch (exception, stack) { + FlutterError.reportError(FlutterErrorDetails( + exception: exception, + stack: stack, + library: 'widget library', + context: ErrorDescription('while invoking the callback for $runtimeType'), + informationCollector: () sync* { + yield DiagnosticsProperty<_CallbackHookProvider>( + 'The $runtimeType that invoked the callback was:', + this, + style: DiagnosticsTreeStyle.errorProperty, + ); + }, + )); + return defaultValue; + } + } +} + +/// Report to a [Router] when the user taps the back button on platforms that +/// support back buttons (such as Android). +/// +/// When [Router] widgets are nested, consider using a +/// [ChildBackButtonDispatcher], passing it the parent [BackButtonDispatcher], +/// so that the back button requests get dispatched to the appropriate [Router]. +/// To make this work properly, it's important that whenever a [Router] thinks +/// it should get the back button messages (e.g. after the user taps inside it), +/// it calls [takePriority] on its [BackButtonDispatcher] (or +/// [ChildBackButtonDispatcher]) instance. +/// +/// The class takes a single callback, which must return a [Future]. The +/// callback's semantics match [WidgetsBindingObserver.didPopRoute]'s, namely, +/// the callback should return a future that completes to true if it can handle +/// the pop request, and a future that completes to false otherwise. +abstract class BackButtonDispatcher extends _CallbackHookProvider> { + LinkedHashSet _children; + + @override + bool get hasCallbacks => super.hasCallbacks || (_children != null && _children.isNotEmpty); + + /// Handles a pop route request. + /// + /// This method prioritizes the children list in reverse order and calls + /// [ChildBackButtonDispatcher.notifiedByParent] on them. If any of them + /// handles the request (by returning a future with true), it exits this + /// method by returning this future. Otherwise, it keeps moving on to the next + /// child until a child handles the request. If none of the children handles + /// the request, this back button dispatcher will then try to handle the request + /// by itself. This back button dispatcher handles the request by notifying the + /// router which in turn calls the [RouterDelegate.popRoute] and returns its + /// result. + /// + /// To decide whether this back button dispatcher will handle the pop route + /// request, you can override the [RouterDelegate.popRoute] of the router + /// delegate you pass into the router with this back button dispatcher to + /// return a future of true or false. + @override + Future invokeCallback(Future defaultValue) { + if (_children != null && _children.isNotEmpty) { + final List children = _children.toList(); + int childIndex = children.length - 1; + + Future notifyNextChild(bool result) { + // If the previous child handles the callback, we returns the result. + if (result) + return SynchronousFuture(result); + // If the previous child did not handle the callback, we ask the next + // child to handle the it. + if (childIndex > 0) { + childIndex -= 1; + return children[childIndex] + .notifiedByParent(defaultValue) + .then(notifyNextChild); + } + // If none of the child handles the callback, the parent will then handle it. + return super.invokeCallback(defaultValue); + } + + return children[childIndex] + .notifiedByParent(defaultValue) + .then(notifyNextChild); + } + return super.invokeCallback(defaultValue); + } + + /// Creates a [ChildBackButtonDispatcher] that is a direct descendant of this + /// back button dispatcher. + /// + /// To participate in handling the pop route request, call the [takePriority] + /// on the [ChildBackButtonDispatcher] created from this method. + /// + /// When the pop route request is handled by this back button dispatcher, it + /// propagate the request to its direct descendants that have called the + /// [takePriority] method. If there are multiple candidates, the latest one + /// that called the [takePriority] wins the right to handle the request. If + /// the latest one does not handle the request (by returning a future of + /// false in [ChildBackButtonDispatcher.notifiedByParent]), the second latest + /// one will then have the right to handle the request. This dispatcher + /// continues finding the next candidate until there are no more candidates + /// and finally handles the request itself. + ChildBackButtonDispatcher createChildBackButtonDispatcher() { + return ChildBackButtonDispatcher(this); + } + + /// Make this [BackButtonDispatcher] take priority among its peers. + /// + /// This has no effect when a [BackButtonDispatcher] has no parents and no + /// children. If a [BackButtonDispatcher] does have parents or children, + /// however, it causes this object to be the one to dispatch the notification + /// when the parent would normally notify its callback. + /// + /// The [BackButtonDispatcher] must have a listener registered before it can + /// be told to take priority. + void takePriority() { + if (_children != null) + _children.clear(); + } + + /// Mark the given child as taking priority over this object and the other + /// children. + /// + /// This causes [invokeCallback] to defer to the given child instead of + /// calling this object's callback. + /// + /// Children are stored in a list, so that if the current child is removed + /// using [forget], a previous child will return to take its place. When + /// [takePriority] is called, the list is cleared. + /// + /// Calling this again without first calling [forget] moves the child back to + /// the head of the list. + /// + // (Actually it moves it to the end of the list and we treat the end of the + // list to be the priority end, but that's an implementation detail.) + // + /// The [BackButtonDispatcher] must have a listener registered before it can + /// be told to defer to a child. + void deferTo(ChildBackButtonDispatcher child) { + assert(hasCallbacks); + _children ??= {} as LinkedHashSet; + _children.remove(child); // child may or may not be in the set already + _children.add(child); + } + + /// Causes the given child to be removed from the list of children to which + /// this object might defer, as if [deferTo] had never been called for that + /// child. + /// + /// This should only be called once per child, even if [deferTo] was called + /// multiple times for that child. + /// + /// If no children are left in the list, this object will stop deferring to + /// its children. (This is not the same as calling [takePriority], since, if + /// this object itself is a [ChildBackButtonDispatcher], [takePriority] would + /// additionally attempt to claim priority from its parent, whereas removing + /// the last child does not.) + void forget(ChildBackButtonDispatcher child) { + assert(_children != null); + assert(_children.contains(child)); + _children.remove(child); + } +} + +/// The default implementation of back button dispatcher for the root router. +/// +/// This dispatcher listens to platform pop route notifications. When the +/// platform wants to pop the current route, this dispatcher calls the +/// [BackButtonDispatcher.invokeCallback] method to handle the request. +class RootBackButtonDispatcher extends BackButtonDispatcher with WidgetsBindingObserver { + /// Create a root back button dispatcher. + RootBackButtonDispatcher(); + + @override + void addCallback(ValueGetter> callback) { + if (!hasCallbacks) + WidgetsBinding.instance.addObserver(this); + super.addCallback(callback); + } + + @override + void removeCallback(ValueGetter> callback) { + super.removeCallback(callback); + if (!hasCallbacks) + WidgetsBinding.instance.removeObserver(this); + } + + @override + Future didPopRoute() => invokeCallback(Future.value(false)); +} + +/// A variant of [BackButtonDispatcher] which listens to notifications from a +/// parent back button dispatcher, and can take priority from its parent for the +/// handling of such notifications. +/// +/// Useful when [Router]s are being nested within each other. +/// +/// Use [Router.of] to obtain a reference to the nearest ancestor [Router], from +/// which the [Router.backButtonDispatcher] can be found, and then used as the +/// [parent] of the [ChildBackButtonDispatcher]. +class ChildBackButtonDispatcher extends BackButtonDispatcher { + /// Creates a back button dispatcher that acts as the child of another. + /// + /// The [parent] must not be null. + ChildBackButtonDispatcher(this.parent) : assert(parent != null); + + /// The back button dispatcher that this object will attempt to take priority + /// over when [takePriority] is called. + /// + /// The parent must have a listener registered before this child object can + /// have its [takePriority] or [deferTo] methods used. + final BackButtonDispatcher parent; + + /// The parent of this child back button dispatcher decide to let this + /// child to handle the invoke the callback request in + /// [BackButtonDispatcher.invokeCallback]. + /// + /// Return a boolean future with true if this child will handle the request; + /// otherwise, return a boolean future with false. + @protected + Future notifiedByParent(Future defaultValue) { + return invokeCallback(defaultValue); + } + + @override + void takePriority() { + parent.deferTo(this); + super.takePriority(); + } + + @override + void deferTo(ChildBackButtonDispatcher child) { + assert(hasCallbacks); + super.deferTo(child); + } + + @override + void removeCallback(ValueGetter> callback) { + super.removeCallback(callback); + if (!hasCallbacks) + parent.forget(this); + } +} + +/// A delegate that is used by the [Router] widget to parse a route information +/// into a configuration of type T. +/// +/// This delegate is used when the [Router] widget is first built with initial +/// route information from [Router.routeInformationProvider] and any subsequent +/// new route notifications from it. The [parseRouteInformation] widget calls +/// the [parseRouteInformation] with the route information. +abstract class RouteInformationParser { + /// Abstract const constructor. This constructor enables subclasses to provide + /// const constructors so that they can be used in const expressions. + const RouteInformationParser(); + + /// Converts the given route information into parsed data to pass to a + /// [RouterDelegate]. + /// + /// The method should return a future which completes when the parsing is + /// complete. The parsing may be asynchronous if, e.g., the parser needs to + /// communicate with the OEM thread to obtain additional data about the route. + /// + /// Consider using a [SynchronousFuture] if the result can be computed + /// synchronously, so that the [Router] does not need to wait for the next + /// microtask to pass the data to the [RouterDelegate]. + Future parseRouteInformation(RouteInformation routeInformation); + + /// Restore the route information from the given configuration. + /// + /// This is not required if you do not opt for the route information reporting + /// , which is used for updating browser history for the web application. If + /// you decides to opt in, you must also overrides this method to return a + /// route information. + /// + /// In practice, the [parseRouteInformation] method must produce an equivalent + /// configuration when passed this method's return value + RouteInformation restoreRouteInformation(T configuration) => null; +} + +/// A delegate that is used by the [Router] widget to build and configure a +/// navigating widget. +/// +/// This delegate is the core piece of the [Router] widget. It responds to +/// push route and pop route intent from the engine and notifies the [Router] +/// to rebuild. It also act as a builder for the [Router] widget and builds a +/// navigating widget, typically a [Navigator], when the [Router] widget +/// builds. +/// +/// When engine pushes a new route, the route information is parsed by the +/// [RouteInformationParser] to produce a configuration of type T. The router +/// delegate receives the configuration through [setInitialRoutePath] or +/// [setNewRoutePath] to configure itself and builds the latest navigating +/// widget upon asked. +/// +/// When implementing subclass, consider defining a listenable app state to be +/// used for building the navigating widget. The router delegate should update +/// the app state accordingly and notify the listener know the app state has +/// changed when it receive route related engine intents (e.g. +/// [setNewRoutePath], [setInitialRoutePath], or [popRoute]). +/// +/// All subclass must implement [setNewRoutePath], [popRoute], and [build]. +/// +/// See also: +/// +/// * [RouteInformationParser], which is responsible for parsing the route +/// information to a configuration before passing in to router delegate. +/// * [Router], which is the widget that wires all the delegates together to +/// provide a fully functional routing solution. +abstract class RouterDelegate extends Listenable { + /// Called by the [Router] at startup with the structure that the + /// [RouteInformationParser] obtained from parsing the initial route. + /// + /// This should configure the [RouterDelegate] so that when [build] is + /// invoked, it will create a widget tree that matches the initial route. + /// + /// By default, this method forwards the [configuration] to [setNewRoutePath]. + /// + /// Consider using a [SynchronousFuture] if the result can be computed + /// synchronously, so that the [Router] does not need to wait for the next + /// microtask to schedule a build. + Future setInitialRoutePath(T configuration) { + return setNewRoutePath(configuration); + } + + /// Called by the [Router] when the [Router.routeInformationProvider] reports that a + /// new route has been pushed to the application by the operating system. + /// + /// Consider using a [SynchronousFuture] if the result can be computed + /// synchronously, so that the [Router] does not need to wait for the next + /// microtask to schedule a build. + Future setNewRoutePath(T configuration); + + /// Called by the [Router] when the [Router.backButtonDispatcher] reports that + /// the operating system is requesting that the current route be popped. + /// + /// The method should return a boolean [Future] to indicate whether this + /// delegate handles the request. Returning false will cause the entire app + /// to be popped. + /// + /// Consider using a [SynchronousFuture] if the result can be computed + /// synchronously, so that the [Router] does not need to wait for the next + /// microtask to schedule a build. + Future popRoute(); + + /// Called by the [Router] when it detects a route information may have + /// changed as a result of rebuild. + /// + /// If this getter returns non-null, the [Router] will start to report new + /// route information back to the engine. In web applications, the new + /// route information is used for populating browser history in order to + /// support the forward and the backward buttons. + /// + /// When overriding this method, the configuration returned by this getter + /// must be able to construct the current app state and build the widget + /// with the same configuration in the [build] method if it is passed back + /// to the the [setNewRoutePath]. Otherwise, the browser backward and forward + /// buttons will not work properly. + /// + /// By default, this getter returns null, which prevents the [Router] from + /// reporting the route information. To opt in, a subclass can override this + /// getter to return the current configuration. + /// + /// At most one [Router] can opt in to route information reporting. Typically, + /// only the top-most [Router] created by [WidgetsApp.router] should opt for + /// route information reporting. + T get currentConfiguration => null; + + /// Called by the [Router] to obtain the widget tree that represents the + /// current state. + /// + /// This is called whenever the [setInitialRoutePath] method's future + /// completes, the [setNewRoutePath] method's future completes with the value + /// true, the [popRoute] method's future completes with the value true, or + /// this object notifies its clients (see the [Listenable] interface, which + /// this interface includes). In addition, it may be called at other times. It + /// is important, therefore, that the methods above do not update the state + /// that the [build] method uses before they complete their respective + /// futures. + /// + /// Typically this method returns a suitably-configured [Navigator]. If you do + /// plan to create a navigator, consider using the + /// [PopNavigatorRouterDelegateMixin]. + /// + /// This method must not return null. + /// + /// The `context` is the [Router]'s build context. + Widget build(BuildContext context); +} + +/// A route information provider that provides route information for the +/// [Router] widget +/// +/// This provider is responsible for handing the route information through [value] +/// getter and notifies listeners, typically the [Router] widget, when a new +/// route information is available. +/// +/// When the router opts for the route information reporting (by overrides the +/// [RouterDelegate.currentConfiguration] to return non-null), overrides the +/// [routerReportsNewRouteInformation] method to process the route information. +/// +/// See also: +/// +/// * [PlatformRouteInformationProvider], which wires up the itself with the +/// [WidgetsBindingObserver.didPushRoute] to propagate platform push route +/// intent to the [Router] widget, as well as reports new route information +/// from the [Router] back to the engine by overriding the +/// [routerReportsNewRouteInformation]. +abstract class RouteInformationProvider extends ValueListenable { + /// A callback called when the [Router] widget detects any navigation event + /// due to state changes. + /// + /// The subclasses can override this method to update theirs values or trigger + /// other side effects. For example, the [PlatformRouteInformationProvider] + /// overrides this method to report the route information back to the engine. + /// + /// The [routeInformation] is the new route information after the navigation + /// event. + void routerReportsNewRouteInformation(RouteInformation routeInformation) {} +} + +/// The route information provider that propagates the platform route information changes. +/// +/// This provider also reports the new route information from the [Router] widget +/// back to engine using message channel method, the +/// [SystemNavigator.routeInformationUpdated]. +class PlatformRouteInformationProvider extends RouteInformationProvider with WidgetsBindingObserver, ChangeNotifier { + /// Create a platform route information provider. + /// + /// Use the [initialRouteInformation] to set the default route information for this + /// provider. + PlatformRouteInformationProvider({ + RouteInformation initialRouteInformation + }) : _value = initialRouteInformation; + + @override + void routerReportsNewRouteInformation(RouteInformation routeInformation) { + SystemNavigator.routeInformationUpdated( + location: routeInformation.location, + state: routeInformation.state, + ); + _value = routeInformation; + } + + @override + RouteInformation get value => _value; + RouteInformation _value; + + void _platformReportsNewRouteInformation(RouteInformation routeInformation) { + if (_value == routeInformation) + return; + _value = routeInformation; + notifyListeners(); + } + + @override + void addListener(VoidCallback listener) { + if (!hasListeners) + WidgetsBinding.instance.addObserver(this); + super.addListener(listener); + } + + @override + void removeListener(VoidCallback listener) { + super.removeListener(listener); + if (!hasListeners) + WidgetsBinding.instance.removeObserver(this); + } + + @override + void dispose() { + // In practice, this will rarely be called. We assume that the listeners + // will be added and removed in a coherent fashion such that when the object + // is no longer being used, there's no listener, and so it will get garbage + // collected. + if (hasListeners) + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + Future didPushRouteInformation(RouteInformation routeInformation) async { + assert(hasListeners); + _platformReportsNewRouteInformation(routeInformation); + return true; + } + + @override + Future didPushRoute(String route) async { + assert(hasListeners); + _platformReportsNewRouteInformation(RouteInformation(location: route)); + return true; + } +} + +/// A mixin that wires [RouterDelegate.popRoute] to the [Navigator] it builds. +/// +/// This mixin calls [Navigator.maybePop] when it receives an Android back +/// button intent through the [RouterDelegate.popRoute]. Using this mixin +/// guarantees that the back button still respects pageless routes in the +/// navigator. +/// +/// Only use this mixin if you plan to build a navigator in the +/// [RouterDelegate.build]. +mixin PopNavigatorRouterDelegateMixin on RouterDelegate { + /// The key used for retrieving the current navigator. + /// + /// When using this mixin, be sure to use this key to create the navigator. + GlobalKey get navigatorKey; + + @override + Future popRoute() { + final NavigatorState navigator = navigatorKey?.currentState; + if (navigator == null) + return SynchronousFuture(false); + return navigator.maybePop(); + } +} diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart index c595c18eec..06a0e08495 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -85,6 +85,7 @@ export 'src/widgets/primary_scroll_controller.dart'; export 'src/widgets/raw_keyboard_listener.dart'; export 'src/widgets/restoration.dart'; export 'src/widgets/restoration_properties.dart'; +export 'src/widgets/router.dart'; export 'src/widgets/routes.dart'; export 'src/widgets/safe_area.dart'; export 'src/widgets/scroll_activity.dart'; diff --git a/packages/flutter/test/cupertino/app_test.dart b/packages/flutter/test/cupertino/app_test.dart index 9fb55ccbcb..3abf497e02 100644 --- a/packages/flutter/test/cupertino/app_test.dart +++ b/packages/flutter/test/cupertino/app_test.dart @@ -6,6 +6,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; void main() { testWidgets('Heroes work', (WidgetTester tester) async { @@ -147,4 +149,101 @@ void main() { expect(key2.currentState, isA()); expect(key1.currentState, isNull); }); + + testWidgets('CupertinoApp.router works', (WidgetTester tester) async { + final PlatformRouteInformationProvider provider = PlatformRouteInformationProvider( + initialRouteInformation: const RouteInformation( + location: 'initial', + ), + ); + final SimpleNavigatorRouterDelegate delegate = SimpleNavigatorRouterDelegate( + builder: (BuildContext context, RouteInformation information) { + return Text(information.location); + }, + onPopPage: (Route route, void result, SimpleNavigatorRouterDelegate delegate) { + delegate.routeInformation = const RouteInformation( + location: 'popped', + ); + return route.didPop(result); + } + ); + await tester.pumpWidget(CupertinoApp.router( + routeInformationProvider: provider, + routeInformationParser: SimpleRouteInformationParser(), + routerDelegate: delegate, + )); + expect(find.text('initial'), findsOneWidget); + + // Simulate android back button intent. + final ByteData message = const JSONMethodCodec().encodeMethodCall(const MethodCall('popRoute')); + await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage('flutter/navigation', message, (_) { }); + await tester.pumpAndSettle(); + expect(find.text('popped'), findsOneWidget); + }); +} + +typedef SimpleRouterDelegateBuilder = Widget Function(BuildContext, RouteInformation); +typedef SimpleNavigatorRouterDelegatePopPage = bool Function(Route route, T result, SimpleNavigatorRouterDelegate delegate); + +class SimpleRouteInformationParser extends RouteInformationParser { + SimpleRouteInformationParser(); + + @override + Future parseRouteInformation(RouteInformation information) { + return SynchronousFuture(information); + } + + @override + RouteInformation restoreRouteInformation(RouteInformation configuration) { + return configuration; + } +} + +class SimpleNavigatorRouterDelegate extends RouterDelegate with PopNavigatorRouterDelegateMixin, ChangeNotifier { + SimpleNavigatorRouterDelegate({ + @required this.builder, + this.onPopPage, + }); + + @override + GlobalKey navigatorKey = GlobalKey(); + + RouteInformation get routeInformation => _routeInformation; + RouteInformation _routeInformation; + set routeInformation(RouteInformation newValue) { + _routeInformation = newValue; + notifyListeners(); + } + + SimpleRouterDelegateBuilder builder; + SimpleNavigatorRouterDelegatePopPage onPopPage; + + @override + Future setNewRoutePath(RouteInformation configuration) { + _routeInformation = configuration; + return SynchronousFuture(null); + } + + bool _handlePopPage(Route route, void data) { + return onPopPage(route, data, this); + } + + @override + Widget build(BuildContext context) { + return Navigator( + key: navigatorKey, + onPopPage: _handlePopPage, + pages: >[ + // We need at least two pages for the pop to propagate through. + // Otherwise, the navigator will bubble the pop to the system navigator. + CupertinoPage( + builder: (BuildContext context) => const Text('base'), + ), + CupertinoPage( + key: ValueKey(routeInformation?.location), + builder: (BuildContext context) => builder(context, routeInformation), + ) + ], + ); + } } diff --git a/packages/flutter/test/material/app_test.dart b/packages/flutter/test/material/app_test.dart index afbe731cf7..232f869cb3 100644 --- a/packages/flutter/test/material/app_test.dart +++ b/packages/flutter/test/material/app_test.dart @@ -4,7 +4,9 @@ // @dart = 2.8 +import 'package:flutter/foundation.dart'; import 'package:flutter/semantics.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; @@ -947,6 +949,37 @@ void main() { expect(key2.currentState, isA()); expect(key1.currentState, isNull); }); + + testWidgets('MaterialApp.router works', (WidgetTester tester) async { + final PlatformRouteInformationProvider provider = PlatformRouteInformationProvider( + initialRouteInformation: const RouteInformation( + location: 'initial', + ), + ); + final SimpleNavigatorRouterDelegate delegate = SimpleNavigatorRouterDelegate( + builder: (BuildContext context, RouteInformation information) { + return Text(information.location); + }, + onPopPage: (Route route, void result, SimpleNavigatorRouterDelegate delegate) { + delegate.routeInformation = const RouteInformation( + location: 'popped', + ); + return route.didPop(result); + } + ); + await tester.pumpWidget(MaterialApp.router( + routeInformationProvider: provider, + routeInformationParser: SimpleRouteInformationParser(), + routerDelegate: delegate, + )); + expect(find.text('initial'), findsOneWidget); + + // Simulate android back button intent. + final ByteData message = const JSONMethodCodec().encodeMethodCall(const MethodCall('popRoute')); + await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage('flutter/navigation', message, (_) { }); + await tester.pumpAndSettle(); + expect(find.text('popped'), findsOneWidget); + }); } class MockAccessibilityFeature implements AccessibilityFeatures { @@ -968,3 +1001,69 @@ class MockAccessibilityFeature implements AccessibilityFeatures { @override bool get reduceMotion => true; } + +typedef SimpleRouterDelegateBuilder = Widget Function(BuildContext, RouteInformation); +typedef SimpleNavigatorRouterDelegatePopPage = bool Function(Route route, T result, SimpleNavigatorRouterDelegate delegate); + +class SimpleRouteInformationParser extends RouteInformationParser { + SimpleRouteInformationParser(); + + @override + Future parseRouteInformation(RouteInformation information) { + return SynchronousFuture(information); + } + + @override + RouteInformation restoreRouteInformation(RouteInformation configuration) { + return configuration; + } +} + +class SimpleNavigatorRouterDelegate extends RouterDelegate with PopNavigatorRouterDelegateMixin, ChangeNotifier { + SimpleNavigatorRouterDelegate({ + @required this.builder, + this.onPopPage, + }); + + @override + GlobalKey navigatorKey = GlobalKey(); + + RouteInformation get routeInformation => _routeInformation; + RouteInformation _routeInformation; + set routeInformation(RouteInformation newValue) { + _routeInformation = newValue; + notifyListeners(); + } + + SimpleRouterDelegateBuilder builder; + SimpleNavigatorRouterDelegatePopPage onPopPage; + + @override + Future setNewRoutePath(RouteInformation configuration) { + _routeInformation = configuration; + return SynchronousFuture(null); + } + + bool _handlePopPage(Route route, void data) { + return onPopPage(route, data, this); + } + + @override + Widget build(BuildContext context) { + return Navigator( + key: navigatorKey, + onPopPage: _handlePopPage, + pages: >[ + // We need at least two pages for the pop to propagate through. + // Otherwise, the navigator will bubble the pop to the system navigator. + MaterialPage( + builder: (BuildContext context) => const Text('base'), + ), + MaterialPage( + key: ValueKey(routeInformation?.location), + builder: (BuildContext context) => builder(context, routeInformation), + ) + ], + ); + } +} diff --git a/packages/flutter/test/widgets/app_test.dart b/packages/flutter/test/widgets/app_test.dart index 0d80588738..2336e510de 100644 --- a/packages/flutter/test/widgets/app_test.dart +++ b/packages/flutter/test/widgets/app_test.dart @@ -264,4 +264,116 @@ void main() { expect(find.text('non-regular page one'), findsOneWidget); expect(find.text('regular page'), findsNothing); }); + + testWidgets('WidgetsApp.router works', (WidgetTester tester) async { + final PlatformRouteInformationProvider provider = PlatformRouteInformationProvider( + initialRouteInformation: const RouteInformation( + location: 'initial', + ), + ); + final SimpleNavigatorRouterDelegate delegate = SimpleNavigatorRouterDelegate( + builder: (BuildContext context, RouteInformation information) { + return Text(information.location); + }, + onPopPage: (Route route, void result, SimpleNavigatorRouterDelegate delegate) { + delegate.routeInformation = const RouteInformation( + location: 'popped', + ); + return route.didPop(result); + } + ); + await tester.pumpWidget(WidgetsApp.router( + routeInformationProvider: provider, + routeInformationParser: SimpleRouteInformationParser(), + routerDelegate: delegate, + color: const Color(0xFF123456), + )); + expect(find.text('initial'), findsOneWidget); + + // Simulate android back button intent. + final ByteData message = const JSONMethodCodec().encodeMethodCall(const MethodCall('popRoute')); + await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage('flutter/navigation', message, (_) { }); + await tester.pumpAndSettle(); + expect(find.text('popped'), findsOneWidget); + }); + + testWidgets('WidgetsApp.router has correct default', (WidgetTester tester) async { + final SimpleNavigatorRouterDelegate delegate = SimpleNavigatorRouterDelegate( + builder: (BuildContext context, RouteInformation information) { + return Text(information.location); + }, + ); + await tester.pumpWidget(WidgetsApp.router( + routeInformationParser: SimpleRouteInformationParser(), + routerDelegate: delegate, + color: const Color(0xFF123456), + )); + expect(find.text('/'), findsOneWidget); + }); +} + +typedef SimpleRouterDelegateBuilder = Widget Function(BuildContext, RouteInformation); +typedef SimpleNavigatorRouterDelegatePopPage = bool Function(Route route, T result, SimpleNavigatorRouterDelegate delegate); + +class SimpleRouteInformationParser extends RouteInformationParser { + SimpleRouteInformationParser(); + + @override + Future parseRouteInformation(RouteInformation information) { + return SynchronousFuture(information); + } + + @override + RouteInformation restoreRouteInformation(RouteInformation configuration) { + return configuration; + } +} + +class SimpleNavigatorRouterDelegate extends RouterDelegate with PopNavigatorRouterDelegateMixin, ChangeNotifier { + SimpleNavigatorRouterDelegate({ + @required this.builder, + this.onPopPage, + }); + + @override + GlobalKey navigatorKey = GlobalKey(); + + RouteInformation get routeInformation => _routeInformation; + RouteInformation _routeInformation; + set routeInformation(RouteInformation newValue) { + _routeInformation = newValue; + notifyListeners(); + } + + SimpleRouterDelegateBuilder builder; + SimpleNavigatorRouterDelegatePopPage onPopPage; + + @override + Future setNewRoutePath(RouteInformation configuration) { + _routeInformation = configuration; + return SynchronousFuture(null); + } + + bool _handlePopPage(Route route, void data) { + return onPopPage(route, data, this); + } + + @override + Widget build(BuildContext context) { + return Navigator( + key: navigatorKey, + onPopPage: _handlePopPage, + pages: >[ + // We need at least two pages for the pop to propagate through. + // Otherwise, the navigator will bubble the pop to the system navigator. + MaterialPage( + builder: (BuildContext context) => const Text('base'), + ), + MaterialPage( + key: ValueKey(routeInformation?.location), + builder: (BuildContext context) => builder(context, routeInformation), + ) + ], + ); + } } diff --git a/packages/flutter/test/widgets/binding_test.dart b/packages/flutter/test/widgets/binding_test.dart index 837e4ef43d..a17cfab994 100644 --- a/packages/flutter/test/widgets/binding_test.dart +++ b/packages/flutter/test/widgets/binding_test.dart @@ -40,6 +40,16 @@ class PushRouteObserver with WidgetsBindingObserver { } } +class PushRouteInformationObserver with WidgetsBindingObserver { + RouteInformation pushedRouteInformation; + + @override + Future didPushRouteInformation(RouteInformation routeInformation) async { + pushedRouteInformation = routeInformation; + return true; + } +} + void main() { setUp(() { WidgetsFlutterBinding.ensureInitialized(); @@ -90,6 +100,38 @@ void main() { WidgetsBinding.instance.removeObserver(observer); }); + testWidgets('didPushRouteInformation calls didPushRoute by default', (WidgetTester tester) async { + final PushRouteObserver observer = PushRouteObserver(); + WidgetsBinding.instance.addObserver(observer); + + const Map testRouteInformation = { + 'location': 'testRouteName', + 'state': 'state', + 'restorationData': {'test': 'config'} + }; + final ByteData message = const JSONMethodCodec().encodeMethodCall( + const MethodCall('pushRouteInformation', testRouteInformation)); + await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage('flutter/navigation', message, (_) { }); + expect(observer.pushedRoute, 'testRouteName'); + WidgetsBinding.instance.removeObserver(observer); + }); + + testWidgets('didPushRouteInformation callback', (WidgetTester tester) async { + final PushRouteInformationObserver observer = PushRouteInformationObserver(); + WidgetsBinding.instance.addObserver(observer); + + const Map testRouteInformation = { + 'location': 'testRouteName', + 'state': 'state', + }; + final ByteData message = const JSONMethodCodec().encodeMethodCall( + const MethodCall('pushRouteInformation', testRouteInformation)); + await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage('flutter/navigation', message, (_) { }); + expect(observer.pushedRouteInformation.location, 'testRouteName'); + expect(observer.pushedRouteInformation.state, 'state'); + WidgetsBinding.instance.removeObserver(observer); + }); + testWidgets('Application lifecycle affects frame scheduling', (WidgetTester tester) async { final BinaryMessenger defaultBinaryMessenger = ServicesBinding.instance.defaultBinaryMessenger; ByteData message; diff --git a/packages/flutter/test/widgets/route_notification_messages_test.dart b/packages/flutter/test/widgets/route_notification_messages_test.dart index 9c967d2afd..46346e8e35 100644 --- a/packages/flutter/test/widgets/route_notification_messages_test.dart +++ b/packages/flutter/test/widgets/route_notification_messages_test.dart @@ -36,6 +36,13 @@ class OnTapPage extends StatelessWidget { } } +Map convertRouteInformationToMap(RouteInformation routeInformation) { + return { + 'location': routeInformation.location, + 'state': routeInformation.state, + }; +} + void main() { testWidgets('Push and Pop should send platform messages', (WidgetTester tester) async { final Map routes = { @@ -258,4 +265,109 @@ void main() { }), ); }); + + testWidgets('PlatformRouteInformationProvider reports URL', (WidgetTester tester) async { + final List log = []; + SystemChannels.navigation.setMockMethodCallHandler((MethodCall methodCall) async { + log.add(methodCall); + }); + + final PlatformRouteInformationProvider provider = PlatformRouteInformationProvider( + initialRouteInformation: const RouteInformation( + location: 'initial', + ), + ); + final SimpleRouterDelegate delegate = SimpleRouterDelegate( + reportConfiguration: true, + builder: (BuildContext context, RouteInformation information) { + return Text(information.location); + } + ); + + await tester.pumpWidget(MaterialApp.router( + routeInformationProvider: provider, + routeInformationParser: SimpleRouteInformationParser(), + routerDelegate: delegate, + )); + expect(find.text('initial'), findsOneWidget); + + // Triggers a router rebuild and verify the route information is reported + // to the web engine. + delegate.routeInformation = const RouteInformation( + location: 'update', + state: 'state', + ); + 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, + isMethodCall('routeInformationUpdated', arguments: { + 'location': 'update', + 'state': 'state', + }), + ); + }); +} + +typedef SimpleRouterDelegateBuilder = Widget Function(BuildContext, RouteInformation); +typedef SimpleRouterDelegatePopRoute = Future Function(); + +class SimpleRouteInformationParser extends RouteInformationParser { + SimpleRouteInformationParser(); + + @override + Future parseRouteInformation(RouteInformation information) { + return SynchronousFuture(information); + } + + @override + RouteInformation restoreRouteInformation(RouteInformation configuration) { + return configuration; + } +} + +class SimpleRouterDelegate extends RouterDelegate with ChangeNotifier { + SimpleRouterDelegate({ + @required this.builder, + this.onPopRoute, + this.reportConfiguration = false, + }); + + RouteInformation get routeInformation => _routeInformation; + RouteInformation _routeInformation; + set routeInformation(RouteInformation newValue) { + _routeInformation = newValue; + notifyListeners(); + } + + SimpleRouterDelegateBuilder builder; + SimpleRouterDelegatePopRoute onPopRoute; + final bool reportConfiguration; + + @override + RouteInformation get currentConfiguration { + if (reportConfiguration) + return routeInformation; + return null; + } + + @override + Future setNewRoutePath(RouteInformation configuration) { + _routeInformation = configuration; + return SynchronousFuture(null); + } + + @override + Future popRoute() { + if (onPopRoute != null) + return onPopRoute(); + return SynchronousFuture(true); + } + + @override + Widget build(BuildContext context) => builder(context, routeInformation); } diff --git a/packages/flutter/test/widgets/router_test.dart b/packages/flutter/test/widgets/router_test.dart new file mode 100644 index 0000000000..4cf54635bf --- /dev/null +++ b/packages/flutter/test/widgets/router_test.dart @@ -0,0 +1,705 @@ +// Copyright 2014 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. + +// @dart = 2.8 + +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +void main() { + testWidgets('Simple router basic functionality - synchronized', (WidgetTester tester) async { + final SimpleRouteInformationProvider provider = SimpleRouteInformationProvider(); + provider.value = const RouteInformation( + location: 'initial', + ); + await tester.pumpWidget(buildBoilerPlate( + Router( + routeInformationProvider: provider, + routeInformationParser: SimpleRouteInformationParser(), + routerDelegate: SimpleRouterDelegate( + builder: (BuildContext context, RouteInformation information) { + return Text(information.location); + } + ), + ) + )); + expect(find.text('initial'), findsOneWidget); + + provider.value = const RouteInformation( + location: 'update', + ); + await tester.pump(); + expect(find.text('initial'), findsNothing); + expect(find.text('update'), findsOneWidget); + }); + + testWidgets('Simple router basic functionality - asynchronized', (WidgetTester tester) async { + final SimpleRouteInformationProvider provider = SimpleRouteInformationProvider(); + provider.value = const RouteInformation( + location: 'initial', + ); + final SimpleAsyncRouteInformationParser parser = SimpleAsyncRouteInformationParser(); + final SimpleAsyncRouterDelegate delegate = SimpleAsyncRouterDelegate( + builder: (BuildContext context, RouteInformation information) { + if (information == null) + return const Text('waiting'); + return Text(information.location); + } + ); + await tester.runAsync(() async { + await tester.pumpWidget(buildBoilerPlate( + Router( + routeInformationProvider: provider, + routeInformationParser: parser, + routerDelegate: delegate, + ) + )); + // Future has not yet completed. + expect(find.text('waiting'), findsOneWidget); + + await parser.parsingFuture; + await delegate.setNewRouteFuture; + await tester.pump(); + expect(find.text('initial'), findsOneWidget); + + provider.value = const RouteInformation( + location: 'update', + ); + await tester.pump(); + // Future has not yet completed. + expect(find.text('initial'), findsOneWidget); + + await parser.parsingFuture; + await delegate.setNewRouteFuture; + await tester.pump(); + expect(find.text('update'), findsOneWidget); + }); + }); + + testWidgets('Simple router can handle pop route', (WidgetTester tester) async { + final SimpleRouteInformationProvider provider = SimpleRouteInformationProvider(); + provider.value = const RouteInformation( + location: 'initial', + ); + final BackButtonDispatcher dispatcher = RootBackButtonDispatcher(); + + await tester.pumpWidget(buildBoilerPlate( + Router( + routeInformationProvider: provider, + routeInformationParser: SimpleRouteInformationParser(), + routerDelegate: SimpleRouterDelegate( + builder: (BuildContext context, RouteInformation information) { + return Text(information.location); + }, + onPopRoute: () { + provider.value = const RouteInformation( + location: 'popped', + ); + return SynchronousFuture(true); + } + ), + backButtonDispatcher: dispatcher, + ) + )); + expect(find.text('initial'), findsOneWidget); + + bool result = false; + // SynchronousFuture should complete immediately. + dispatcher.invokeCallback(SynchronousFuture(false)) + .then((bool data) { + result = data; + }); + expect(result, isTrue); + + await tester.pump(); + expect(find.text('popped'), findsOneWidget); + }); + + testWidgets('PopNavigatorRouterDelegateMixin works', (WidgetTester tester) async { + final SimpleRouteInformationProvider provider = SimpleRouteInformationProvider(); + provider.value = const RouteInformation( + location: 'initial', + ); + final BackButtonDispatcher dispatcher = RootBackButtonDispatcher(); + final SimpleNavigatorRouterDelegate delegate = SimpleNavigatorRouterDelegate( + builder: (BuildContext context, RouteInformation information) { + return Text(information.location); + }, + onPopPage: (Route route, void result) { + provider.value = const RouteInformation( + location: 'popped', + ); + return route.didPop(result); + } + ); + await tester.pumpWidget(buildBoilerPlate( + Router( + routeInformationProvider: provider, + routeInformationParser: SimpleRouteInformationParser(), + routerDelegate: delegate, + backButtonDispatcher: dispatcher, + ) + )); + expect(find.text('initial'), findsOneWidget); + + // Pushes a nameless route. + showDialog( + useRootNavigator: false, + context: delegate.navigatorKey.currentContext, + builder: (BuildContext context) => const Text('dialog') + ); + await tester.pumpAndSettle(); + expect(find.text('dialog'), findsOneWidget); + + // Pops the nameless route and makes sure the initial page is shown. + bool result = false; + result = await dispatcher.invokeCallback(SynchronousFuture(false)); + expect(result, isTrue); + + await tester.pumpAndSettle(); + expect(find.text('initial'), findsOneWidget); + expect(find.text('dialog'), findsNothing); + + // Pops one more time. + result = false; + result = await dispatcher.invokeCallback(SynchronousFuture(false)); + expect(result, isTrue); + await tester.pumpAndSettle(); + expect(find.text('popped'), findsOneWidget); + }); + + testWidgets('Nested routers back button dispatcher works', (WidgetTester tester) async { + final SimpleRouteInformationProvider provider = SimpleRouteInformationProvider(); + provider.value = const RouteInformation( + location: 'initial', + ); + final BackButtonDispatcher outerDispatcher = RootBackButtonDispatcher(); + await tester.pumpWidget(buildBoilerPlate( + Router( + backButtonDispatcher: outerDispatcher, + routeInformationProvider: provider, + routeInformationParser: SimpleRouteInformationParser(), + routerDelegate: SimpleRouterDelegate( + builder: (BuildContext context, RouteInformation information) { + final BackButtonDispatcher innerDispatcher = ChildBackButtonDispatcher(outerDispatcher); + innerDispatcher.takePriority(); + // Creates the sub-router. + return Router( + backButtonDispatcher: innerDispatcher, + routerDelegate: SimpleRouterDelegate( + builder: (BuildContext context, RouteInformation innerInformation) { + return Text(information.location); + }, + onPopRoute: () { + provider.value = const RouteInformation( + location: 'popped inner', + ); + return SynchronousFuture(true); + }, + ), + ); + }, + onPopRoute: () { + provider.value = const RouteInformation( + location: 'popped outter', + ); + return SynchronousFuture(true); + } + ), + ) + )); + expect(find.text('initial'), findsOneWidget); + + // The outer dispatcher should trigger the pop on the inner router. + bool result = false; + result = await outerDispatcher.invokeCallback(SynchronousFuture(false)); + expect(result, isTrue); + await tester.pump(); + expect(find.text('popped inner'), findsOneWidget); + }); + + testWidgets('Nested router back button dispatcher works for multiple children', (WidgetTester tester) async { + final SimpleRouteInformationProvider provider = SimpleRouteInformationProvider(); + provider.value = const RouteInformation( + location: 'initial', + ); + final BackButtonDispatcher outerDispatcher = RootBackButtonDispatcher(); + final BackButtonDispatcher innerDispatcher1 = ChildBackButtonDispatcher(outerDispatcher); + final BackButtonDispatcher innerDispatcher2 = ChildBackButtonDispatcher(outerDispatcher); + await tester.pumpWidget(buildBoilerPlate( + Router( + backButtonDispatcher: outerDispatcher, + routeInformationProvider: provider, + routeInformationParser: SimpleRouteInformationParser(), + routerDelegate: SimpleRouterDelegate( + builder: (BuildContext context, RouteInformation information) { + // Creates the sub-router. + return Column( + children: [ + Text(information.location), + Router( + backButtonDispatcher: innerDispatcher1, + routerDelegate: SimpleRouterDelegate( + builder: (BuildContext context, RouteInformation innerInformation) { + return Container(); + }, + onPopRoute: () { + provider.value = const RouteInformation( + location: 'popped inner1', + ); + return SynchronousFuture(true); + }, + ), + ), + Router( + backButtonDispatcher: innerDispatcher2, + routerDelegate: SimpleRouterDelegate( + builder: (BuildContext context, RouteInformation innerInformation) { + return Container(); + }, + onPopRoute: () { + provider.value = const RouteInformation( + location: 'popped inner2', + ); + return SynchronousFuture(true); + }, + ), + ), + ], + ); + }, + onPopRoute: () { + provider.value = const RouteInformation( + location: 'popped outter', + ); + return SynchronousFuture(true); + } + ), + ) + )); + expect(find.text('initial'), findsOneWidget); + + // If none of the children have taken the priority, the root router handles + // the pop. + bool result = false; + result = await outerDispatcher.invokeCallback(SynchronousFuture(false)); + expect(result, isTrue); + await tester.pump(); + expect(find.text('popped outter'), findsOneWidget); + + innerDispatcher1.takePriority(); + result = false; + result = await outerDispatcher.invokeCallback(SynchronousFuture(false)); + expect(result, isTrue); + await tester.pump(); + expect(find.text('popped inner1'), findsOneWidget); + + // The last child dispatcher that took priority handles the pop. + innerDispatcher2.takePriority(); + result = false; + result = await outerDispatcher.invokeCallback(SynchronousFuture(false)); + expect(result, isTrue); + await tester.pump(); + expect(find.text('popped inner2'), findsOneWidget); + }); + + testWidgets('router does report URL change correctly', (WidgetTester tester) async { + RouteInformation reportedRouteInformation; + final SimpleRouteInformationProvider provider = SimpleRouteInformationProvider( + onRouterReport: (RouteInformation information) { + // Makes sure we only report once after manually cleaning up. + expect(reportedRouteInformation, isNull); + reportedRouteInformation = information; + } + ); + final SimpleRouterDelegate delegate = SimpleRouterDelegate( + reportConfiguration: true, + builder: (BuildContext context, RouteInformation information) { + return Text(information.location); + } + ); + delegate.onPopRoute = () { + delegate.routeInformation = const RouteInformation( + location: 'popped', + ); + return SynchronousFuture(true); + }; + final BackButtonDispatcher outerDispatcher = RootBackButtonDispatcher(); + + provider.value = const RouteInformation( + location: 'initial', + ); + + await tester.pumpWidget(buildBoilerPlate( + Router( + backButtonDispatcher: outerDispatcher, + routeInformationProvider: provider, + routeInformationParser: SimpleRouteInformationParser(), + routerDelegate: delegate, + ) + )); + expect(find.text('initial'), findsOneWidget); + expect(reportedRouteInformation, isNull); + delegate.routeInformation = const RouteInformation( + location: 'update', + ); + await tester.pump(); + expect(find.text('initial'), findsNothing); + expect(find.text('update'), findsOneWidget); + expect(reportedRouteInformation.location, 'update'); + + // The router should not report if only state changes. + reportedRouteInformation = null; + delegate.routeInformation = const RouteInformation( + location: 'update', + state: 'another state', + ); + await tester.pump(); + expect(find.text('update'), findsOneWidget); + expect(reportedRouteInformation, isNull); + + reportedRouteInformation = null; + bool result = false; + result = await outerDispatcher.invokeCallback(SynchronousFuture(false)); + expect(result, isTrue); + await tester.pump(); + expect(find.text('popped'), findsOneWidget); + expect(reportedRouteInformation.location, 'popped'); + }); + + testWidgets('router can be forced to recognize or ignore navigating events', (WidgetTester tester) async { + RouteInformation reportedRouteInformation; + bool isNavigating = false; + RouteInformation nextRouteInformation; + final SimpleRouteInformationProvider provider = SimpleRouteInformationProvider( + onRouterReport: (RouteInformation information) { + // Makes sure we only report once after manually cleaning up. + expect(reportedRouteInformation, isNull); + reportedRouteInformation = information; + } + ); + provider.value = const RouteInformation( + location: 'initial', + ); + final SimpleRouterDelegate delegate = SimpleRouterDelegate(reportConfiguration: true); + delegate.builder = (BuildContext context, RouteInformation information) { + return ElevatedButton( + child: Text(information.location), + onPressed: () { + if (isNavigating) { + Router.navigate(context, () { + if (delegate.routeInformation != nextRouteInformation) + delegate.routeInformation = nextRouteInformation; + }); + } else { + Router.neglect(context, () { + if (delegate.routeInformation != nextRouteInformation) + delegate.routeInformation = nextRouteInformation; + }); + } + }, + ); + }; + final BackButtonDispatcher outerDispatcher = RootBackButtonDispatcher(); + + await tester.pumpWidget(buildBoilerPlate( + Router( + backButtonDispatcher: outerDispatcher, + routeInformationProvider: provider, + routeInformationParser: SimpleRouteInformationParser(), + routerDelegate: delegate, + ) + )); + expect(find.text('initial'), findsOneWidget); + expect(reportedRouteInformation, isNull); + + nextRouteInformation = const RouteInformation( + location: 'update', + ); + await tester.tap(find.byType(ElevatedButton)); + await tester.pump(); + expect(find.text('initial'), findsNothing); + expect(find.text('update'), findsOneWidget); + expect(reportedRouteInformation, isNull); + + isNavigating = true; + // This should not trigger any real navigating event because the + // nextRouteInformation does not change. However, the router should still + // report a route information because isNavigating = true. + await tester.tap(find.byType(ElevatedButton)); + await tester.pump(); + expect(reportedRouteInformation.location, 'update'); + }); + + testWidgets('PlatformRouteInformationProvider works', (WidgetTester tester) async { + final RouteInformationProvider provider = PlatformRouteInformationProvider( + initialRouteInformation: const RouteInformation( + location: 'initial', + ), + ); + final SimpleRouterDelegate delegate = SimpleRouterDelegate( + builder: (BuildContext context, RouteInformation information) { + final List children = []; + if (information.location != null) + children.add(Text(information.location)); + if (information.state != null) + children.add(Text(information.state.toString())); + return Column( + children: children, + ); + } + ); + + await tester.pumpWidget(MaterialApp.router( + routeInformationProvider: provider, + routeInformationParser: SimpleRouteInformationParser(), + routerDelegate: delegate, + )); + expect(find.text('initial'), findsOneWidget); + + // Pushes through the `pushRouteInformation` in the navigation method channel. + const Map testRouteInformation = { + 'location': 'testRouteName', + 'state': 'state', + }; + final ByteData routerMessage = const JSONMethodCodec().encodeMethodCall( + const MethodCall('pushRouteInformation', testRouteInformation) + ); + await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage('flutter/navigation', routerMessage, (_) { }); + await tester.pump(); + expect(find.text('testRouteName'), findsOneWidget); + expect(find.text('state'), findsOneWidget); + + // Pushes through the `pushRoute` in the navigation method channel. + const String testRouteName = 'newTestRouteName'; + final ByteData message = const JSONMethodCodec().encodeMethodCall( + const MethodCall('pushRoute', testRouteName)); + await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage('flutter/navigation', message, (_) { }); + await tester.pump(); + expect(find.text('newTestRouteName'), findsOneWidget); + }); + + testWidgets('RootBackButtonDispatcher works', (WidgetTester tester) async { + final BackButtonDispatcher outerDispatcher = RootBackButtonDispatcher(); + final RouteInformationProvider provider = PlatformRouteInformationProvider( + initialRouteInformation: const RouteInformation( + location: 'initial', + ), + ); + final SimpleRouterDelegate delegate = SimpleRouterDelegate( + reportConfiguration: true, + builder: (BuildContext context, RouteInformation information) { + return Text(information.location); + } + ); + delegate.onPopRoute = () { + delegate.routeInformation = const RouteInformation( + location: 'popped', + ); + return SynchronousFuture(true); + }; + + await tester.pumpWidget(MaterialApp.router( + backButtonDispatcher: outerDispatcher, + routeInformationProvider: provider, + routeInformationParser: SimpleRouteInformationParser(), + routerDelegate: delegate, + )); + expect(find.text('initial'), findsOneWidget); + + // Pop route through the message channel. + final ByteData message = const JSONMethodCodec().encodeMethodCall(const MethodCall('popRoute')); + await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage('flutter/navigation', message, (_) { }); + await tester.pump(); + expect(find.text('popped'), findsOneWidget); + }); +} + +Widget buildBoilerPlate(Widget child) { + return MaterialApp( + home: Scaffold( + body: child, + ), + ); +} + +typedef SimpleRouterDelegateBuilder = Widget Function(BuildContext, RouteInformation); +typedef SimpleRouterDelegatePopRoute = Future Function(); +typedef SimpleNavigatorRouterDelegatePopPage = bool Function(Route route, T result); +typedef RouterReportRouterInformation = void Function(RouteInformation); + +class SimpleRouteInformationParser extends RouteInformationParser { + SimpleRouteInformationParser(); + + @override + Future parseRouteInformation(RouteInformation information) { + return SynchronousFuture(information); + } + + @override + RouteInformation restoreRouteInformation(RouteInformation configuration) { + return configuration; + } +} + +class SimpleRouterDelegate extends RouterDelegate with ChangeNotifier { + SimpleRouterDelegate({ + this.builder, + this.onPopRoute, + this.reportConfiguration = false, + }); + + RouteInformation get routeInformation => _routeInformation; + RouteInformation _routeInformation; + set routeInformation(RouteInformation newValue) { + _routeInformation = newValue; + notifyListeners(); + } + + SimpleRouterDelegateBuilder builder; + SimpleRouterDelegatePopRoute onPopRoute; + final bool reportConfiguration; + + @override + RouteInformation get currentConfiguration { + if (reportConfiguration) + return routeInformation; + return null; + } + + @override + Future setNewRoutePath(RouteInformation configuration) { + _routeInformation = configuration; + return SynchronousFuture(null); + } + + @override + Future popRoute() { + if (onPopRoute != null) + return onPopRoute(); + return SynchronousFuture(true); + } + + @override + Widget build(BuildContext context) => builder(context, routeInformation); +} + +class SimpleNavigatorRouterDelegate extends RouterDelegate with PopNavigatorRouterDelegateMixin, ChangeNotifier { + SimpleNavigatorRouterDelegate({ + @required this.builder, + this.onPopPage, + }); + + @override + GlobalKey navigatorKey = GlobalKey(); + + RouteInformation get routeInformation => _routeInformation; + RouteInformation _routeInformation; + set routeInformation(RouteInformation newValue) { + _routeInformation = newValue; + notifyListeners(); + } + + SimpleRouterDelegateBuilder builder; + SimpleNavigatorRouterDelegatePopPage onPopPage; + + @override + Future setNewRoutePath(RouteInformation configuration) { + _routeInformation = configuration; + return SynchronousFuture(null); + } + + bool _handlePopPage(Route route, void data) { + return onPopPage(route, data); + } + + @override + Widget build(BuildContext context) { + return Navigator( + key: navigatorKey, + onPopPage: _handlePopPage, + pages: >[ + // We need at least two pages for the pop to propagate through. + // Otherwise, the navigator will bubble the pop to the system navigator. + MaterialPage( + builder: (BuildContext context) => const Text('base'), + ), + MaterialPage( + key: ValueKey(routeInformation?.location), + builder: (BuildContext context) => builder(context, routeInformation), + ) + ], + ); + } +} + +class SimpleRouteInformationProvider extends RouteInformationProvider with ChangeNotifier { + SimpleRouteInformationProvider({ + this.onRouterReport + }); + + RouterReportRouterInformation onRouterReport; + + @override + RouteInformation get value => _value; + RouteInformation _value; + set value(RouteInformation newValue) { + _value = newValue; + notifyListeners(); + } + + @override + void routerReportsNewRouteInformation(RouteInformation routeInformation) { + if (onRouterReport != null) + onRouterReport(routeInformation); + } +} + +class SimpleAsyncRouteInformationParser extends RouteInformationParser { + SimpleAsyncRouteInformationParser(); + + Future parsingFuture; + + @override + Future parseRouteInformation(RouteInformation information) { + return parsingFuture = Future.value(information); + } + + @override + RouteInformation restoreRouteInformation(RouteInformation configuration) { + return configuration; + } +} + +class SimpleAsyncRouterDelegate extends RouterDelegate with ChangeNotifier{ + SimpleAsyncRouterDelegate({ + @required this.builder, + }); + + RouteInformation get routeInformation => _routeInformation; + RouteInformation _routeInformation; + set routeInformation(RouteInformation newValue) { + _routeInformation = newValue; + notifyListeners(); + } + + SimpleRouterDelegateBuilder builder; + Future setNewRouteFuture; + + @override + Future setNewRoutePath(RouteInformation configuration) { + _routeInformation = configuration; + return setNewRouteFuture = Future.value(); + } + + @override + Future popRoute() { + return Future.value(true); + } + + @override + Widget build(BuildContext context) => builder(context, routeInformation); +}