From a206bf7e2f3d64fb4453d87bad80f8e7acddcf0a Mon Sep 17 00:00:00 2001 From: chunhtai <47866232+chunhtai@users.noreply.github.com> Date: Wed, 1 Apr 2020 09:44:22 -0700 Subject: [PATCH] Reland "Implements the navigator page api (#50362)" (#53708) This reverts commit aee9e94c21009bfc6c08f442eacde06f001c25f9. --- .../flutter/lib/src/widgets/navigator.dart | 1064 ++++++++++++++++- .../flutter/test/widgets/navigator_test.dart | 716 +++++++++++ 2 files changed, 1735 insertions(+), 45 deletions(-) diff --git a/packages/flutter/lib/src/widgets/navigator.dart b/packages/flutter/lib/src/widgets/navigator.dart index c81cae0505f..b57d3522930 100644 --- a/packages/flutter/lib/src/widgets/navigator.dart +++ b/packages/flutter/lib/src/widgets/navigator.dart @@ -36,6 +36,11 @@ import 'ticker_provider.dart'; /// * [Navigator], which is where all the [Route]s end up. typedef RouteFactory = Route Function(RouteSettings settings); +/// Creates a route for the given context and route settings. +/// +/// Used by [CustomBuilderPage.routeBuilder]. +typedef RouteBuilder = Route Function(BuildContext context, RouteSettings settings); + /// Creates a series of one or more routes. /// /// Used by [Navigator.onGenerateInitialRoutes]. @@ -50,6 +55,15 @@ typedef RoutePredicate = bool Function(Route route); /// [ModalRoute.removeScopedWillPopCallback], and [WillPopScope]. typedef WillPopCallback = Future Function(); +/// Signature for the [Navigator.onPopPage] callback. +/// +/// This callback must call [Route.didPop] on the specified route and must +/// properly update the pages list the next time it is passed into +/// [Navigator.pages] so that it no longer includes the corresponding [Page]. +/// (Otherwise, the page will be interpreted as a new page to show when the +/// [Navigator.pages] list is next updated.) +typedef PopPageCallback = bool Function(Route route, dynamic result); + /// Indicates whether the current route should be popped. /// /// Used as the return value for [Route.willPop]. @@ -90,6 +104,13 @@ enum RoutePopDisposition { /// See [MaterialPageRoute] for a route that replaces the entire screen with a /// platform-adaptive transition. /// +/// A route can belong to a page if the [settings] are a subclass of [Page]. A +/// page-based route, as opposite to pageless route, is created from +/// [Page.createRoute] during [Navigator.pages] updates. The page associated +/// with this route may change during the lifetime of the route. If the +/// [Navigator] updates the page of this route, it calls [changedInternalState] +/// to notify the route that the page has been updated. +/// /// The type argument `T` is the route's return type, as used by /// [currentResult], [popped], and [didPop]. The type `void` may be used if the /// route does not return a value. @@ -98,7 +119,7 @@ abstract class Route { /// /// If the [settings] are not provided, an empty [RouteSettings] object is /// used instead. - Route({ RouteSettings settings }) : settings = settings ?? const RouteSettings(); + Route({ RouteSettings settings }) : _settings = settings ?? const RouteSettings(); /// The navigator that the route is in, if any. NavigatorState get navigator => _navigator; @@ -107,7 +128,26 @@ abstract class Route { /// The settings for this route. /// /// See [RouteSettings] for details. - final RouteSettings settings; + /// + /// The settings can change during the route's lifetime. If the settings + /// change, the route's overlays will be marked dirty (see + /// [changedInternalState]). + /// + /// If the route is created from a [Page] in the [Navigator.pages] list, then + /// this will be a [Page] subclass, and it will be updated each time its + /// corresponding [Page] in the [Navigator.pages] has changed. Once the + /// [Route] is removed from the history, this value stops updating (and + /// remains with its last value). + RouteSettings get settings => _settings; + RouteSettings _settings; + + void _updateSettings(RouteSettings newSettings) { + assert(newSettings != null); + if (_settings != newSettings) { + _settings = newSettings; + changedInternalState(); + } + } /// The overlay entries of this route. /// @@ -247,7 +287,6 @@ abstract class Route { /// /// See [popped], [didComplete], and [currentResult] for a discussion of the /// `result` argument. - @protected @mustCallSuper bool didPop(T result) { didComplete(result); @@ -447,6 +486,82 @@ class RouteSettings { String toString() => '${objectRuntimeType(this, 'RouteSettings')}("$name", $arguments)'; } +/// Describes the configuration of a [Route]. +/// +/// The type argument `T` is the corresponding [Route]'s return type, as +/// used by [Route.currentResult], [Route.popped], and [Route.didPop]. +/// +/// See also: +/// +/// * [Navigator.pages], which accepts a list of [Page]s and updates its routes +/// history. +/// * [CustomBuilderPage], a [Page] subclass that provides the API to build a +/// customized route. +abstract class Page extends RouteSettings { + /// Creates a page and initializes [key] for subclasses. + /// + /// The [arguments] argument must not be null. + const Page({ + this.key, + String name, + Object arguments, + }) : super(name: name, arguments: arguments); + + /// The key associated with this page. + /// + /// This key will be used for comparing pages in [canUpdate]. + final LocalKey key; + + /// Whether this page can be updated with the [other] page. + /// + /// Two pages are consider updatable if they have same the [runtimeType] and + /// [key]. + bool canUpdate(Page other) { + return other.runtimeType == runtimeType && + other.key == key; + } + + /// Creates the [Route] that corresponds to this page. + /// + /// The created [Route] must have its [Route.settings] property set to this [Page]. + Route createRoute(BuildContext context); + + @override + String toString() => '${objectRuntimeType(this, 'Page')}("$name", $key, $arguments)'; +} + +/// A [Page] that builds a customized [Route] based on the [routeBuilder]. +/// +/// The type argument `T` is the corresponding [Route]'s return type, as +/// used by [Route.currentResult], [Route.popped], and [Route.didPop]. +class CustomBuilderPage extends Page { + /// Creates a page with a custom route builder. + /// + /// Use [routeBuilder] to specify the route that will be created from this + /// page. + const CustomBuilderPage({ + @required LocalKey key, + @required this.routeBuilder, + String name, + Object arguments, + }) : assert(key != null), + assert(routeBuilder != null), + super(key: key, name: name, arguments: arguments); + + /// A builder that will be called during [createRoute] to create a [Route]. + /// + /// The routes returned from this builder must have their settings equal to + /// the input `settings`. + final RouteBuilder routeBuilder; + + @override + Route createRoute(BuildContext context) { + final Route route = routeBuilder(context, this); + assert(route.settings == this); + return route; + } +} + /// An interface for observing the behavior of a [Navigator]. class NavigatorObserver { /// The navigator that the observer is observing, if any. @@ -491,6 +606,336 @@ class NavigatorObserver { void didStopUserGesture() { } } +/// A [Route] wrapper interface that can be staged for [TransitionDelegate] to +/// decide how its underlying [Route] should transition on or off screen. +abstract class RouteTransitionRecord { + /// Retrieves the wrapped [Route]. + Route get route; + + /// Whether this route is entering the screen. + /// + /// If this property is true, this route requires an explicit decision on how + /// to transition into the screen. Such a decision should be made in the + /// [TransitionDelegate.resolve]. + bool get isEntering; + + bool _debugWaitingForExitDecision = false; + + /// Marks the [route] to be pushed with transition. + /// + /// During [TransitionDelegate.resolve], this can be called on an entering + /// route (where [RouteTransitionRecord.isEntering] is true) in indicate that the + /// route should be pushed onto the [Navigator] with an animated transition. + void markForPush(); + + /// Marks the [route] to be added without transition. + /// + /// During [TransitionDelegate.resolve], this can be called on an entering + /// route (where [RouteTransitionRecord.isEntering] is true) in indicate that the + /// route should be added onto the [Navigator] without an animated transition. + void markForAdd(); + + /// Marks the [route] to be popped with transition. + /// + /// During [TransitionDelegate.resolve], this can be called on an exiting + /// route to indicate that the route should be popped off the [Navigator] with + /// an animated transition. + void markForPop([dynamic result]); + + /// Marks the [route] to be completed without transition. + /// + /// During [TransitionDelegate.resolve], this can be called on an exiting + /// route to indicate that the route should be completed with the provided + /// result and removed from the [Navigator] without an animated transition. + void markForComplete([dynamic result]); + + /// Marks the [route] to be removed without transition. + /// + /// During [TransitionDelegate.resolve], this can be called on an exiting + /// route to indicate that the route should be removed from the [Navigator] + /// without completing and without an animated transition. + void markForRemove(); +} + +/// The delegate that decides how pages added and removed from [Navigator.pages] +/// transition in or out of the screen. +/// +/// This abstract class implements the API to be called by [Navigator] when it +/// requires explicit decisions on how the routes transition on or off the screen. +/// +/// To make route transition decisions, subclass must implement [resolve]. +/// +/// {@tool sample --template=freeform} +/// The following example demonstrates how to implement a subclass that always +/// removes or adds routes without animated transitions and puts the removed +/// routes at the top of the list. +/// +/// ```dart imports +/// import 'package:flutter/widgets.dart'; +/// ``` +/// +/// ```dart +/// class NoAnimationTransitionDelegate extends TransitionDelegate { +/// @override +/// Iterable resolve({ +/// List newPageRouteHistory, +/// Map locationToExitingPageRoute, +/// Map> pageRouteToPagelessRoutes, +/// }) { +/// final List results = []; +/// +/// for (final RouteTransitionRecord pageRoute in newPageRouteHistory) { +/// if (pageRoute.isEntering) { +/// pageRoute.markForAdd(); +/// } +/// results.add(pageRoute); +/// +/// } +/// for (final RouteTransitionRecord exitingPageRoute in locationToExitingPageRoute.values) { +/// exitingPageRoute.markForRemove(); +/// final List pagelessRoutes = pageRouteToPagelessRoutes[exitingPageRoute]; +/// if (pagelessRoutes != null) { +/// for (final RouteTransitionRecord pagelessRoute in pagelessRoutes) { +/// pagelessRoute.markForRemove(); +/// } +/// } +/// results.add(exitingPageRoute); +/// +/// } +/// return results; +/// } +/// } +/// +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [Navigator.transitionDelegate], which uses this class to make route +/// transition decisions. +/// * [DefaultTransitionDelegate], which implements the default way to decide +/// how routes transition in or out of the screen. +abstract class TransitionDelegate { + /// Creates a delegate and enables subclass to create a constant class. + const TransitionDelegate(); + + Iterable _transition({ + List newPageRouteHistory, + Map locationToExitingPageRoute, + Map> pageRouteToPagelessRoutes, + }) { + final Iterable results = resolve( + newPageRouteHistory: newPageRouteHistory, + locationToExitingPageRoute: locationToExitingPageRoute, + pageRouteToPagelessRoutes: pageRouteToPagelessRoutes, + ); + // Verifies the integrity after the decisions have been made. + // + // Here are the rules: + // - All the entering routes in newPageRouteHistory must either be pushed or + // added. + // - All the exiting routes in locationToExitingPageRoute must either be + // popped, completed or removed. + // - All the pageless routes that belong to exiting routes must either be + // popped, completed or removed. + // - All the entering routes in the result must preserve the same order as + // the entering routes in newPageRouteHistory, and the result must contain + // all exiting routes. + // ex: + // + // newPageRouteHistory = [A, B, C] + // + // locationToExitingPageRoute = {A -> D, C -> E} + // + // results = [A, B ,C ,D ,E] is valid + // results = [D, A, B ,C ,E] is also valid because exiting route can be + // inserted in any place + // + // results = [B, A, C ,D ,E] is invalid because B must be after A. + // results = [A, B, C ,E] is invalid because results must include D. + assert(() { + final List resultsToVerify = results.toList(growable: false); + final Set exitingPageRoutes = locationToExitingPageRoute.values.toSet(); + // Firstly, verifies all exiting routes have been marked. + for (final RouteTransitionRecord exitingPageRoute in exitingPageRoutes) { + assert(!exitingPageRoute._debugWaitingForExitDecision); + if (pageRouteToPagelessRoutes.containsKey(exitingPageRoute)) { + for (final RouteTransitionRecord pagelessRoute in pageRouteToPagelessRoutes[exitingPageRoute]) { + assert(!pagelessRoute._debugWaitingForExitDecision); + } + } + } + // Secondly, verifies the order of results matches the newPageRouteHistory + // and contains all the exiting routes. + int indexOfNextRouteInNewHistory = 0; + + for (final _RouteEntry routeEntry in resultsToVerify.cast<_RouteEntry>()) { + assert(routeEntry != null); + assert(!routeEntry.isEntering && !routeEntry._debugWaitingForExitDecision); + if ( + indexOfNextRouteInNewHistory >= newPageRouteHistory.length || + routeEntry != newPageRouteHistory[indexOfNextRouteInNewHistory] + ) { + assert(exitingPageRoutes.contains(routeEntry)); + exitingPageRoutes.remove(routeEntry); + } else { + indexOfNextRouteInNewHistory += 1; + } + } + + assert( + indexOfNextRouteInNewHistory == newPageRouteHistory.length && + exitingPageRoutes.isEmpty + ); + return true; + }()); + + return results; + } + + /// A method that will be called by the [Navigator] to decide how routes + /// transition in or out of the screen when [Navigator.pages] is updated. + /// + /// The `newPageRouteHistory` list contains all page-based routes in the order + /// that will be on the [Navigator]'s history stack after this update + /// completes. If a route in `newPageRouteHistory` has its + /// [RouteTransitionRecord.isEntering] set to true, this route requires explicit + /// decision on how it should transition onto the Navigator. To make a + /// decision, call [RouteTransitionRecord.markForPush] or + /// [RouteTransitionRecord.markForAdd]. + /// + /// The `locationToExitingPageRoute` contains the pages-based routes that + /// are removed from the routes history after page update and require explicit + /// decision on how to transition off the screen. This map records page-based + /// routes to be removed with the location of the route in the original route + /// history before the update. The keys are the locations represented by the + /// page-based routes that are directly below the removed routes, and the value + /// are the page-based routes to be removed. The location is null if the route + /// to be removed is the bottom most route. To make a decision for a removed + /// route, call [RouteTransitionRecord.markForPop], + /// [RouteTransitionRecord.markForComplete] or + /// [RouteTransitionRecord.markForRemove]. + /// + /// The `pageRouteToPagelessRoutes` records the page-based routes and their + /// associated pageless routes. If a page-based route is to be removed, its + /// associated pageless routes also require explicit decisions on how to + /// transition off the screen. + /// + /// Once all the decisions have been made, this method must merge the removed + /// routes and the `newPageRouteHistory` and return the merged result. The + /// order in the result will be the order the [Navigator] uses for updating + /// the route history. The return list must preserve the same order of routes + /// in `newPageRouteHistory`. The removed routes, however, can be inserted + /// into the return list freely as long as all of them are included. + /// + /// For example, consider the following case. + /// + /// newPageRouteHistory = [A, B, C] + /// + /// locationToExitingPageRoute = {A -> D, C -> E} + /// + /// The following outputs are valid. + /// + /// result = [A, B ,C ,D ,E] is valid + /// result = [D, A, B ,C ,E] is also valid because exiting route can be + /// inserted in any place + /// + /// The following outputs are invalid. + /// + /// result = [B, A, C ,D ,E] is invalid because B must be after A. + /// result = [A, B, C ,E] is invalid because results must include D. + /// + /// See also: + /// + /// * [RouteTransitionRecord.markForPush], which makes route enter the screen + /// with an animated transition. + /// * [RouteTransitionRecord.markForAdd], which makes route enter the screen + /// without an animated transition. + /// * [RouteTransitionRecord.markForPop], which makes route exit the screen + /// with an animated transition. + /// * [RouteTransitionRecord.markForRemove], which does not complete the + /// route and makes it exit the screen without an animated transition. + /// * [RouteTransitionRecord.markForComplete], which completes the route and + /// makes it exit the screen without an animated transition. + /// * [DefaultTransitionDelegate.resolve], which implements the default way + /// to decide how routes transition in or out of the screen. + Iterable resolve({ + List newPageRouteHistory, + Map locationToExitingPageRoute, + Map> pageRouteToPagelessRoutes, + }); +} + +/// The default implementation of [TransitionDelegate] that the [Navigator] will +/// use if its [Navigator.transitionDelegate] is not specified. +/// +/// This transition delegate follows two rules. Firstly, all the entering routes +/// are placed on top of the exiting routes if they are at the same location. +/// Secondly, the top most route will always transition with an animated transition. +/// All the other routes below will either be completed with +/// [Route.currentResult] or added without an animated transition. +class DefaultTransitionDelegate extends TransitionDelegate { + /// Creates a default transition delegate. + const DefaultTransitionDelegate() : super(); + + @override + Iterable resolve({ + List newPageRouteHistory, + Map locationToExitingPageRoute, + Map> pageRouteToPagelessRoutes, + }) { + final List results = []; + // This method will handle the exiting route and its corresponding pageless + // route at this location. It will also recursively check if there is any + // other exiting routes above it and handle them accordingly. + void handleExitingRoute(RouteTransitionRecord location, bool isLast) { + final RouteTransitionRecord exitingPageRoute = locationToExitingPageRoute[location]; + if (exitingPageRoute == null) + return; + assert(exitingPageRoute._debugWaitingForExitDecision); + final bool hasPagelessRoute = pageRouteToPagelessRoutes.containsKey(exitingPageRoute); + final bool isLastExitingPageRoute = isLast && !locationToExitingPageRoute.containsKey(exitingPageRoute); + if (isLastExitingPageRoute && !hasPagelessRoute) { + exitingPageRoute.markForPop(exitingPageRoute.route.currentResult); + } else { + exitingPageRoute.markForComplete(exitingPageRoute.route.currentResult); + } + results.add(exitingPageRoute); + + if (hasPagelessRoute) { + final List pagelessRoutes = pageRouteToPagelessRoutes[exitingPageRoute]; + for (final RouteTransitionRecord pagelessRoute in pagelessRoutes) { + assert(pagelessRoute._debugWaitingForExitDecision); + if (isLastExitingPageRoute && pagelessRoute == pagelessRoutes.last) { + pagelessRoute.markForPop(pagelessRoute.route.currentResult); + } else { + pagelessRoute.markForComplete(pagelessRoute.route.currentResult); + } + } + } + // It is possible there is another exiting route above this exitingPageRoute. + handleExitingRoute(exitingPageRoute, isLast); + } + + // Handles exiting route in the beginning of list. + handleExitingRoute(null, newPageRouteHistory.isEmpty); + + for (final RouteTransitionRecord pageRoute in newPageRouteHistory) { + final bool isLastIteration = newPageRouteHistory.last == pageRoute; + if (pageRoute.isEntering) { + if (!locationToExitingPageRoute.containsKey(pageRoute) && isLastIteration) { + pageRoute.markForPush(); + } else { + pageRoute.markForAdd(); + } + } + results.add(pageRoute); + handleExitingRoute(pageRoute, isLastIteration); + } + return results; + } +} + /// A widget that manages a set of child widgets with a stack discipline. /// /// Many apps have a navigator near the top of their widget hierarchy in order @@ -505,8 +950,9 @@ class NavigatorObserver { /// Mobile apps typically reveal their contents via full-screen elements /// called "screens" or "pages". In Flutter these elements are called /// routes and they're managed by a [Navigator] widget. The navigator -/// manages a stack of [Route] objects and provides methods for managing -/// the stack, like [Navigator.push] and [Navigator.pop]. +/// manages a stack of [Route] objects and provides two ways for managing +/// the stack, the declarative API [Navigator.pages] or imperative API +/// [Navigator.push] and [Navigator.pop]. /// /// When your user interface fits this paradigm of a stack, where the user /// should be able to _navigate_ back to an earlier element in the stack, @@ -518,6 +964,21 @@ class NavigatorObserver { /// used in the [Scaffold.appBar] property) can automatically add a back /// button for user navigation. /// +/// ## Using the Pages API +/// +/// The [Navigator] will convert its [Navigator.pages] into a stack of [Route]s +/// if it is provided. A change in [Navigator.pages] will trigger an update to +/// the stack of [Route]s. The [Navigator] will update its routes to match the +/// new configuration of its [Navigator.pages]. To use this API, one can use +/// [CustomBuilderPage] or create a [Page] subclass and defines a list of +/// [Page]s for [Navigator.pages]. A [Navigator.onPopPage] callback is also +/// required to properly clean up the input pages in case of a pop. +/// +/// By Default, the [Navigator] will use [DefaultTransitionDelegate] to decide +/// how routes transition in or out of the screen. To customize it, define a +/// [TransitionDelegate] subclass and provide it to the +/// [Navigator.transitionDelegate]. +/// /// ### Displaying a full-screen route /// /// Although you can create a navigator directly, it's most common to use the @@ -733,7 +1194,7 @@ class NavigatorObserver { /// ``` /// /// ```dart main -/// void main() => runApp(new MyApp()); +/// void main() => runApp(MyApp()); /// ``` /// /// ```dart @@ -859,17 +1320,77 @@ class NavigatorObserver { class Navigator extends StatefulWidget { /// Creates a widget that maintains a stack-based history of child widgets. /// - /// The [onGenerateRoute] argument must not be null. + /// The [onGenerateRoute], [pages], [onGenerateInitialRoutes], + /// [transitionDelegate], [observers] arguments must not be null. + /// + /// If the [pages] is not empty, the [onPopPage] must not be null. const Navigator({ Key key, + this.pages = const >[], + this.onPopPage, this.initialRoute, this.onGenerateInitialRoutes = Navigator.defaultGenerateInitialRoutes, this.onGenerateRoute, this.onUnknownRoute, + this.transitionDelegate = const DefaultTransitionDelegate(), this.observers = const [], - }) : assert(onGenerateInitialRoutes != null), + }) : assert(pages != null), + assert(onGenerateInitialRoutes != null), + assert(transitionDelegate != null), + assert(observers != null), super(key: key); + /// The list of pages with which to populate the history. + /// + /// Pages are turned into routes using [Page.createRoute] in a manner + /// analogous to how [Widget]s are turned into [Element]s (and [State]s or + /// [RenderObject]s) using [Widget.createElement] (and + /// [StatefulWidget.createState] or [RenderObjectWidget.createRenderObject]). + /// + /// When this list is updated, the new list is compared to the previous + /// list and the set of routes is updated accordingly. + /// + /// Some [Route]s do not correspond to [Page] objects, namely, those that are + /// added to the history using the [Navigator] API ([push] and friends). A + /// [Route] that does not correspond to a [Page] object is called a pageless + /// route and is tied to the [Route] that _does_ correspond to a [Page] object + /// that is below it in the history. + /// + /// Pages that are added or removed may be animated as controlled by the + /// [transitionDelegate]. If a page is removed that had other pageless routes + /// pushed on top of it using [push] and friends, those pageless routes are + /// also removed with or without animation as determined by the + /// [transitionDelegate]. + /// + /// To use this API, an [onPopPage] callback must also be provided to properly + /// clean up this list if a page has been popped. + /// + /// If [initialRoute] is non-null when the widget is first created, then + /// [onGenerateInitialRoutes] is used to generate routes that are above those + /// corresponding to [pages] in the initial history. + final List> pages; + + /// Called when [pop] is invoked but the current [Route] corresponds to a + /// [Page] found in the [pages] list. + /// + /// The `result` argument is the value with which the route is to complete + /// (e.g. the value returned from a dialog). + /// + /// This callback is responsible for calling [Route.didPop] and returning + /// whether this pop is successful. + /// + /// The [Navigator] widget should be rebuilt with a [pages] list that does not + /// contain the [Page] for the given [Route]. The next time the [pages] list + /// is updated, if the [Page] corresponding to this [Route] is still present, + /// it will be interpreted as a new route to display. + final PopPageCallback onPopPage; + + /// The delegate used for deciding how routes transition in or off the screen + /// during the [pages] updates. + /// + /// Defaults to [DefaultTransitionDelegate] if not specified, cannot be null. + final TransitionDelegate transitionDelegate; + /// The name of the first route to show. /// /// Defaults to [Navigator.defaultRouteName]. @@ -1680,6 +2201,14 @@ class Navigator extends StatefulWidget { // The _RouteLifecycle state machine (only goes down): // // [creation of a _RouteEntry] +// | +// + +// |\ +// | \ +// | staging +// | / +// |/ +// +-+----------+--+-------+ // / | | | // / | | | // / | | | @@ -1688,7 +2217,7 @@ class Navigator extends StatefulWidget { // pushReplace push* add* replace* // \ | | | // \ | | / -// +--pushing# | / +// +--pushing# adding / // \ / / // \ / / // idle--+-----+ @@ -1714,47 +2243,69 @@ class Navigator extends StatefulWidget { // route entry will exit that state. // # These states await futures or other events, then transition automatically. enum _RouteLifecycle { - // routes that are and will be present: + staging, // we will wait for transition delegate to decide what to do with this route. + // + // routes that are present: + // add, // we'll want to run install, didAdd, etc; a route created by onGenerateInitialRoutes or by the initial widget.pages + adding, // we'll want to run install, didAdd, etc; a route created by onGenerateInitialRoutes or by the initial widget.pages + // routes that are ready for transition. push, // we'll want to run install, didPush, etc; a route added via push() and friends pushReplace, // we'll want to run install, didPush, etc; a route added via pushReplace() and friends pushing, // we're waiting for the future from didPush to complete replace, // we'll want to run install, didReplace, etc; a route added via replace() and friends idle, // route is being harmless - // routes that are but will not present: + // + // routes that are not present: + // + // routes that should be included in route announcement and should still listen to transition changes. pop, // we'll want to call didPop remove, // we'll want to run didReplace/didRemove etc - // routes that are not and will not present: + // routes should not be included in route announcement but should still listen to transition changes. popping, // we're waiting for the route to call finalizeRoute to switch to dispose removing, // we are waiting for subsequent routes to be done animating, then will switch to dispose + // routes that are completely removed from the navigator and overlay. dispose, // we will dispose the route momentarily disposed, // we have disposed the route } typedef _RouteEntryPredicate = bool Function(_RouteEntry entry); -class _RouteEntry { +class _RouteEntry extends RouteTransitionRecord { _RouteEntry( this.route, { @required _RouteLifecycle initialState, }) : assert(route != null), assert(initialState != null), assert( + initialState == _RouteLifecycle.staging || initialState == _RouteLifecycle.add || initialState == _RouteLifecycle.push || initialState == _RouteLifecycle.pushReplace || initialState == _RouteLifecycle.replace ), - currentState = initialState; // ignore: prefer_initializing_formals + currentState = initialState; + @override final Route route; _RouteLifecycle currentState; - Route lastAnnouncedNextRoute; // last argument to Route.didChangeNext Route lastAnnouncedPreviousRoute; // last argument to Route.didChangePrevious Route lastAnnouncedPoppedNextRoute; // last argument to Route.didPopNext + Route lastAnnouncedNextRoute; // last argument to Route.didChangeNext - void handleAdd({ @required NavigatorState navigator, @required bool isNewFirst, @required Route previous, @required Route previousPresent }) { + bool get hasPage => route.settings is Page; + + bool canUpdateFrom(Page page) { + if (currentState.index > _RouteLifecycle.idle.index) + return false; + if (!hasPage) + return false; + final Page routePage = route.settings as Page; + return page.canUpdate(routePage); + } + + void handleAdd({ @required NavigatorState navigator}) { assert(currentState == _RouteLifecycle.add); assert(navigator != null); assert(navigator._debugLocked); @@ -1762,13 +2313,7 @@ class _RouteEntry { route._navigator = navigator; route.install(); assert(route.overlayEntries.isNotEmpty); - route.didAdd(); - currentState = _RouteLifecycle.idle; - if (isNewFirst) { - route.didChangeNext(null); - } - for (final NavigatorObserver observer in navigator.widget.observers) - observer.didPush(route, previousPresent); + currentState = _RouteLifecycle.adding; } void handlePush({ @required NavigatorState navigator, @required bool isNewFirst, @required Route previous, @required Route previousPresent }) { @@ -1838,6 +2383,16 @@ class _RouteEntry { bool doingPop = false; + void didAdd({ @required NavigatorState navigator, @required bool isNewFirst, @required Route previous, @required Route previousPresent }) { + route.didAdd(); + currentState = _RouteLifecycle.idle; + if (isNewFirst) { + route.didChangeNext(null); + } + for (final NavigatorObserver observer in navigator.widget.observers) + observer.didPush(route, previousPresent); + } + void pop(T result) { assert(isPresent); doingPop = true; @@ -1851,6 +2406,11 @@ class _RouteEntry { // Route is removed without being completed. void remove({ bool isReplaced = false }) { + assert( + !hasPage || _debugWaitingForExitDecision, + 'A page-based route cannot be completed using imperative api, provide a ' + 'new list without the corresponding Page to Navigator.pages instead. ' + ); if (currentState.index >= _RouteLifecycle.remove.index) return; assert(isPresent); @@ -1860,6 +2420,11 @@ class _RouteEntry { // Route completes with `result` and is removed. void complete(T result, { bool isReplaced = false }) { + assert( + !hasPage || _debugWaitingForExitDecision, + 'A page-based route cannot be completed using imperative api, provide a ' + 'new list without the corresponding Page to Navigator.pages instead. ' + ); if (currentState.index >= _RouteLifecycle.remove.index) return; assert(isPresent); @@ -1880,8 +2445,25 @@ class _RouteEntry { currentState = _RouteLifecycle.disposed; } - bool get willBePresent => currentState.index <= _RouteLifecycle.idle.index; - bool get isPresent => currentState.index <= _RouteLifecycle.remove.index; + bool get willBePresent { + return currentState.index <= _RouteLifecycle.idle.index && + currentState.index >= _RouteLifecycle.add.index; + } + + bool get isPresent { + return currentState.index <= _RouteLifecycle.remove.index && + currentState.index >= _RouteLifecycle.add.index; + } + + bool get suitableForAnnouncement { + return currentState.index <= _RouteLifecycle.removing.index && + currentState.index >= _RouteLifecycle.push.index; + } + + bool get suitableForTransitionAnimation { + return currentState.index <= _RouteLifecycle.remove.index && + currentState.index >= _RouteLifecycle.push.index; + } bool shouldAnnounceChangeToNext(Route nextRoute) { assert(nextRoute != lastAnnouncedNextRoute); @@ -1895,17 +2477,76 @@ class _RouteEntry { } static final _RouteEntryPredicate isPresentPredicate = (_RouteEntry entry) => entry.isPresent; + static final _RouteEntryPredicate suitableForTransitionAnimationPredicate = (_RouteEntry entry) => entry.suitableForTransitionAnimation; static final _RouteEntryPredicate willBePresentPredicate = (_RouteEntry entry) => entry.willBePresent; static _RouteEntryPredicate isRoutePredicate(Route route) { return (_RouteEntry entry) => entry.route == route; } + + @override + bool get isEntering => currentState == _RouteLifecycle.staging; + + @override + void markForPush() { + assert( + isEntering && !_debugWaitingForExitDecision, + 'This route cannot be marked for push. Either a decision has already been ' + 'made or it does not require an explicit decision on how to transition in.' + ); + currentState = _RouteLifecycle.push; + } + + @override + void markForAdd() { + assert( + isEntering && !_debugWaitingForExitDecision, + 'This route cannot be marked for add. Either a decision has already been ' + 'made or it does not require an explicit decision on how to transition in.' + ); + currentState = _RouteLifecycle.add; + } + + @override + void markForPop([dynamic result]) { + assert( + !isEntering && _debugWaitingForExitDecision, + 'This route cannot be marked for pop. Either a decision has already been ' + 'made or it does not require an explicit decision on how to transition out.' + ); + pop(result); + _debugWaitingForExitDecision = false; + } + + @override + void markForComplete([dynamic result]) { + assert( + !isEntering && _debugWaitingForExitDecision, + 'This route cannot be marked for complete. Either a decision has already ' + 'been made or it does not require an explicit decision on how to transition ' + 'out.' + ); + complete(result); + _debugWaitingForExitDecision = false; + } + + @override + void markForRemove() { + assert( + !isEntering && _debugWaitingForExitDecision, + 'This route cannot be marked for remove. Either a decision has already ' + 'been made or it does not require an explicit decision on how to transition ' + 'out.' + ); + remove(); + _debugWaitingForExitDecision = false; + } } /// The state for a [Navigator] widget. class NavigatorState extends State with TickerProviderStateMixin { final GlobalKey _overlayKey = GlobalKey(); - final List<_RouteEntry> _history = <_RouteEntry>[]; + List<_RouteEntry> _history = <_RouteEntry>[]; /// The [FocusScopeNode] for the [FocusScope] that encloses the routes. final FocusScopeNode focusScopeNode = FocusScopeNode(debugLabel: 'Navigator Scope'); @@ -1915,20 +2556,40 @@ class NavigatorState extends State with TickerProviderStateMixin { @override void initState() { super.initState(); + assert( + widget.pages.isEmpty || widget.onPopPage != null, + 'The Navigator.onPopPage must be provided to use the Navigator.pages API', + ); for (final NavigatorObserver observer in widget.observers) { assert(observer.navigator == null); observer._navigator = this; } - // TODO(chunhtai): Uses pages after we add page api. - // https://github.com/flutter/flutter/issues/45938 - _history.addAll( - widget.onGenerateInitialRoutes(this, widget.initialRoute ?? Navigator.defaultRouteName) - .map((Route route) => _RouteEntry( - route, + String initialRoute = widget.initialRoute; + if (widget.pages.isNotEmpty) { + _history.addAll( + widget.pages.map((Page page) => _RouteEntry( + page.createRoute(context), initialState: _RouteLifecycle.add, + )) + ); + } else { + // If there is no page provided, we will need to provide default route + // to initialize the navigator. + initialRoute = initialRoute ?? Navigator.defaultRouteName; + } + if (initialRoute != null) { + _history.addAll( + widget.onGenerateInitialRoutes( + this, + widget.initialRoute ?? Navigator.defaultRouteName + ).map((Route route) => + _RouteEntry( + route, + initialState: _RouteLifecycle.add, + ), ), - ), - ); + ); + } assert(!_debugLocked); assert(() { _debugLocked = true; return true; }()); _flushHistoryUpdates(); @@ -1938,6 +2599,10 @@ class NavigatorState extends State with TickerProviderStateMixin { @override void didUpdateWidget(Navigator oldWidget) { super.didUpdateWidget(oldWidget); + assert( + widget.pages.isEmpty || widget.onPopPage != null, + 'The Navigator.onPopPage must be provided to use the Navigator.pages API', + ); if (oldWidget.observers != widget.observers) { for (final NavigatorObserver observer in oldWidget.observers) observer._navigator = null; @@ -1946,10 +2611,31 @@ class NavigatorState extends State with TickerProviderStateMixin { observer._navigator = this; } } + if (oldWidget.pages != widget.pages) { + assert( + widget.pages.isNotEmpty, + 'To use the Navigator.pages, there must be at least one page in the list.' + ); + _updatePages(); + } + for (final _RouteEntry entry in _history) entry.route.changedExternalState(); } + void _debugCheckDuplicatedPageKeys() { + assert((){ + final Set keyReservation = {}; + for (final Page page in widget.pages) { + if (page.key != null) { + assert(!keyReservation.contains(page.key)); + keyReservation.add(page.key); + } + } + return true; + }()); + } + @override void dispose() { assert(!_debugLocked); @@ -1977,8 +2663,276 @@ class NavigatorState extends State with TickerProviderStateMixin { String _lastAnnouncedRouteName; + bool _debugUpdatingPage = false; + void _updatePages() { + assert(() { + assert(!_debugUpdatingPage); + _debugCheckDuplicatedPageKeys(); + _debugUpdatingPage = true; + return true; + }()); + + // This attempts to diff the new pages list (widget.pages) with + // the old _RouteEntry[s] list (_history), and produces a new list of + // _RouteEntry[s] to be the new list of _history. This method roughly + // follows the same outline of RenderObjectElement.updateChildren. + // + // The cases it tries to optimize for are: + // - the old list is empty + // - All the pages in the new list can match the page-based routes in the old + // list, and their orders are the same. + // - there is an insertion or removal of one or more page-based route in + // only one place in the list + // If a page-based route with a key is in both lists, it will be synced. + // Page-based routes without keys might be synced but there is no guarantee. + + // The general approach is to sync the entire new list backwards, as follows: + // 1. Walk the lists from the bottom, syncing nodes, and record pageless routes, + // until you no longer have matching nodes. + // 2. Walk the lists from the top, without syncing nodes, until you no + // longer have matching nodes. We'll sync these nodes at the end. We + // don't sync them now because we want to sync all the nodes in order + // from beginning to end. + // At this point we narrowed the old and new lists to the point + // where the nodes no longer match. + // 3. Walk the narrowed part of the old list to get the list of + // keys. + // 4. Walk the narrowed part of the new list forwards: + // * Create a new _RouteEntry for non-keyed items and record them for + // transitionDelegate. + // * Sync keyed items with the source if it exists. + // 5. Walk the narrowed part of the old list again to records the + // _RouteEntry[s], as well as pageless routes, needed to be removed for + // transitionDelegate. + // 5. Walk the top of the list again, syncing the nodes and recording + // pageless routes. + // 6. Use transitionDelegate for explicit decisions on how _RouteEntry[s] + // transition in or off the screens. + // 7. Fill pageless routes back into the new history. + + bool needsExplicitDecision = false; + int newPagesBottom = 0; + int oldEntriesBottom = 0; + int newPagesTop = widget.pages.length - 1; + int oldEntriesTop = _history.length - 1; + + final List<_RouteEntry> newHistory = <_RouteEntry>[]; + final Map<_RouteEntry, List<_RouteEntry>> pageRouteToPagelessRoutes = <_RouteEntry, List<_RouteEntry>>{}; + + // Updates the bottom of the list. + _RouteEntry previousOldPageRouteEntry; + while (oldEntriesBottom <= oldEntriesTop) { + final _RouteEntry oldEntry = _history[oldEntriesBottom]; + assert(oldEntry != null && oldEntry.currentState != _RouteLifecycle.disposed); + // Records pageless route. The bottom most pageless routes will be + // stored in key = null. + if (!oldEntry.hasPage) { + final List<_RouteEntry> pagelessRoutes = pageRouteToPagelessRoutes.putIfAbsent( + previousOldPageRouteEntry, + () => <_RouteEntry>[], + ); + pagelessRoutes.add(oldEntry); + oldEntriesBottom += 1; + continue; + } + if (newPagesBottom > newPagesTop) + break; + final Page newPage = widget.pages[newPagesBottom]; + if (!oldEntry.canUpdateFrom(newPage)) + break; + previousOldPageRouteEntry = oldEntry; + oldEntry.route._updateSettings(newPage); + newHistory.add(oldEntry); + newPagesBottom += 1; + oldEntriesBottom += 1; + } + + int pagelessRoutesToSkip = 0; + // Scans the top of the list until we found a page-based route that cannot be + // updated. + while ((oldEntriesBottom <= oldEntriesTop) && (newPagesBottom <= newPagesTop)) { + final _RouteEntry oldEntry = _history[oldEntriesTop]; + assert(oldEntry != null && oldEntry.currentState != _RouteLifecycle.disposed); + if (!oldEntry.hasPage) { + // This route might need to be skipped if we can not find a page above. + pagelessRoutesToSkip += 1; + oldEntriesTop -= 1; + continue; + } + final Page newPage = widget.pages[newPagesTop]; + if (!oldEntry.canUpdateFrom(newPage)) + break; + // We found the page for all the consecutive pageless routes below. Those + // pageless routes do not need to be skipped. + pagelessRoutesToSkip = 0; + oldEntriesTop -= 1; + newPagesTop -= 1; + } + // Reverts the pageless routes that cannot be updated. + oldEntriesTop += pagelessRoutesToSkip; + + // Scans middle of the old entries and records the page key to old entry map. + int oldEntriesBottomToScan = oldEntriesBottom; + final Map pageKeyToOldEntry = {}; + while (oldEntriesBottomToScan <= oldEntriesTop) { + final _RouteEntry oldEntry = _history[oldEntriesBottomToScan]; + oldEntriesBottomToScan += 1; + assert( + oldEntry != null && + oldEntry.currentState != _RouteLifecycle.disposed + ); + // Pageless routes will be recorded when we update the middle of the old + // list. + if (!oldEntry.hasPage) + continue; + + assert(oldEntry.hasPage); + + final Page page = oldEntry.route.settings as Page; + if (page.key == null) + continue; + + assert(!pageKeyToOldEntry.containsKey(page.key)); + pageKeyToOldEntry[page.key] = oldEntry; + } + + // Updates the middle of the list. + while (newPagesBottom <= newPagesTop) { + final Page nextPage = widget.pages[newPagesBottom]; + newPagesBottom += 1; + if ( + nextPage.key == null || + !pageKeyToOldEntry.containsKey(nextPage.key) || + !pageKeyToOldEntry[nextPage.key].canUpdateFrom(nextPage) + ) { + // There is no matching key in the old history, we need to create a new + // route and wait for the transition delegate to decide how to add + // it into the history. + final _RouteEntry newEntry = _RouteEntry( + nextPage.createRoute(context), + initialState: _RouteLifecycle.staging, + ); + needsExplicitDecision = true; + assert( + newEntry.route.settings == nextPage, + 'If a route is created from a page, its must have that page as its ' + 'settings.', + ); + newHistory.add(newEntry); + } else { + // Removes the key from pageKeyToOldEntry to indicate it is taken. + final _RouteEntry matchingEntry = pageKeyToOldEntry.remove(nextPage.key); + assert(matchingEntry.canUpdateFrom(nextPage)); + matchingEntry.route._updateSettings(nextPage); + newHistory.add(matchingEntry); + } + } + + // Any remaining old routes that do not have a match will need to be removed. + final Map locationToExitingPageRoute = {}; + while (oldEntriesBottom <= oldEntriesTop) { + final _RouteEntry potentialEntryToRemove = _history[oldEntriesBottom]; + oldEntriesBottom += 1; + + if (!potentialEntryToRemove.hasPage) { + assert(previousOldPageRouteEntry != null); + final List<_RouteEntry> pagelessRoutes = pageRouteToPagelessRoutes + .putIfAbsent( + previousOldPageRouteEntry, + () => <_RouteEntry>[] + ); + pagelessRoutes.add(potentialEntryToRemove); + assert(() { + potentialEntryToRemove._debugWaitingForExitDecision = previousOldPageRouteEntry._debugWaitingForExitDecision; + return true; + }()); + continue; + } + + final Page potentialPageToRemove = potentialEntryToRemove.route.settings as Page; + // Marks for transition delegate to remove if this old page does not have + // a key or was not taken during updating the middle of new page. + if ( + potentialPageToRemove.key == null || + pageKeyToOldEntry.containsKey(potentialPageToRemove.key) + ) { + locationToExitingPageRoute[previousOldPageRouteEntry] = potentialEntryToRemove; + assert(() { + potentialEntryToRemove._debugWaitingForExitDecision = true; + return true; + }()); + } + previousOldPageRouteEntry = potentialEntryToRemove; + } + + // We've scanned the whole list. + assert(oldEntriesBottom == oldEntriesTop + 1); + assert(newPagesBottom == newPagesTop + 1); + newPagesTop = widget.pages.length - 1; + oldEntriesTop = _history.length - 1; + // Verifies we either reach the bottom or the oldEntriesBottom must be updatable + // by newPagesBottom. + assert(() { + if (oldEntriesBottom <= oldEntriesTop) + return newPagesBottom <= newPagesTop && + _history[oldEntriesBottom].hasPage && + _history[oldEntriesBottom].canUpdateFrom(widget.pages[newPagesBottom]); + else + return newPagesBottom > newPagesTop; + }()); + + // Updates the top of the list. + while ((oldEntriesBottom <= oldEntriesTop) && (newPagesBottom <= newPagesTop)) { + final _RouteEntry oldEntry = _history[oldEntriesBottom]; + assert(oldEntry != null && oldEntry.currentState != _RouteLifecycle.disposed); + if (!oldEntry.hasPage) { + assert(previousOldPageRouteEntry != null); + final List<_RouteEntry> pagelessRoutes = pageRouteToPagelessRoutes + .putIfAbsent( + previousOldPageRouteEntry, + () => <_RouteEntry>[] + ); + pagelessRoutes.add(oldEntry); + continue; + } + previousOldPageRouteEntry = oldEntry; + final Page newPage = widget.pages[newPagesBottom]; + assert(oldEntry.canUpdateFrom(newPage)); + oldEntry.route._updateSettings(newPage); + newHistory.add(oldEntry); + oldEntriesBottom += 1; + newPagesBottom += 1; + } + + // Finally, uses transition delegate to make explicit decision if needed. + needsExplicitDecision = needsExplicitDecision || locationToExitingPageRoute.isNotEmpty; + Iterable<_RouteEntry> results = newHistory; + if (needsExplicitDecision) { + results = widget.transitionDelegate._transition( + newPageRouteHistory: newHistory, + locationToExitingPageRoute: locationToExitingPageRoute, + pageRouteToPagelessRoutes: pageRouteToPagelessRoutes, + ).cast<_RouteEntry>(); + } + _history = <_RouteEntry>[]; + // Adds the leading pageless routes if there is any. + if (pageRouteToPagelessRoutes.containsKey(null)) { + _history.addAll(pageRouteToPagelessRoutes[null]); + } + for (final _RouteEntry result in results) { + _history.add(result); + if (pageRouteToPagelessRoutes.containsKey(result)) { + _history.addAll(pageRouteToPagelessRoutes[result]); + } + } + assert(() {_debugUpdatingPage = false; return true;}()); + assert(() { _debugLocked = true; return true; }()); + _flushHistoryUpdates(); + assert(() { _debugLocked = false; return true; }()); + } + void _flushHistoryUpdates({bool rearrangeOverlay = true}) { - assert(_debugLocked); + assert(_debugLocked && !_debugUpdatingPage); // Clean up the list, sending updates to the routes that changed. Notably, // we don't send the didChangePrevious/didChangeNext updates to those that // did not change at this point, because we're not yet sure exactly what the @@ -1987,7 +2941,7 @@ class NavigatorState extends State with TickerProviderStateMixin { _RouteEntry next; _RouteEntry entry = _history[index]; _RouteEntry previous = index > 0 ? _history[index - 1] : null; - bool canRemove = false; + bool canRemoveOrAdd = false; // Whether there is a fully opaque route on top to silently remove or add route underneath. Route poppedRoute; // The route that should trigger didPopNext on the top active route. bool seenTopActiveRoute = false; // Whether we've seen the route that would get didPopNext. final List<_RouteEntry> toBeDisposed = <_RouteEntry>[]; @@ -1997,12 +2951,21 @@ class NavigatorState extends State with TickerProviderStateMixin { assert(rearrangeOverlay); entry.handleAdd( navigator: this, - previous: previous?.route, - previousPresent: _getRouteBefore(index - 1, _RouteEntry.isPresentPredicate)?.route, - isNewFirst: next == null, ); - assert(entry.currentState == _RouteLifecycle.idle); + assert(entry.currentState == _RouteLifecycle.adding); continue; + case _RouteLifecycle.adding: + if (canRemoveOrAdd || next == null) { + entry.didAdd( + navigator: this, + previous: previous?.route, + previousPresent: _getRouteBefore(index - 1, _RouteEntry.isPresentPredicate)?.route, + isNewFirst: next == null + ); + assert(entry.currentState == _RouteLifecycle.idle); + continue; + } + break; case _RouteLifecycle.push: case _RouteLifecycle.pushReplace: case _RouteLifecycle.replace: @@ -2031,7 +2994,7 @@ class NavigatorState extends State with TickerProviderStateMixin { seenTopActiveRoute = true; // This route is idle, so we are allowed to remove subsequent (earlier) // routes that are waiting to be removed silently: - canRemove = true; + canRemoveOrAdd = true; break; case _RouteLifecycle.pop: if (!seenTopActiveRoute) { @@ -2044,6 +3007,7 @@ class NavigatorState extends State with TickerProviderStateMixin { previousPresent: _getRouteBefore(index, _RouteEntry.willBePresentPredicate)?.route, ); assert(entry.currentState == _RouteLifecycle.popping); + canRemoveOrAdd = true; break; case _RouteLifecycle.popping: // Will exit this state when animation completes. @@ -2061,7 +3025,7 @@ class NavigatorState extends State with TickerProviderStateMixin { assert(entry.currentState == _RouteLifecycle.removing); continue; case _RouteLifecycle.removing: - if (!canRemove && next != null) { + if (!canRemoveOrAdd && next != null) { // We aren't allowed to remove this route yet. break; } @@ -2073,6 +3037,7 @@ class NavigatorState extends State with TickerProviderStateMixin { entry = next; break; case _RouteLifecycle.disposed: + case _RouteLifecycle.staging: assert(false); break; } @@ -2108,7 +3073,11 @@ class NavigatorState extends State with TickerProviderStateMixin { int index = _history.length - 1; while (index >= 0) { final _RouteEntry entry = _history[index]; - final _RouteEntry next = _getRouteAfter(index + 1, _RouteEntry.isPresentPredicate); + if (!entry.suitableForAnnouncement) { + index -= 1; + continue; + } + final _RouteEntry next = _getRouteAfter(index + 1, _RouteEntry.suitableForTransitionAnimationPredicate); if (next?.route != entry.lastAnnouncedNextRoute) { if (entry.shouldAnnounceChangeToNext(next?.route)) { @@ -2116,7 +3085,7 @@ class NavigatorState extends State with TickerProviderStateMixin { } entry.lastAnnouncedNextRoute = next?.route; } - final _RouteEntry previous = _getRouteBefore(index - 1, _RouteEntry.isPresentPredicate); + final _RouteEntry previous = _getRouteBefore(index - 1, _RouteEntry.suitableForTransitionAnimationPredicate); if (previous?.route != entry.lastAnnouncedPreviousRoute) { entry.route.didChangePrevious(previous?.route); entry.lastAnnouncedPreviousRoute = previous?.route; @@ -2615,7 +3584,12 @@ class NavigatorState extends State with TickerProviderStateMixin { return true; }()); final _RouteEntry entry = _history.lastWhere(_RouteEntry.isPresentPredicate); - entry.pop(result); + if (entry.hasPage) { + if (widget.onPopPage(entry.route, result)) + entry.currentState = _RouteLifecycle.pop; + } else { + entry.pop(result); + } if (entry.currentState == _RouteLifecycle.pop) { // Flush the history if the route actually wants to be popped (the pop // wasn't handled internally). diff --git a/packages/flutter/test/widgets/navigator_test.dart b/packages/flutter/test/widgets/navigator_test.dart index cc01fdb8dbf..51855e6ef1a 100644 --- a/packages/flutter/test/widgets/navigator_test.dart +++ b/packages/flutter/test/widgets/navigator_test.dart @@ -1708,6 +1708,666 @@ void main() { await tester.pump(const Duration(seconds: 1)); expect(tickCount, 4); }); + + group('Page api', (){ + Widget buildNavigator( + List> pages, + PopPageCallback onPopPage, [ + GlobalKey key, + TransitionDelegate transitionDelegate + ]) { + return MediaQuery( + data: MediaQueryData.fromWindow(WidgetsBinding.instance.window), + child: Localizations( + locale: const Locale('en', 'US'), + delegates: const >[ + DefaultMaterialLocalizations.delegate, + DefaultWidgetsLocalizations.delegate + ], + child: Directionality( + textDirection: TextDirection.ltr, + child: Navigator( + key: key, + pages: pages, + onPopPage: onPopPage, + transitionDelegate: transitionDelegate ?? const DefaultTransitionDelegate(), + ), + ), + ), + ); + } + + testWidgets('can initialize with pages list', (WidgetTester tester) async { + final GlobalKey navigator = GlobalKey(); + final List myPages = [ + const TestPage(key: ValueKey('1'), name:'initial'), + const TestPage(key: ValueKey('2'), name:'second'), + const TestPage(key: ValueKey('3'), name:'third'), + ]; + + bool onPopPage(Route route, dynamic result) { + myPages.removeWhere((Page page) => route.settings == page); + return route.didPop(result); + } + + await tester.pumpWidget(buildNavigator(myPages, onPopPage, navigator)); + expect(find.text('third'), findsOneWidget); + expect(find.text('second'), findsNothing); + expect(find.text('initial'), findsNothing); + + navigator.currentState.pop(); + await tester.pumpAndSettle(); + expect(find.text('third'), findsNothing); + expect(find.text('second'), findsOneWidget); + expect(find.text('initial'), findsNothing); + + navigator.currentState.pop(); + await tester.pumpAndSettle(); + expect(find.text('third'), findsNothing); + expect(find.text('second'), findsNothing); + expect(find.text('initial'), findsOneWidget); + }); + + testWidgets('can push and pop pages using page api', (WidgetTester tester) async { + Animation secondaryAnimationOfRouteOne; + Animation primaryAnimationOfRouteOne; + Animation secondaryAnimationOfRouteTwo; + Animation primaryAnimationOfRouteTwo; + Animation secondaryAnimationOfRouteThree; + Animation primaryAnimationOfRouteThree; + final GlobalKey navigator = GlobalKey(); + List> myPages = >[ + CustomBuilderPage( + key: const ValueKey('1'), + name:'initial', + routeBuilder: (BuildContext context, RouteSettings settings) { + return PageRouteBuilder( + settings: settings, + pageBuilder: (_, Animation animation, Animation secondaryAnimation) { + secondaryAnimationOfRouteOne = secondaryAnimation; + primaryAnimationOfRouteOne = animation; + return const Text('initial'); + }, + ); + }, + ), + ]; + + bool onPopPage(Route route, dynamic result) { + myPages.removeWhere((Page page) => route.settings == page); + return route.didPop(result); + } + + await tester.pumpWidget(buildNavigator(myPages, onPopPage, navigator)); + expect(find.text('initial'), findsOneWidget); + + myPages = >[ + CustomBuilderPage( + key: const ValueKey('1'), + name:'initial', + routeBuilder: (BuildContext context, RouteSettings settings) { + return PageRouteBuilder( + settings: settings, + pageBuilder: (_, Animation animation, Animation secondaryAnimation) { + secondaryAnimationOfRouteOne = secondaryAnimation; + primaryAnimationOfRouteOne = animation; + return const Text('initial'); + }, + ); + }, + ), + CustomBuilderPage( + key: const ValueKey('2'), + name:'second', + routeBuilder: (BuildContext context, RouteSettings settings) { + return PageRouteBuilder( + settings: settings, + pageBuilder: (_, Animation animation, Animation secondaryAnimation) { + secondaryAnimationOfRouteTwo = secondaryAnimation; + primaryAnimationOfRouteTwo = animation; + return const Text('second'); + }, + ); + }, + ), + CustomBuilderPage( + key: const ValueKey('3'), + name:'third', + routeBuilder: (BuildContext context, RouteSettings settings) { + return PageRouteBuilder( + settings: settings, + pageBuilder: (_, Animation animation, Animation secondaryAnimation) { + secondaryAnimationOfRouteThree = secondaryAnimation; + primaryAnimationOfRouteThree = animation; + return const Text('third'); + }, + ); + }, + ) + ]; + + await tester.pumpWidget(buildNavigator(myPages, onPopPage, navigator)); + // The third page is transitioning, and the secondary animation of first + // page should chain with the third page. The animation of second page + // won't start until the third page finishes transition. + expect(secondaryAnimationOfRouteOne.value, primaryAnimationOfRouteThree.value); + expect(primaryAnimationOfRouteOne.status, AnimationStatus.completed); + expect(secondaryAnimationOfRouteTwo.status, AnimationStatus.dismissed); + expect(primaryAnimationOfRouteTwo.status, AnimationStatus.dismissed); + expect(secondaryAnimationOfRouteThree.status, AnimationStatus.dismissed); + expect(primaryAnimationOfRouteThree.status, AnimationStatus.forward); + + await tester.pump(const Duration(milliseconds: 30)); + expect(secondaryAnimationOfRouteOne.value, primaryAnimationOfRouteThree.value); + expect(primaryAnimationOfRouteOne.status, AnimationStatus.completed); + expect(secondaryAnimationOfRouteTwo.status, AnimationStatus.dismissed); + expect(primaryAnimationOfRouteTwo.status, AnimationStatus.dismissed); + expect(secondaryAnimationOfRouteThree.status, AnimationStatus.dismissed); + expect(primaryAnimationOfRouteThree.value, 0.1); + await tester.pumpAndSettle(); + // After transition finishes, the routes' animations are correctly chained. + expect(secondaryAnimationOfRouteOne.value, primaryAnimationOfRouteTwo.value); + expect(primaryAnimationOfRouteOne.status, AnimationStatus.completed); + expect(secondaryAnimationOfRouteTwo.value, primaryAnimationOfRouteThree.value); + expect(primaryAnimationOfRouteTwo.status, AnimationStatus.completed); + expect(secondaryAnimationOfRouteThree.status, AnimationStatus.dismissed); + expect(primaryAnimationOfRouteThree.status, AnimationStatus.completed); + expect(find.text('third'), findsOneWidget); + expect(find.text('second'), findsNothing); + expect(find.text('initial'), findsNothing); + // Starts pops the pages using page api and verify the animations chain + // correctly. + + myPages = >[ + CustomBuilderPage( + key: const ValueKey('1'), + name:'initial', + routeBuilder: (BuildContext context, RouteSettings settings) { + return PageRouteBuilder( + settings: settings, + pageBuilder: (_, Animation animation, Animation secondaryAnimation) { + secondaryAnimationOfRouteOne = secondaryAnimation; + primaryAnimationOfRouteOne = animation; + return const Text('initial'); + }, + ); + }, + ), + CustomBuilderPage( + key: const ValueKey('2'), + name:'second', + routeBuilder: (BuildContext context, RouteSettings settings) { + return PageRouteBuilder( + settings: settings, + pageBuilder: (_, Animation animation, Animation secondaryAnimation) { + secondaryAnimationOfRouteTwo = secondaryAnimation; + primaryAnimationOfRouteTwo = animation; + return const Text('second'); + }, + ); + }, + ), + ]; + + await tester.pumpWidget(buildNavigator(myPages, onPopPage, navigator)); + await tester.pump(const Duration(milliseconds: 30)); + expect(secondaryAnimationOfRouteOne.value, primaryAnimationOfRouteTwo.value); + expect(primaryAnimationOfRouteOne.status, AnimationStatus.completed); + expect(secondaryAnimationOfRouteTwo.value, primaryAnimationOfRouteThree.value); + expect(primaryAnimationOfRouteTwo.status, AnimationStatus.completed); + expect(secondaryAnimationOfRouteThree.status, AnimationStatus.dismissed); + expect(primaryAnimationOfRouteThree.value, 0.9); + await tester.pumpAndSettle(); + expect(secondaryAnimationOfRouteOne.value, primaryAnimationOfRouteTwo.value); + expect(primaryAnimationOfRouteOne.status, AnimationStatus.completed); + expect(secondaryAnimationOfRouteTwo.value, primaryAnimationOfRouteThree.value); + expect(primaryAnimationOfRouteTwo.status, AnimationStatus.completed); + expect(secondaryAnimationOfRouteThree.status, AnimationStatus.dismissed); + expect(primaryAnimationOfRouteThree.status, AnimationStatus.dismissed); + }); + + testWidgets('can modify routes history and secondary animation still works', (WidgetTester tester) async { + final GlobalKey navigator = GlobalKey(); + Animation secondaryAnimationOfRouteOne; + Animation primaryAnimationOfRouteOne; + Animation secondaryAnimationOfRouteTwo; + Animation primaryAnimationOfRouteTwo; + Animation secondaryAnimationOfRouteThree; + Animation primaryAnimationOfRouteThree; + List> myPages = >[ + CustomBuilderPage( + key: const ValueKey('1'), + name:'initial', + routeBuilder: (BuildContext context, RouteSettings settings) { + return PageRouteBuilder( + settings: settings, + pageBuilder: (_, Animation animation, Animation secondaryAnimation) { + secondaryAnimationOfRouteOne = secondaryAnimation; + primaryAnimationOfRouteOne = animation; + return const Text('initial'); + }, + ); + }, + ), + CustomBuilderPage( + key: const ValueKey('2'), + name:'second', + routeBuilder: (BuildContext context, RouteSettings settings) { + return PageRouteBuilder( + settings: settings, + pageBuilder: (_, Animation animation, Animation secondaryAnimation) { + secondaryAnimationOfRouteTwo = secondaryAnimation; + primaryAnimationOfRouteTwo = animation; + return const Text('second'); + }, + ); + }, + ), + CustomBuilderPage( + key: const ValueKey('3'), + name:'third', + routeBuilder: (BuildContext context, RouteSettings settings) { + return PageRouteBuilder( + settings: settings, + pageBuilder: (_, Animation animation, Animation secondaryAnimation) { + secondaryAnimationOfRouteThree = secondaryAnimation; + primaryAnimationOfRouteThree = animation; + return const Text('third'); + }, + ); + }, + ), + ]; + bool onPopPage(Route route, dynamic result) { + myPages.removeWhere((Page page) => route.settings == page); + return route.didPop(result); + } + await tester.pumpWidget(buildNavigator(myPages, onPopPage, navigator)); + expect(find.text('third'), findsOneWidget); + expect(find.text('second'), findsNothing); + expect(find.text('initial'), findsNothing); + expect(secondaryAnimationOfRouteOne.value, primaryAnimationOfRouteTwo.value); + expect(primaryAnimationOfRouteOne.status, AnimationStatus.completed); + expect(secondaryAnimationOfRouteTwo.value, primaryAnimationOfRouteThree.value); + expect(primaryAnimationOfRouteTwo.status, AnimationStatus.completed); + expect(secondaryAnimationOfRouteThree.status, AnimationStatus.dismissed); + expect(primaryAnimationOfRouteThree.status, AnimationStatus.completed); + + myPages = myPages.reversed.toList(); + await tester.pumpWidget(buildNavigator(myPages, onPopPage, navigator)); + // Reversed routes are still chained up correctly. + expect(secondaryAnimationOfRouteThree.value, primaryAnimationOfRouteTwo.value); + expect(primaryAnimationOfRouteThree.status, AnimationStatus.completed); + expect(secondaryAnimationOfRouteTwo.value, primaryAnimationOfRouteOne.value); + expect(primaryAnimationOfRouteTwo.status, AnimationStatus.completed); + expect(secondaryAnimationOfRouteOne.status, AnimationStatus.dismissed); + expect(primaryAnimationOfRouteOne.status, AnimationStatus.completed); + + navigator.currentState.pop(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 30)); + expect(secondaryAnimationOfRouteThree.value, primaryAnimationOfRouteTwo.value); + expect(primaryAnimationOfRouteThree.status, AnimationStatus.completed); + expect(secondaryAnimationOfRouteTwo.value, primaryAnimationOfRouteOne.value); + expect(primaryAnimationOfRouteTwo.status, AnimationStatus.completed); + expect(secondaryAnimationOfRouteOne.status, AnimationStatus.dismissed); + expect(primaryAnimationOfRouteOne.value, 0.9); + await tester.pumpAndSettle(); + expect(secondaryAnimationOfRouteThree.value, primaryAnimationOfRouteTwo.value); + expect(primaryAnimationOfRouteThree.status, AnimationStatus.completed); + expect(secondaryAnimationOfRouteTwo.value, primaryAnimationOfRouteOne.value); + expect(primaryAnimationOfRouteTwo.status, AnimationStatus.completed); + expect(secondaryAnimationOfRouteOne.status, AnimationStatus.dismissed); + expect(primaryAnimationOfRouteOne.status, AnimationStatus.dismissed); + + navigator.currentState.pop(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 30)); + expect(secondaryAnimationOfRouteThree.value, primaryAnimationOfRouteTwo.value); + expect(primaryAnimationOfRouteThree.status, AnimationStatus.completed); + expect(secondaryAnimationOfRouteTwo.value, primaryAnimationOfRouteOne.value); + expect(primaryAnimationOfRouteTwo.value, 0.9); + expect(secondaryAnimationOfRouteOne.status, AnimationStatus.dismissed); + expect(primaryAnimationOfRouteOne.status, AnimationStatus.dismissed); + await tester.pumpAndSettle(); + expect(secondaryAnimationOfRouteThree.value, primaryAnimationOfRouteTwo.value); + expect(primaryAnimationOfRouteThree.status, AnimationStatus.completed); + expect(secondaryAnimationOfRouteTwo.value, primaryAnimationOfRouteOne.value); + expect(primaryAnimationOfRouteTwo.status, AnimationStatus.dismissed); + expect(secondaryAnimationOfRouteOne.status, AnimationStatus.dismissed); + expect(primaryAnimationOfRouteOne.status, AnimationStatus.dismissed); + }); + + testWidgets('can work with pageless route', (WidgetTester tester) async { + final GlobalKey navigator = GlobalKey(); + List myPages = [ + const TestPage(key: ValueKey('1'), name:'initial'), + const TestPage(key: ValueKey('2'), name:'second'), + ]; + + bool onPopPage(Route route, dynamic result) { + myPages.removeWhere((Page page) => route.settings == page); + return route.didPop(result); + } + + await tester.pumpWidget(buildNavigator(myPages, onPopPage, navigator)); + expect(find.text('second'), findsOneWidget); + expect(find.text('initial'), findsNothing); + // Pushes two pageless routes to second page route + navigator.currentState.push( + MaterialPageRoute( + builder: (BuildContext context) => const Text('second-pageless1'), + settings: null, + ) + ); + navigator.currentState.push( + MaterialPageRoute( + builder: (BuildContext context) => const Text('second-pageless2'), + settings: null, + ) + ); + await tester.pumpAndSettle(); + // Now the history should look like + // [initial, second, second-pageless1, second-pageless2]. + expect(find.text('initial'), findsNothing); + expect(find.text('second'), findsNothing); + expect(find.text('second-pageless1'), findsNothing); + expect(find.text('second-pageless2'), findsOneWidget); + + myPages = [ + const TestPage(key: ValueKey('1'), name:'initial'), + const TestPage(key: ValueKey('2'), name:'second'), + const TestPage(key: ValueKey('3'), name:'third'), + ]; + await tester.pumpWidget(buildNavigator(myPages, onPopPage, navigator)); + await tester.pumpAndSettle(); + expect(find.text('initial'), findsNothing); + expect(find.text('second'), findsNothing); + expect(find.text('second-pageless1'), findsNothing); + expect(find.text('second-pageless2'), findsNothing); + expect(find.text('third'), findsOneWidget); + + // Pushes one pageless routes to third page route + navigator.currentState.push( + MaterialPageRoute( + builder: (BuildContext context) => const Text('third-pageless1'), + settings: null, + ) + ); + await tester.pumpAndSettle(); + // Now the history should look like + // [initial, second, second-pageless1, second-pageless2, third, third-pageless1]. + expect(find.text('initial'), findsNothing); + expect(find.text('second'), findsNothing); + expect(find.text('second-pageless1'), findsNothing); + expect(find.text('second-pageless2'), findsNothing); + expect(find.text('third'), findsNothing); + expect(find.text('third-pageless1'), findsOneWidget); + + myPages = [ + const TestPage(key: ValueKey('1'), name:'initial'), + const TestPage(key: ValueKey('3'), name:'third'), + const TestPage(key: ValueKey('2'), name:'second'), + ]; + await tester.pumpWidget(buildNavigator(myPages, onPopPage, navigator)); + // Swaps the order without any adding or removing should not trigger any + // transition. The routes should update without a pumpAndSettle + // Now the history should look like + // [initial, third, third-pageless1, second, second-pageless1, second-pageless2]. + expect(find.text('initial'), findsNothing); + expect(find.text('third'), findsNothing); + expect(find.text('third-pageless1'), findsNothing); + expect(find.text('second'), findsNothing); + expect(find.text('second-pageless1'), findsNothing); + expect(find.text('second-pageless2'), findsOneWidget); + // Pops the route one by one to make sure the order is correct. + navigator.currentState.pop(); + await tester.pumpAndSettle(); + expect(find.text('initial'), findsNothing); + expect(find.text('third'), findsNothing); + expect(find.text('third-pageless1'), findsNothing); + expect(find.text('second'), findsNothing); + expect(find.text('second-pageless1'), findsOneWidget); + expect(find.text('second-pageless2'), findsNothing); + expect(myPages.length, 3); + navigator.currentState.pop(); + await tester.pumpAndSettle(); + expect(find.text('initial'), findsNothing); + expect(find.text('third'), findsNothing); + expect(find.text('third-pageless1'), findsNothing); + expect(find.text('second'), findsOneWidget); + expect(find.text('second-pageless1'), findsNothing); + expect(find.text('second-pageless2'), findsNothing); + expect(myPages.length, 3); + navigator.currentState.pop(); + await tester.pumpAndSettle(); + expect(find.text('initial'), findsNothing); + expect(find.text('third'), findsNothing); + expect(find.text('third-pageless1'), findsOneWidget); + expect(find.text('second'), findsNothing); + expect(find.text('second-pageless1'), findsNothing); + expect(find.text('second-pageless2'), findsNothing); + expect(myPages.length, 2); + navigator.currentState.pop(); + await tester.pumpAndSettle(); + expect(find.text('initial'), findsNothing); + expect(find.text('third'), findsOneWidget); + expect(find.text('third-pageless1'), findsNothing); + expect(find.text('second'), findsNothing); + expect(find.text('second-pageless1'), findsNothing); + expect(find.text('second-pageless2'), findsNothing); + expect(myPages.length, 2); + navigator.currentState.pop(); + await tester.pumpAndSettle(); + expect(find.text('initial'), findsOneWidget); + expect(find.text('third'), findsNothing); + expect(find.text('third-pageless1'), findsNothing); + expect(find.text('second'), findsNothing); + expect(find.text('second-pageless1'), findsNothing); + expect(find.text('second-pageless2'), findsNothing); + expect(myPages.length, 1); + }); + + testWidgets('complex case 1', (WidgetTester tester) async { + final GlobalKey navigator = GlobalKey(); + List myPages = [ + const TestPage(key: ValueKey('1'), name: 'initial'), + ]; + bool onPopPage(Route route, dynamic result) { + myPages.removeWhere((Page page) => route.settings == page); + return route.didPop(result); + } + + // Add initial page route with one pageless route. + await tester.pumpWidget(buildNavigator(myPages, onPopPage, navigator)); + bool initialPageless1Completed = false; + navigator.currentState.push( + MaterialPageRoute( + builder: (BuildContext context) => const Text('initial-pageless1'), + settings: null, + ) + ).then((_) => initialPageless1Completed = true); + await tester.pumpAndSettle(); + + // Pushes second page route with two pageless routes. + myPages = [ + const TestPage(key: ValueKey('1'), name: 'initial'), + const TestPage(key: ValueKey('2'), name: 'second'), + ]; + await tester.pumpWidget(buildNavigator(myPages, onPopPage, navigator)); + await tester.pumpAndSettle(); + bool secondPageless1Completed = false; + navigator.currentState.push( + MaterialPageRoute( + builder: (BuildContext context) => const Text('second-pageless1'), + settings: null, + ) + ).then((_) => secondPageless1Completed = true); + await tester.pumpAndSettle(); + bool secondPageless2Completed = false; + navigator.currentState.push( + MaterialPageRoute( + builder: (BuildContext context) => const Text('second-pageless2'), + settings: null, + ) + ).then((_) => secondPageless2Completed = true); + await tester.pumpAndSettle(); + + // Pushes third page route with one pageless route. + myPages = [ + const TestPage(key: ValueKey('1'), name: 'initial'), + const TestPage(key: ValueKey('2'), name: 'second'), + const TestPage(key: ValueKey('3'), name: 'third'), + ]; + await tester.pumpWidget(buildNavigator(myPages, onPopPage, navigator)); + await tester.pumpAndSettle(); + bool thirdPageless1Completed = false; + navigator.currentState.push( + MaterialPageRoute( + builder: (BuildContext context) => const Text('third-pageless1'), + settings: null, + ) + ).then((_) => thirdPageless1Completed = true); + await tester.pumpAndSettle(); + + // Nothing has been popped. + expect(initialPageless1Completed, false); + expect(secondPageless1Completed, false); + expect(secondPageless2Completed, false); + expect(thirdPageless1Completed, false); + + // Switches order and removes the initial page route. + myPages = [ + const TestPage(key: ValueKey('3'), name: 'third'), + const TestPage(key: ValueKey('2'), name: 'second'), + ]; + await tester.pumpWidget(buildNavigator(myPages, onPopPage, navigator)); + // The pageless route of initial page route should be completed. + expect(initialPageless1Completed, true); + expect(secondPageless1Completed, false); + expect(secondPageless2Completed, false); + expect(thirdPageless1Completed, false); + + myPages = [ + const TestPage(key: ValueKey('3'), name: 'third'), + ]; + await tester.pumpWidget(buildNavigator(myPages, onPopPage, navigator)); + await tester.pumpAndSettle(); + expect(secondPageless1Completed, true); + expect(secondPageless2Completed, true); + expect(thirdPageless1Completed, false); + + myPages = [ + const TestPage(key: ValueKey('4'), name: 'forth'), + ]; + await tester.pumpWidget(buildNavigator(myPages, onPopPage, navigator)); + expect(thirdPageless1Completed, true); + await tester.pumpAndSettle(); + expect(find.text('forth'), findsOneWidget); + }); + + testWidgets('complex case 1 - with always remove transition delegate', (WidgetTester tester) async { + final GlobalKey navigator = GlobalKey(); + final AlwaysRemoveTransitionDelegate transitionDelegate = AlwaysRemoveTransitionDelegate(); + List myPages = [ + const TestPage(key: ValueKey('1'), name: 'initial'), + ]; + bool onPopPage(Route route, dynamic result) { + myPages.removeWhere((Page page) => route.settings == page); + return route.didPop(result); + } + + // Add initial page route with one pageless route. + await tester.pumpWidget(buildNavigator(myPages, onPopPage, navigator, transitionDelegate)); + bool initialPageless1Completed = false; + navigator.currentState.push( + MaterialPageRoute( + builder: (BuildContext context) => const Text('initial-pageless1'), + settings: null, + ) + ).then((_) => initialPageless1Completed = true); + await tester.pumpAndSettle(); + + // Pushes second page route with two pageless routes. + myPages = [ + const TestPage(key: ValueKey('1'), name: 'initial'), + const TestPage(key: ValueKey('2'), name: 'second'), + ]; + await tester.pumpWidget(buildNavigator(myPages, onPopPage, navigator, transitionDelegate)); + bool secondPageless1Completed = false; + navigator.currentState.push( + MaterialPageRoute( + builder: (BuildContext context) => const Text('second-pageless1'), + settings: null, + ) + ).then((_) => secondPageless1Completed = true); + await tester.pumpAndSettle(); + bool secondPageless2Completed = false; + navigator.currentState.push( + MaterialPageRoute( + builder: (BuildContext context) => const Text('second-pageless2'), + settings: null, + ) + ).then((_) => secondPageless2Completed = true); + await tester.pumpAndSettle(); + + // Pushes third page route with one pageless route. + myPages = [ + const TestPage(key: ValueKey('1'), name: 'initial'), + const TestPage(key: ValueKey('2'), name: 'second'), + const TestPage(key: ValueKey('3'), name: 'third'), + ]; + await tester.pumpWidget(buildNavigator(myPages, onPopPage, navigator, transitionDelegate)); + bool thirdPageless1Completed = false; + navigator.currentState.push( + MaterialPageRoute( + builder: (BuildContext context) => const Text('third-pageless1'), + settings: null, + ) + ).then((_) => thirdPageless1Completed = true); + await tester.pumpAndSettle(); + + // Nothing has been popped. + expect(initialPageless1Completed, false); + expect(secondPageless1Completed, false); + expect(secondPageless2Completed, false); + expect(thirdPageless1Completed, false); + + // Switches order and removes the initial page route. + myPages = [ + const TestPage(key: ValueKey('3'), name: 'third'), + const TestPage(key: ValueKey('2'), name: 'second'), + ]; + await tester.pumpWidget(buildNavigator(myPages, onPopPage, navigator, transitionDelegate)); + // The pageless route of initial page route should be removed without complete. + expect(initialPageless1Completed, false); + expect(secondPageless1Completed, false); + expect(secondPageless2Completed, false); + expect(thirdPageless1Completed, false); + + myPages = [ + const TestPage(key: ValueKey('3'), name: 'third'), + ]; + await tester.pumpWidget(buildNavigator(myPages, onPopPage, navigator, transitionDelegate)); + await tester.pumpAndSettle(); + expect(initialPageless1Completed, false); + expect(secondPageless1Completed, false); + expect(secondPageless2Completed, false); + expect(thirdPageless1Completed, false); + + myPages = [ + const TestPage(key: ValueKey('4'), name: 'forth'), + ]; + await tester.pumpWidget(buildNavigator(myPages, onPopPage, navigator, transitionDelegate)); + await tester.pump(); + expect(initialPageless1Completed, false); + expect(secondPageless1Completed, false); + expect(secondPageless2Completed, false); + expect(thirdPageless1Completed, false); + expect(find.text('forth'), findsOneWidget); + }); + + }); } class _TickingWidget extends StatefulWidget { @@ -1742,6 +2402,62 @@ class _TickingWidgetState extends State<_TickingWidget> with SingleTickerProvide } } +class AlwaysRemoveTransitionDelegate extends TransitionDelegate { + @override + Iterable resolve({ + List newPageRouteHistory, + Map locationToExitingPageRoute, + Map> pageRouteToPagelessRoutes, + }) { + final List results = []; + void handleExitingRoute(RouteTransitionRecord location) { + if (!locationToExitingPageRoute.containsKey(location)) + return; + + final RouteTransitionRecord exitingPageRoute = locationToExitingPageRoute[location]; + final bool hasPagelessRoute = pageRouteToPagelessRoutes.containsKey(exitingPageRoute); + + exitingPageRoute.markForRemove(); + results.add(exitingPageRoute); + + if (hasPagelessRoute) { + final List pagelessRoutes = pageRouteToPagelessRoutes[exitingPageRoute]; + for (final RouteTransitionRecord pagelessRoute in pagelessRoutes) { + pagelessRoute.markForRemove(); + } + } + handleExitingRoute(exitingPageRoute); + } + handleExitingRoute(null); + + for (final RouteTransitionRecord pageRoute in newPageRouteHistory) { + if (pageRoute.isEntering) { + pageRoute.markForAdd(); + } + results.add(pageRoute); + handleExitingRoute(pageRoute); + + } + return results; + } +} + +class TestPage extends Page { + const TestPage({ + LocalKey key, + String name, + Object arguments, + }) : super(key: key, name: name, arguments: arguments); + + @override + Route createRoute(BuildContext context) { + return MaterialPageRoute( + builder: (BuildContext context) => Text(name), + settings: this, + ); + } +} + class NoAnimationPageRoute extends PageRouteBuilder { NoAnimationPageRoute({WidgetBuilder pageBuilder}) : super(pageBuilder: (BuildContext context, __, ___) {