blob: c497a79f43731c3283133da075cab932b7ece68c [file] [log] [blame]
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart';
import 'configuration.dart';
import 'logging.dart';
import 'match.dart';
import 'matching.dart';
import 'misc/error_screen.dart';
import 'pages/cupertino.dart';
import 'pages/custom_transition_page.dart';
import 'pages/material.dart';
import 'route_data.dart';
import 'typedefs.dart';
/// Builds the top-level Navigator for GoRouter.
class RouteBuilder {
/// [RouteBuilder] constructor.
RouteBuilder({
required this.configuration,
required this.builderWithNav,
required this.errorPageBuilder,
required this.errorBuilder,
required this.restorationScopeId,
required this.observers,
});
/// Builder function for a go router with Navigator.
final GoRouterBuilderWithNav builderWithNav;
/// Error page builder for the go router delegate.
final GoRouterPageBuilder? errorPageBuilder;
/// Error widget builder for the go router delegate.
final GoRouterWidgetBuilder? errorBuilder;
/// The route configuration for the app.
final RouteConfiguration configuration;
/// Restoration ID to save and restore the state of the navigator, including
/// its history.
final String? restorationScopeId;
/// NavigatorObserver used to receive notifications when navigating in between routes.
/// changes.
final List<NavigatorObserver> observers;
final GoRouterStateRegistry _registry = GoRouterStateRegistry();
final Map<Page<Object?>, RouteMatch> _routeMatchLookUp =
<Page<Object?>, RouteMatch>{};
/// Looks the the [RouteMatch] for a given [Page].
///
/// The [Page] must be in the latest [Navigator.pages]; otherwise, this method
/// returns null.
RouteMatch? getRouteMatchForPage(Page<Object?> page) =>
_routeMatchLookUp[page];
// final Map<>
/// Caches a HeroController for the nested Navigator, which solves cases where the
/// Hero Widget animation stops working when navigating.
final Map<GlobalKey<NavigatorState>, HeroController> _goHeroCache =
<GlobalKey<NavigatorState>, HeroController>{};
/// Builds the top-level Navigator for the given [RouteMatchList].
Widget build(
BuildContext context,
RouteMatchList matchList,
PopPageCallback onPopPage,
bool routerNeglect,
) {
if (matchList.isEmpty) {
// The build method can be called before async redirect finishes. Build a
// empty box until then.
return const SizedBox.shrink();
}
return builderWithNav(
context,
Builder(
builder: (BuildContext context) {
try {
final Map<Page<Object?>, GoRouterState> newRegistry =
<Page<Object?>, GoRouterState>{};
final Widget result = tryBuild(context, matchList, onPopPage,
routerNeglect, configuration.navigatorKey, newRegistry);
_registry.updateRegistry(newRegistry);
return GoRouterStateRegistryScope(
registry: _registry, child: result);
} on _RouteBuilderError catch (e) {
return _buildErrorNavigator(context, e, matchList.uri, onPopPage,
configuration.navigatorKey);
}
},
),
);
}
/// Builds the top-level Navigator by invoking the build method on each
/// matching route.
///
/// Throws a [_RouteBuilderError].
@visibleForTesting
Widget tryBuild(
BuildContext context,
RouteMatchList matchList,
PopPageCallback onPopPage,
bool routerNeglect,
GlobalKey<NavigatorState> navigatorKey,
Map<Page<Object?>, GoRouterState> registry,
) {
return builderWithNav(
context,
_buildNavigator(
onPopPage,
buildPages(context, matchList, onPopPage, routerNeglect, navigatorKey,
registry),
navigatorKey,
observers: observers,
),
);
}
/// Returns the top-level pages instead of the root navigator. Used for
/// testing.
@visibleForTesting
List<Page<Object?>> buildPages(
BuildContext context,
RouteMatchList matchList,
PopPageCallback onPopPage,
bool routerNeglect,
GlobalKey<NavigatorState> navigatorKey,
Map<Page<Object?>, GoRouterState> registry) {
final Map<GlobalKey<NavigatorState>, List<Page<Object?>>> keyToPage =
<GlobalKey<NavigatorState>, List<Page<Object?>>>{};
try {
_routeMatchLookUp.clear();
_buildRecursive(context, matchList, 0, onPopPage, routerNeglect,
keyToPage, navigatorKey, registry);
// Every Page should have a corresponding RouteMatch.
assert(keyToPage.values.flattened
.every((Page<Object?> page) => _routeMatchLookUp.containsKey(page)));
return keyToPage[navigatorKey]!;
} on _RouteBuilderError catch (e) {
return <Page<Object?>>[
_buildErrorPage(context, e, matchList.uri),
];
} finally {
/// Clean up previous cache to prevent memory leak.
_goHeroCache.removeWhere(
(GlobalKey<NavigatorState> key, _) => !keyToPage.keys.contains(key));
}
}
void _buildRecursive(
BuildContext context,
RouteMatchList matchList,
int startIndex,
PopPageCallback onPopPage,
bool routerNeglect,
Map<GlobalKey<NavigatorState>, List<Page<Object?>>> keyToPages,
GlobalKey<NavigatorState> navigatorKey,
Map<Page<Object?>, GoRouterState> registry,
) {
if (startIndex >= matchList.matches.length) {
return;
}
final RouteMatch match = matchList.matches[startIndex];
if (match.error != null) {
throw _RouteBuilderError('Match error found during build phase',
exception: match.error);
}
final RouteBase route = match.route;
final GoRouterState state = buildState(matchList, match);
if (route is GoRoute) {
final Page<Object?> page = _buildPageForRoute(context, state, match);
registry[page] = state;
// If this GoRoute is for a different Navigator, add it to the
// list of out of scope pages
final GlobalKey<NavigatorState> goRouteNavKey =
route.parentNavigatorKey ?? navigatorKey;
keyToPages.putIfAbsent(goRouteNavKey, () => <Page<Object?>>[]).add(page);
_buildRecursive(context, matchList, startIndex + 1, onPopPage,
routerNeglect, keyToPages, navigatorKey, registry);
} else if (route is ShellRoute) {
// The key for the Navigator that will display this ShellRoute's page.
final GlobalKey<NavigatorState> parentNavigatorKey = navigatorKey;
// The key to provide to the ShellRoute's Navigator.
final GlobalKey<NavigatorState> shellNavigatorKey = route.navigatorKey;
// The observers list for the ShellRoute's Navigator.
final List<NavigatorObserver> observers =
route.observers ?? <NavigatorObserver>[];
// Add an entry for the parent navigator if none exists.
keyToPages.putIfAbsent(parentNavigatorKey, () => <Page<Object?>>[]);
// Add an entry for the shell route's navigator
keyToPages.putIfAbsent(shellNavigatorKey, () => <Page<Object?>>[]);
// Calling _buildRecursive can result in adding pages to the
// parentNavigatorKey entry's list. Store the current length so
// that the page for this ShellRoute is placed at the right index.
final int shellPageIdx = keyToPages[parentNavigatorKey]!.length;
// Build the remaining pages
_buildRecursive(context, matchList, startIndex + 1, onPopPage,
routerNeglect, keyToPages, shellNavigatorKey, registry);
final HeroController heroController = _goHeroCache.putIfAbsent(
shellNavigatorKey, () => _getHeroController(context));
// Build the Navigator
final Widget child = HeroControllerScope(
controller: heroController,
child: _buildNavigator(
onPopPage, keyToPages[shellNavigatorKey]!, shellNavigatorKey,
observers: observers),
);
// Build the Page for this route
final Page<Object?> page =
_buildPageForRoute(context, state, match, child: child);
registry[page] = state;
// Place the ShellRoute's Page onto the list for the parent navigator.
keyToPages
.putIfAbsent(parentNavigatorKey, () => <Page<Object?>>[])
.insert(shellPageIdx, page);
}
}
Navigator _buildNavigator(
PopPageCallback onPopPage,
List<Page<Object?>> pages,
Key? navigatorKey, {
List<NavigatorObserver> observers = const <NavigatorObserver>[],
}) {
return Navigator(
key: navigatorKey,
restorationScopeId: restorationScopeId,
pages: pages,
observers: observers,
onPopPage: onPopPage,
);
}
/// Helper method that builds a [GoRouterState] object for the given [match]
/// and [pathParameters].
@visibleForTesting
GoRouterState buildState(RouteMatchList matchList, RouteMatch match) {
final RouteBase route = match.route;
String? name;
String path = '';
if (route is GoRoute) {
name = route.name;
path = route.path;
}
final RouteMatchList effectiveMatchList =
match is ImperativeRouteMatch ? match.matches : matchList;
return GoRouterState(
configuration,
location: effectiveMatchList.uri.toString(),
matchedLocation: match.matchedLocation,
name: name,
path: path,
fullPath: effectiveMatchList.fullPath,
pathParameters:
Map<String, String>.from(effectiveMatchList.pathParameters),
error: match.error,
queryParameters: effectiveMatchList.uri.queryParameters,
queryParametersAll: effectiveMatchList.uri.queryParametersAll,
extra: match.extra,
pageKey: match.pageKey,
);
}
/// Builds a [Page] for [StackedRoute]
Page<Object?> _buildPageForRoute(
BuildContext context, GoRouterState state, RouteMatch match,
{Widget? child}) {
final RouteBase route = match.route;
Page<Object?>? page;
if (route is GoRoute) {
// Call the pageBuilder if it's non-null
final GoRouterPageBuilder? pageBuilder = route.pageBuilder;
if (pageBuilder != null) {
page = pageBuilder(context, state);
}
} else if (route is ShellRoute) {
final ShellRoutePageBuilder? pageBuilder = route.pageBuilder;
assert(child != null, 'ShellRoute must contain a child route');
if (pageBuilder != null) {
page = pageBuilder(context, state, child!);
}
}
if (page is NoOpPage) {
page = null;
}
page ??= buildPage(context, state, Builder(builder: (BuildContext context) {
return _callRouteBuilder(context, state, match, childWidget: child);
}));
_routeMatchLookUp[page] = match;
// Return the result of the route's builder() or pageBuilder()
return page;
}
/// Calls the user-provided route builder from the [RouteMatch]'s [RouteBase].
Widget _callRouteBuilder(
BuildContext context, GoRouterState state, RouteMatch match,
{Widget? childWidget}) {
final RouteBase route = match.route;
if (route is GoRoute) {
final GoRouterWidgetBuilder? builder = route.builder;
if (builder == null) {
throw _RouteBuilderError('No routeBuilder provided to GoRoute: $route');
}
return builder(context, state);
} else if (route is ShellRoute) {
if (childWidget == null) {
throw _RouteBuilderException(
'Attempt to build ShellRoute without a child widget');
}
final ShellRouteBuilder? builder = route.builder;
if (builder == null) {
throw _RouteBuilderError('No builder provided to ShellRoute: $route');
}
return builder(context, state, childWidget);
}
throw _RouteBuilderException('Unsupported route type $route');
}
_PageBuilderForAppType? _pageBuilderForAppType;
Widget Function(
BuildContext context,
GoRouterState state,
)? _errorBuilderForAppType;
void _cacheAppType(BuildContext context) {
// cache app type-specific page and error builders
if (_pageBuilderForAppType == null) {
assert(_errorBuilderForAppType == null);
// can be null during testing
final Element? elem = context is Element ? context : null;
if (elem != null && isMaterialApp(elem)) {
log.info('Using MaterialApp configuration');
_pageBuilderForAppType = pageBuilderForMaterialApp;
_errorBuilderForAppType =
(BuildContext c, GoRouterState s) => MaterialErrorScreen(s.error);
} else if (elem != null && isCupertinoApp(elem)) {
log.info('Using CupertinoApp configuration');
_pageBuilderForAppType = pageBuilderForCupertinoApp;
_errorBuilderForAppType =
(BuildContext c, GoRouterState s) => CupertinoErrorScreen(s.error);
} else {
log.info('Using WidgetsApp configuration');
_pageBuilderForAppType = pageBuilderForWidgetApp;
_errorBuilderForAppType =
(BuildContext c, GoRouterState s) => ErrorScreen(s.error);
}
}
assert(_pageBuilderForAppType != null);
assert(_errorBuilderForAppType != null);
}
/// builds the page based on app type, i.e. MaterialApp vs. CupertinoApp
@visibleForTesting
Page<Object?> buildPage(
BuildContext context,
GoRouterState state,
Widget child,
) {
// build the page based on app type
_cacheAppType(context);
return _pageBuilderForAppType!(
key: state.pageKey,
name: state.name ?? state.path,
arguments: <String, String>{
...state.pathParameters,
...state.queryParameters
},
restorationId: state.pageKey.value,
child: child,
);
}
/// Builds a page without any transitions.
Page<void> pageBuilderForWidgetApp({
required LocalKey key,
required String? name,
required Object? arguments,
required String restorationId,
required Widget child,
}) =>
NoTransitionPage<void>(
name: name,
arguments: arguments,
key: key,
restorationId: restorationId,
child: child,
);
/// Builds a Navigator containing an error page.
Widget _buildErrorNavigator(
BuildContext context,
_RouteBuilderError e,
Uri uri,
PopPageCallback onPopPage,
GlobalKey<NavigatorState> navigatorKey) {
return _buildNavigator(
onPopPage,
<Page<Object?>>[
_buildErrorPage(context, e, uri),
],
navigatorKey,
);
}
/// Builds a an error page.
Page<void> _buildErrorPage(
BuildContext context,
_RouteBuilderError error,
Uri uri,
) {
final GoRouterState state = GoRouterState(
configuration,
location: uri.toString(),
matchedLocation: uri.path,
name: null,
queryParameters: uri.queryParameters,
queryParametersAll: uri.queryParametersAll,
error: Exception(error),
pageKey: const ValueKey<String>('error'),
);
// If the error page builder is provided, use that, otherwise, if the error
// builder is provided, wrap that in an app-specific page (for example,
// MaterialPage). Finally, if nothing is provided, use a default error page
// wrapped in the app-specific page.
_cacheAppType(context);
final GoRouterWidgetBuilder? errorBuilder = this.errorBuilder;
return errorPageBuilder != null
? errorPageBuilder!(context, state)
: buildPage(
context,
state,
errorBuilder != null
? errorBuilder(context, state)
: _errorBuilderForAppType!(context, state),
);
}
/// Return a HeroController based on the app type.
HeroController _getHeroController(BuildContext context) {
if (context is Element) {
if (isMaterialApp(context)) {
return createMaterialHeroController();
} else if (isCupertinoApp(context)) {
return createCupertinoHeroController();
}
}
return HeroController();
}
}
typedef _PageBuilderForAppType = Page<void> Function({
required LocalKey key,
required String? name,
required Object? arguments,
required String restorationId,
required Widget child,
});
/// An error that occurred while building the app's UI based on the route
/// matches.
class _RouteBuilderError extends Error {
/// Constructs a [_RouteBuilderError].
_RouteBuilderError(this.message, {this.exception});
/// The error message.
final String message;
/// The exception that occurred.
final Exception? exception;
@override
String toString() {
return '$message ${exception ?? ""}';
}
}
/// An error that occurred while building the app's UI based on the route
/// matches.
class _RouteBuilderException implements Exception {
/// Constructs a [_RouteBuilderException].
//ignore: unused_element
_RouteBuilderException(this.message, {this.exception});
/// The error message.
final String message;
/// The exception that occurred.
final Exception? exception;
@override
String toString() {
return '$message ${exception ?? ""}';
}
}