You are on page 1of 21

Summary

Introduce a declarative API to set the history stack of


the Navigator and a new Router widget to configure
the Navigator based on app state and system events.
Author: Michael Goderbauer (@goderbauer)
Go Link: flutter.dev/go/navigator-with-router
Created: September 2019 / Last updated: October 2019

Objective
This document describes a new API for the Navigator that allows setting the navigator's history
stack in a declarative way by providing a list of immutable Pages. The Pages will be turned into
actual Routes living on the navigator stack similar to how the Flutter framework inflates
immutable Widgets into Elements to render them on screen. The document also describes how
the implementation of the existing imperative Navigator API (push(), pop(), replace(), etc.)
can be refactored to work hand-in-hand with the new declarative API. Last, but not least, the
document introduces a new Router widget that can be wrapped around a Navigator. The Router
widget configures the list of Pages to be displayed by the Navigator based on events from the
operating system and based on app state: It can obtain the initially requested initial route from
the operating system and configure the Navigator's history stack via the newly introduced
declarative API accordingly. Or, when a new intent is received while the app is running, the
Router can reconfigure the Navigator to go to the new route specified by the intent. The Router
also responds to taps on the system's back button by removing the topmost route from the
navigator stack. At the same time, the Router can also be used to configure the Navigator from
within the app, e.g. in response to user inputs.

Goals
● The Navigator's history stack can be set and modified declaratively by developers.

PUBLICLY SHARED
PUBLICLY SHARED
● The Navigator's imperative API (push(), pop(), replace(), and friends) continues to
work and both APIs can be used together in the same app.
● The way the Navigator transitions a declaratively provided history stack to a newly
provided one can be customized.
● A Router widget is available that wraps the Navigator and reconfigures its history stack
based on system events and app state, supporting the following:
○ The Router can configure the Navigator to show the initial route requested by the
operating system when the app launches.
○ The Router can reconfigure the Navigator when a new intent (e.g. from the
operating system) arrives to show the route specified by that intent.
○ The Router can reconfigure the Navigator appropriately when the user hits the
system back button to pop the topmost route.
○ The developer can instruct the Router to reconfigure the Navigator's history stack
to his/her liking, e.g. to show new routes in response to user interactions.
● Developers can customize the Router behavior described above (including how a route
string provided by the operating system is parsed into a stack of routes).
● Routers/Navigators can be nested and pressing the system back button pops the route
from the most appropriate Navigator (e.g. in most cases routes should pop first from the
innermost navigator).

Background & Motivation


This section talks about the capabilities of the current Navigator API and its shortcomings.
Currently, Flutter provides two ways to configure and modify the history stack of a Navigator:
The initialRoute property and the imperative API (push(), pop(), pushNamed(),
pushAndRemoveUntil(), etc.).

Initial Route
The initialRoute argument is only honored the first time the Navigator is built to set the first
route the Navigator should show. It is usually set to Window.defaultRouteName, which contains
the name of the route requested by the operating system when the app was launched.
Changing the initialRoute after the Navigator has been built for the first time has no effect
leaving the developer with no good option to properly respond to incoming intents while the app
is running: They cannot easily replace the Navigator's history stack to show the route requested
in the intent.

Imperative API
The imperative API of the Navigator is implemented as static methods on the Navigator, which
are forwarded to instance methods on the NavigatorState. This API allows developers to push
new routes onto the Navigator and remove existing ones. This API is often used to respond to
user input from within an app: e.g. when the user hits the back button in the AppBar, pop() is
called to remove the top-most route. When the user clicks on an element to open a detailed
view, push() is called to show a route with detailed information about the clicked element. As
implemented, the imperative API allows very targeted modifications of the history stack without
offering much flexibility: It does not allow developers to freely modify and rearrange the history

PUBLICLY SHARED
PUBLICLY SHARED
1
stack to their liking . This has led to many feature requests where developers are asking for
extensions to the imperative API. At its core, most of these feature requests are essentially
asking for full control over the Navigator's history stack.

As mentioned by a participant in one of Flutter's user studies, the API also feels outdated and
not very Flutter-y. In Flutter, if you want a widget to have a different set of children you'd just
rebuild that widget with a new set of children. If you want the Navigator to have a different set of
routes as children, well, you cannot just rebuild the Navigator. Currently, you'd have to use the
somewhat clunky imperative API to achieve your goal.

Nested Navigators
Another pain point for developers with the current state of affairs are nested Navigators. Nested
Navigators are common in tabbed user interfaces where each tab has its own Navigator nested
under one root Navigator. The nested Navigators keep track of the route history within one tab.
Currently, Flutter connects the system back button only to the root navigator which can create
confusion for users if they have navigated to a specific route within a tab: Hitting the system
back button will not get them back to the previous route within that tab. It will instead pop the
global navigator and remove the route with the entire tabbed interface from the global history
stack.

Overview
The section gives an overview of the proposed new design for the declarative API of the
Navigator and the newly introduced Router widget from the perspective of a future developer
using these APIs. Refer to the "Detailed Design" section below to learn about the
implementation details behind these APIs.

Navigator
The section introduces the concept of Pages to the Navigator and divides the Routes managed
by the Navigator into two groups: Some Routes are backed by a Page, others are not. The latter
are called pageless Routes.

Pages
To set the history stack of the Navigator declaratively, developers will provide a list of Page
objects to the Navigator widget in its constructor. Page objects are immutable and describe the
route that should be placed into the Navigator's history stack. The Navigator inflates a Page
object into a Route object. In this way, the relationship between Pages and Routes is analogous
to the relationship between Widgets and Elements: A Widget or a Page describes a
configuration for an actual Element or Route, respectively.

A Page object can always produce a corresponding Route that is placed in the history stack.
However, not every Route corresponds to a Page (see "Pageless Routes" section below).

Developers are free to implement their own Page objects, or they can use one of the

1
Some believe that the imperative API of the Navigator is the world's worst implementation of a list
mutation API [citation needed].

PUBLICLY SHARED
PUBLICLY SHARED
PageBuilders provided by the framework. The framework will have PageBuilders that take either
a Route or a Widget. In the case of the latter, the specific PageBuilder will wrap the widget with
an appropriate Route (e.g. MaterialPageRoute, etc.).

The order of Routes corresponding to Pages within the history stack is the same as the order of
their corresponding Pages in the list provided to the Navigator. When the list of Pages given to
the Navigator is updated, the new list is compared to the old list and the route history is updated
accordingly:
● Routes belonging to Pages that are no longer present in the new list are removed from
the history.
● Pages, that are present in the new list, but don't have a corresponding Route yet, are
inflated and the resulting Route is added to the history at the appropriate location.
● The order of Routes in the history stack is updated to match the order of their
corresponding Pages in the new list.

A transition delegate decides how Routes, whose Pages were added to or removed from the
list, transition on or off the screen (see section "Transition Delegate" below).

In addition to a list of Pages, the Navigator also takes a new onPopPage callback. The Navigator
calls this method - usually in response to a Navigator.pop() call - to ask that the given Route
corresponding to a Page should be popped. If the receiver of this callback agrees, it calls
didPop on the Route. If that was successful, it must update the Navigator with a list of Pages
that no longer includes the Page corresponding to the popped Route and return true from the
onPopPage callback. If the popped Page is not removed from the list provided to the Navigator,
it will be treated as a new Page, which is inflated to a new Route. If the receiver of the
onPopPage callback doesn't want the Route to be popped it must simply return false (without
calling didPop on the Route). The onPopPage callback is only ever called for the topmost Page.

Pageless Routes
The existing imperative API of the Navigator adds Routes to the history stack (via push() and
friends), that don't correspond to a Page. To minimize breakage in existing apps, this code path
will continue to work. In the new world, pageless Routes are tied to the Route below them in the
history stack that does correspond to a Page. If a Route corresponding to a Page is moved to a
different place in the route history, all pageless Routes tied to it move to the new location as
well. The order within the pageless Routes tied to a given Route is kept the same during the
move. If a Route corresponding to a Page is removed from the route history, all pageless
Routes tied to it are removed as well (their exit transition can be controlled via the transition
delegate).

The existing initialRoute property on the Navigator also generates a list of pageless Routes
when the Navigator is inserted into the tree for the first time. The Routes generated from that
initialRoute string will be placed on top of all Routes inflated from the list of Pages provided
to the Navigator. However, in practice it will be discouraged to provide an initialRoute string
as well as an initial list of Pages. Instead, the initial route should just be defined as a list of
Pages and the initialRoute string should be left as null.

Transition Delegate
The transition delegate has to decide how Routes should enter or exit the screen when their

PUBLICLY SHARED
PUBLICLY SHARED
corresponding Pages are added to or removed from the list provided to the Navigator. For this,
the transition delegate has to make two decisions:
A. Should the Route animate in/out when it is added/removed or should it just
appear/disappear?
B. When Routes are added and removed at the same location in the history stack how
should they be ordered for the duration of the transition?

Let's look at an example to better understand the second question: Let's assume the list of
Pages provided to the Navigator changes from [A, B] to [A, C]. There are two possible ways in
which this transition can happen:
1. C is added (possibly with an animation) on top of B while B is removed (possibly with an
animation) from underneath (this removal may be delayed until C's animation has been
completed).
2. C is added underneath B (possibly with an animation) and B is transitioning out on top
of it (possibly with an animation) to reveal C.

The visual effect of the first option would make it look as if C is pushed on top of B. The second
option would look like B is getting popped to reveal C.

To get the visual effect described by option #1, the order in the Navigator stack has to be [A, B,
C] for the duration of the transition. For option #2, the order has to be [A, C, B]. There is no way
to determine which effect is desired just by looking at the old and new list of Pages provided to
the Navigator. It's therefore the responsibility of the transition delegate to figure out how Pages
should be ordered to achieve the effect desired.

When the list of Pages is changed, the transition delegate is provided with a series of HistoryDiff
objects, one for each location in the history that has Pages added or removed. A HistoryDiff
contains an ordered list of Routes added to that location and an ordered list of Routes removed
from that location. For context, it also gets to see what Routes are in the history stack prior to
the location of the diff and what Routes are in the history stack after that location. It is now the
responsibility of the transition delegate to decide for each Route in the added and removed list
whether it should animate in or not. Furthermore, the delegate has to return a new list which
merges the added and removed list to determine the desired order. The relative order the
Routes had in the added and removed list has to be preserved in the merged list (that order can
only be changed by providing a new list of pages to the Navigator).

The transition delegate can also decide how pageless Routes tied to a removed Route should
leave the screen. For that, the HistoryDiff contains a Map from "Route with Page" to "List of
pageless Routes owned by that Route". Only Routes from the removed list may have an entry
here as newly added Routes cannot own any pageless Routes when they are added. The
transition delegate cannot modify the order of pageless Routes, though. They will always be
located on top of their owning page-based Route and their lifecycle is tied to its lifecycle.

The transition delegate can be configured on the Navigator by the developer. Developers may
choose to provide a different delegate for each update to the list of Pages to implement different
transition styles.

If no custom delegate is provided, the default one will be used. The default delegate implements
a push-like effect as described in option #1 above. For each HistoryDiff it places all added

PUBLICLY SHARED
PUBLICLY SHARED
routes on top of any removed routes and it will only animate Route transitions if the very top of
the history stack changes:
● If the topmost Route is to be added, it will animate that Route in. All other Routes will be
added/removed without animation when that transition completes (this assumes that the
newly pushed route hides these transitions to avoid visual glitches).
● If the Route is to be removed, it will animate that Route out. All other Routes are
added/removed without animation before the transition starts (this assumes that the
to-be-popped route hides these transitions to avoid visual glitches).

Summary
To summarize, this document suggests the following changes to the public Navigator API:
● Introduce a new class Page, which functions as a blueprint for creating Routes.
● Add the following properties to the Navigator API:
○ List<Page> pages to declaratively set the route history
○ OnPopPageCallback onPopPage, which allows the Navigator to pop a Page
○ TransitionDelegate transitionDelegate to customize the transition for
added/removed Pages

Router
The Router is a new widget that is a dispatcher to open and close pages of an application. It
wraps a Navigator and configures its current list of Pages based on the current app state.
Furthermore, the Router also listens to events from the operating system and can change the
configuration of the Navigator in responds to them.

An application using the new Router widget may manage what's currently shown on screen in
its app state. Instead of using the imperative API to show a new Route in response to the user
tapping a button, the button's click handler will modify that state. The Router is registered to
listen to changes in the app state and will rebuild with a newly configured Navigator to reflect
those changes. When the Navigator was reconfigured with a new Page this may cause a new
Route to appear on screen. This exampleatory process of how the Router can be used is
illustrated in the following diagram:

PUBLICLY SHARED
PUBLICLY SHARED

The specific behavior of how the Router learns about changes in app state and how it responds
to them is configured by the routerDelegate. Users of the Router have to custom-implement this
delegate to fit it to the needs of their app. They may choose to make the delegate listen to
changes in app state as illustrated above, but that is not a requirement for using the Router.

The Router can also help developers in listening to routing related events from the operating
system. The Router is designed to support the following system events:
● Retrieve the initial route requested by the operating system when the app first launches.
● Listen to new intents from the operating system which may request a new route to be
shown.
● Listen to requests from the operating system to pop the last route of the history stack.

How the Router listens to these events is configured by the routeNameProvider delegate and
the backButtonDispatcher delegate. The String representing the route requested by the
operating system as either initial route or from within an intent is parsed by the
routeNameParser delegate. This delegate turns the String provided by the routeNameProvider
into parsed route data of type T, which is a generic type argument of the Router. The framework
ships with default implementations for these delegates, which should be sufficient for most use
cases. When the default delegates are used, T will be a list of RouteSettings.

The parsed route data from the routeNameParser and the notifications about back button
presses from the backButtonDispatcher are passed on to the routerDelegate, which may rebuild
the Navigator with a new list of Pages based on these information. In the example given in the
diagram above, the routerDelegate would use these notifications to reconfigure the app state

PUBLICLY SHARED
PUBLICLY SHARED
based on the information in the notification and then rebuild the Navigator to reflect those
changes in app state.

The following diagram illustrates the flow of information through the delegates:

The part where the routerDelegate communicates with the app state is optional and dependent
on the user-supplied concrete implementation of the routerDelegate. Where and how the
backButtonDispatcher and the routeNameProvider listen to events (it doesn't have to be the
operating system) can be customized by providing custom implementations of those delegates.

Route Name Provider


The routeNameProvider delegate determines how the Router learns about routes that the
operating system wants to show. It is a ValueListenable of String and when the Router builds for
the first time its current value is used to determine the initial Route shown. Whenever the value
of the Listenable changes (this may happen when a new intent is received from the operating
system to show a different route), the Router is informed and may change the configuration of
the Navigator by providing a new list of Pages to show that route.

The String obtained from the ValueListenable is parsed by the routeNameParser delegate into
data of type T. The parsed data is passed to the routerDelegate, which may decide to rebuild
the navigator with a new list of Pages based on this information.

The provided default routeNameProvider is a ValueNotifier of String that simply wraps


Window.defaultRouteName as initial value and listens to
WidgetsBindingObserver.didPushRoute. When the latter fires, the listeners of the
routeNameProvider (which is the Router) are informed that there's a new route from the
operating system that needs processing.

The default routeNameProvider should be sufficient for most use cases and only very few users
will probably choose to provide a custom implementation.

Route Name Parser


The routeNameParser delegate gets the current route string from the routeNameProvider and
turns it into data of type T. That data is used by the routerDelegate to configure the Navigator to
show the appropriate route.

The default routeNameParser will parse the provided string into a list of RouteSettings, where

PUBLICLY SHARED
PUBLICLY SHARED
each element of the List represents a Page that should be pushed onto the navigator. The
default delegate assumes that route strings have the following shape:
/foo/bar?id=20&name=mike. This string will be parsed into three RouteSettings for the routes
/, /foo, and /foo/bar and each RouteSetting will have the arguments {'id': '20',
'name': 'mike'} associated with it.

It's expected that the default routeNameParser is sufficient for most cases and that this delegate
will be rarely customized.

Router Delegate
The router delegate is the heart of the Router and responsible for building an appropriately
configured Navigator based on the information it has available. The delegate itself is a
Listenable, which the Router widget is subscribed to. Whenever the delegate wants to change
how the Navigator is configured (e.g. change the list of Pages provided to the Navigator), it
notifies its listeners, which will cause the Router widget to rebuild. As part of that rebuild the
Router asks the routerDelegate for a correctly configured Navigator instance, which the Router
will incorporate into the tree.

The framework does not provide a default implementation for this delegate as its behavior is
highly app-specific. Developers may choose to make the delegate listen to changes in app state
and reconfigure the Navigator based on the current app state to show the routes appropriate for
that state.

The routerDelegate is also informed about routing related system events:


● popRoute is called when the backButtonDispatcher notifies the Router that the user has
pressed the system back button.
● setInitialRoutePath is called shortly after the Router has been built for the first time
with the initial route information obtained from the routeNameProvider. The route name
is parsed by the routeNameParser before it is passed into this method. By default, this
method is just forwarded to setNewRoutePath.
● setNewRoutePath is called whenever the routeNameProvider signals that a new route
should be shown. The route name is parsed by the routeNameParser before it is passed
into this method.

The Router doesn't make any assumptions about what the routerDelegate does with these
notifications. Possible options include:
● Ignore these events and do nothing.
● Configure the app state to reflect the changes requested by these notifications and then
request the Router to rebuild with a reconfigured Navigator.
● Directly request the Router to rebuild with a newly configured Navigator.

Back Button Dispatcher


The backButtonDispatcher delegate informs the Router that the user has tapped the system's
back button and would therefore like to go back to the previous route. This is only used on
platforms that have a system back button (e.g. Android).

The backButtonDispatcher is implemented as a Listenable, which the Router is subscribed to.


The dispatcher notifies its listeners whenever it thinks the Navigator wrapped by the Router

PUBLICLY SHARED
PUBLICLY SHARED
should pop the current page because the user has hit the system back button.

Instead of notifying its own Router listener, the dispatcher may choose to instead notify a child
backButtonDispatcher and have the child's Router listener handle the back button press. This
feature allows nesting Routers/Navigators in e.g. a tabbed interface. The design doesn't limit the
level of nesting and the child backButtonDispatcher could in turn have another child as well.

The framework ships with two concrete implementations for a backButtonDispatcher: a default
implementation typically used for the root Router and an implementation for a child
backButtonDispatcher typically used in a nested Router.

The default implementation for the root Router just listens to


WidgetsBindingObserver.didPopRoute to determine if the back button has been pressed. It
either notifies its listeners or forwards the notification to a child backButtonDispatcher, if one has
requested to take priority over the root dispatcher. When multiple children have claimed priority,
the child, who claimed it last, gets the notification. When that child decides it no longer wants
priority, the child who claimed priority before that one gets the notification. If no other child has
claimed priority, the notification is dispatched to the listener of the parent as usual.

The ChildBackButtonDispatcher does not listen to any system events itself. It only gets the
notifications of a parent dispatcher once it has claimed priority on that dispatcher. In order to
claim priority, it will need a reference to its parent. To get that reference, Routers are
implemented as an InheritedWidget with a static of method to obtain the closest Router widget
in the context. That Router widget has a reference to its backButtonDispatcher, which is used as
the parent for the ChildBackButtonDispatcher.

Example Usage
To use the Router with the new Navigator API, developers will basically only have to implement
their own custom RouterDelegate. For all other delegates, the defaults should be sufficient. This
section demonstrates how the Router and new Navigator API can be used for a Stock app.

For this example we assume that the stocks app has three screens:
● A homepage displaying a list of favorited stock symbols (clicking on a symbol goes to its
details page) and a search icon (clicking on the search icon goes to the search page).
● A details page showing details about a particular stock with a back button to go back to
the previous screen.
● A search page with a search bar, a back button, and a list of results. Clicking on a result
goes to its details page. Clicking on the back button goes back to the previous screen.

The app state (or model) for this app would look something like the following. It is made
available within the app using an InheritedWidget as explained in this article. The app state is a
change notifier to allow the RouterDelegate to listen for changes.

class StockAppState extends ChangeNotifier {


// If non-null: show the search page with this initial query.
String get searchQuery;
String _searchQuery;

PUBLICLY SHARED
PUBLICLY SHARED
set searchQuery(String value) {
if (value == _searchQuery) {
return;
}
_searchQuery = value;
notifyListeners();
}

// If non-null: Show the details page for this symbol.


String get stockSymbol;
String _stockSymbol;
set stockSymbol(String value) {
if (value == _stockSymbol) {
return;
}
_stockSymbol = value;
notifyListeners();
}

// Show these symbols on the home screen.


final List<String> favoriteStockSymbols; // Loaded from e.g. a database.
}

This app state can now be used by the custom RouterDelegate that developers will have to
implement. The RouterDelegate is passed to the Router. All other delegates of the Router can
keep their default implementation.

class StockAppRouteDelegate extends RouterDelegate<List<RouteSettings>>


with PopNavigatorRouterDelegateMixin {
StockAppRouteDelegate(this.state) {
state.addListener(notifyListeners);
}

void dispose() {
state.removeListener(notifyListeners);
}

final StockAppState state;

@override // From PopNavigatorRouterDelegateMixin.


final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();

@override
void setNewRoutePath(List<RouteSettings> configuration) {
if (configuration.length != 1 || configuration.single.name != '/') {
// Don't do anything if the route is invalid.
return;
}
// Update state; if this modifies the state it will call our listener,
// which will cause a rebuild.
state.searchQuery = configuration.single.arguments['searchQuery'];
state.stockSymbol = configuration.single.arguments['stockSymbol'];

PUBLICLY SHARED
PUBLICLY SHARED
}

@override
Widget build(BuildContext context) {
// Return a Navigator with a list of Pages representing the current app
// state.
return Navigator(
key: navigatorKey,
onPopPage: _handlePopPage,
pages: [
MaterialPageBuilder(
key: ValueKey<String>('home'),
builder: (context) => HomePageWidget(),
),
if (state.searchQuery != null)
MaterialPageBuilder(
key: ValueKey<String>('search'),
builder: (context) => SearchPageWidget(),
),
if (state.stockSymbol != null)
MaterialPageBuilder(
key: ValueKey<String>('details'),
builder: (context) => DetailsPageWidget(),
),
],
);
}

bool _handlePopPage(Route<dynamic> route, dynamic result) {


Page page = route.settings;
if (page.key == ValueKey<String>('home')) {
assert(!route.willHandlePopInternally);
// Do not pop the home page.
return false;
}

final bool result = route.didPop(result);


assert(result);
// Update state to remove the page in question; if this modifies the state
// it will call our listener, which will cause a rebuild.
if (page.key == ValueKey<String>('search')) {
state.searchQuery = null;
return true;
}
if (page.key == ValueKey<String>('details')) {
state.stockSymbol = null;
return true;
}
assert(false); // We should never be asked to pop anything else.
return true;
}
}

PUBLICLY SHARED
PUBLICLY SHARED

Coexistence of imperative and declarative API


As outlined above and explained in the following section the existing imperative API of the
Navigator and the new declarative API (possibly in combination with the new Router) can be
used in parallel within the same app. However, that may lead to some code duplication as some
route strings will have to be parsed in the Navigator and the Router. It is recommended that
apps decide on one style to use. Hopefully, medium to large applications will choose to
configure the Navigator's history stack via the Router and only use the imperative API to bring
up very transient Routes like dialogs and alerts. The flexible Router-approach is more
future-proved as it will make it easier to integrate and support new features like Linkability and
saving/restoring the current instance state (e.g. when the operating system kills an app in the
background due to low memory) into the framework.

Detailed Design
This section contains some more details about specific aspects of the design outlined in the
Overview chapter.

Navigator
This section talks about how Pages are implemented, how the Navigator keeps track of the
current state of its routes, and what the Navigator does when its list of pages is updated.

Pages
Routes already have a property for RouteSettings and Pages are basically RouteSettings on
steroids since a Page essentially describes the configuration for a Route. Therefore, it makes
sense to implement Pages as a subclass of the existing RouteSettings class.

In terms of implementation, each Page may have an optional Key similar to how Widgets may
have a Key. The key is used to determine if a given Page represents the current configuration
for an already inflated Page when the list of Pages provided to a Navigator is updated (see
"Updating Pages"). A LocalKey is sufficient here since Pages are only moved around in a
one-dimensional list.

In addition to that, a Page also has to implement a createRoute method. It gets a BuildContext
as an argument and must return the inflated Route corresponding to the Page. This method is
called when the Page is added to the Navigator's history stack for the first time. The Route
returned by this method must have its settings property set to the corresponding Page.

Last, but not least, a Page also implements a canUpdate method, which is consulted when a
new list of Pages is given to the Navigator to figure out if a Route corresponding to a Page
present in the old list can be updated by a Page found in the new list (see "Updating Pages").
This is similar to how Widget.canUpdate is used. The default implementation returns true when
old and new Page have the same runtime type and the same key.

Taking all this into consideration, the interface of a Page looks like this:

PUBLICLY SHARED
PUBLICLY SHARED
abstract class Page<T> extends RouteSettings {
const Page({
this.key,
String name,
Object arguments,
}) : super(name: name, arguments: arguments);

final LocalKey key;

bool canUpdate(Page<dynamic> other) {


return other.runtimeType == runtimeType && other.key == key;
}

Route<T> createRoute(BuildContext context);


}

Route State Machine


The imperative and the declarative API both transition Routes from one lifecycle state to the
next while triggering the side effects that come with these state transitions on the Route. For
example, when the Navigator is requested to pop a Route, it will trigger the route exit transition
by calling Route.didPop() and wait for the animation to complete before disposing the route.
For the Navigator, the request to pop the Route transitions the Route's lifecycle state from "idle"
over "waiting for pop to complete" to "disposed". Currently, these lifecycle state changes are
implicitly encoded in the imperative API.

The declarative API will have to transition Routes through the same lifecycle states as the
imperative API. To avoid duplication, the lifecycle transitions are extracted into a shared method
and both APIs will only mark routes with the transition they need performed on them before
calling the central method. That method - named flushHistoryUpdates - will perform the
actual transition and trigger all side effects that come with the transition on the Route.

The following diagram describes the lifecycle transition that a Route can undergo in this new
world. The transient states marked with an asterix are used as marker states by the imperative
and declarative API to tell flushHistoryUpdates what transition needs to be performed next
on a Route. As soon as flushHistoryUpdates runs, the Route will leave that state to advance
to the next. A # indicates that the Route stays in this state until a specified event triggers.

PUBLICLY SHARED
PUBLICLY SHARED

PUBLICLY SHARED
PUBLICLY SHARED

The following events determine the current lifecycle state of a Route:


● Routes added to the history stack by parsing the initial route start in the add* state.
● Routes added with push() and pushAndRemoveUntil() start in the push* state. Routes
removed by pushAndRemoveUntil() advance to the remove* state.
● Routes added with pushReplacement() start in the pushReplace* state and the replaced
Route advances to the remove* state.
● Routes added with replace() start in the replace* state and the replaced route advances
to remove*.
● Calling pop() may cause the topmost route to advance to the pop* state, if the Route
cannot handle the pop internally (e.g. LocalHistoryRoute).
● Pages added to the list of Pages provided to the Navigator may start in the push*,
replace*, or add* state as determined by the transition delegate.
● Pages removed from the list of pages provided to the Navigator may advance to the
pop*, complete*, or remove* state as determined by the transition delegate.
● Routes advance from pushing# once the push animation has completed (the future
returned by Route.didPush() is completed).
● Routes advance from popping# when finalizeRoute() is called on the Navigator to
indicate that the pop animation is completed.
● Routes advance from removing# and adding# as soon as all pushes above the Route
have completed or instantly if there is at least one idle route on top of the Route to avoid
visible visual glitches.

Informing Routes about their new previous/next Route (via didChangeNext() or


didChangePrevious()) is delayed until all transient state changes indicated by an asterisk have
been processed. This avoids notifying Routes about transient neighbors that are about to go
away. Routes are also only informed about the next neighboring active Route.

Not shown in the diagram is the edge case of removing a Route while it's push animation is still
running (it is in the pushing# state). This case is also fully supported: A route in pushing# can
skip the idle state and directly advance to one of the states after idle.

The Route object and its current lifecycle state are bundled together in RouteEntry objects. The
route history stack of the Navigator is just a list of these RouteEntries in which the last entry
belongs to the top-most Route.

Updating Pages
When the list of Pages provided to the Navigator changes, the history stack has to be updated
as described in the "Pages" section. For this, the Navigator constructs a new history stack by
matching the Pages in the new List to Routes from the old history stack. A Page matches a
Route if canUpdate(pageFromTheNewList) called on the old Page associated with that Route
returns true. By default, this is true when old and new Page have the same runtime type and
when their keys are equal, if provided. If the Page matches the Route, the Route's settings are
updated to the new Page (which will cause the Route to rebuild if the new Page is different from
the old). The RouteEntrys for that Route and all pageless Routes owned by it are then copied
into the new History.

If there is no match for a Page in the old history, the Page is inflated by calling createRoute.
The resulting Route is wrapped in a new RouteEntry and placed into the new history.

PUBLICLY SHARED
PUBLICLY SHARED

This process is repeated for all Pages in the new list. At the end, Routes that were not matched
up with a Page are marked as being removed and reinserted into the history stack at their old
locations relative to the other Routes that were matched up. These Routes have to be kept in
the history for a little longer as the Navigator may have to trigger and wait for exit transitions
before disposing the Routes.

Routes that have moved past the idle lifecycle state cannot be matched with a Page anymore
as these are basically on their way out.

The transition delegate gets to decide in which order the removed and added routes are added
to the new history. It also decides how these Routes transition in and out.

Transition Delegate
In technical terms, the transition delegate has to decide in which lifecycle state a newly inflated
Route is added to the history stack. The potential options from the state machine diagram are:
push*, replace*, and add*. Similarly, it has to decide which transition Routes removed from the
history stack should take from the idle state. Choices here are: pop*, remove*, and complete*.

The merged history stack described in the previous section is split up into multiple history diffs
that are fed one by one to the transition delegate. The following example illustrates how these
diffs are created (lowercase letters are used to indicate pageless Routes, uppercase letters
indicate Routes backed by a Page, and PA represents the Page corresponding to Route A):

Old Route History: A, B, x, y, z, C, D

New Page List: PA, PE, PF, PD, PG

Merged Route History: A, B, x, y, z, C, E, F, D, G

This will produce two HistoryDiffs. The first one contains the following information:
Page-based Routes added: E, F

Page-based Routes removed: B, C

Mapping to pageless Routes: { B => [x, y, z] }

Routes before diff: A

Routes after diff: D, G

Diff number: 1 of 2

The transition delegate receiving this diff will now call methods on E and F to mark them as
either push*, replace*, or add*. Similarly it will call methods on B, C, and the pageless Routes x,
y, z attached to them to mark them es either pop*, remove*, and complete*. Last, but not least, it
will return a list that merged the added and removed Routes to determine their order. Let's
assume it returns [E, B, F, C]. The second HistoryDiff is then constructed as follows:
Page-based Routes added: G

PUBLICLY SHARED
PUBLICLY SHARED
Page-based Routes removed: n/a

Mapping to pageless Routes: { }

Routes before diff: A, E, B, x, y, z, F, C, D

Routes after diff: n/a

Diff number: 2 of 2

The transition delegate processes this diff similar to how the first one was processed. Since the
"Routes after diff" is empty, the transition delegate can infer that this HistoryDiff is located at the
top of the history stack.

What is included in the HistoryDiff as a Route in the added and removed list as well as in the
mapping is basically a mostly read-only view of the RouteEntries that the Navigator uses
internally to manage the history stack. This view gives the transition delegate access to the
current lifecycle state of a Route and its RouteSettings (which may be a Page). It also exposes
some methods to move these RouteEntries to the push*, replace*, add*, pop*, remove*, or
complete* lifecycle state. The other Routes included in the diff are read-only views of the
RouteEntries without access to these additional mutating methods.

Routes & RouteSettings


Currently, the only way to add a Route to the history stack without playing its entrance animation
is to mark it as an initial route in its RouteSettings. The declarative API requires that routes can
be added without animation at any time. This is useful when the route is covered by another
route and playing the animation simply doesn't make sense. To support this, a didAdd method is
added to the Route interface. This method is called by the Navigator instead of didPush when
the Route should just be added without its regular entrance animation. To simplify things, this
new method will also be used to bring the initial route on screen. This makes the
RouteSettings.initialRoute parameter useless and it will be removed from RouteSettings. This is
a minor breaking change.

Router
The API between the Router and its various delegates is entirely asynchronous. The
routeNameProvider notifies the Router asynchronously when a new Route is available. The
routeNameParser returns a Future which completes when the parsing is done. The
routerDelegate notifies the Router asynchronously when a new navigator configuration is
available. And so on.

The asynchronous nature gives developers more flexibility in implementing these delegates:
The routeNameParser may need to communicate with the OEM thread to obtain more data
about a route. This communication can only happen asynchronously and therefore the parsing
API also needs to be asynchronously. Similar statements apply to most of the other delegate
API methods.

When the delegates can perform their work entirely synchronously, then SynchronousFuture
should be used in their implementations. This will allow the Router to proceed in a completely

PUBLICLY SHARED
PUBLICLY SHARED
synchronous way, which may speed up things a bit. However, asynchronous work is completely
supported by the Router if necessary.

To enable correct asynchronous processing, special care has to be taken when implementing
the Router: When a future returned by one of the delegates completes, it needs to be checked
that the request that created the future is still current. If another newer request has been made
since then the completion value of the future should be ignored.

Router Delegate
The routerDelegate that developers using the Router will have to implement has the following
interface:

abstract class RouterDelegate<T> implements Listenable {


void setInitialRoutePath(T configuration);
void setNewRoutePath(T configuration);
Future<bool> popRoute();
Widget build(BuildContext context);
}

The main task of the RouterDelegate is to return a properly configured Navigator in its build
method whenever the Router asks for one. The Navigator should be configured with a list of
Pages that the RouterDelegate would like to display on screen. Whenever the RouterDelegate
would like to change the configuration of the Navigator returned by its build method it needs to
call notifyListeners() inherited from its superclass. That's the signal for the Router widget to
rebuild and request a new Navigator from the RouteDelegate by calling the delegate's build
method again.

The other methods of the routerDelegate are called by the Router in response to system events:
setInitialRoutePath and setNewRoutePath are called when the initial route or a new route have
been retrieved from the routeNameProvider and the route string has been parsed by the
routeNameParser. In response to these calls, the delegate may notify the router via
notifyListeners to request a rebuild of the Navigator with the new configuration implied by the
arguments handed to those methods.

The popRoute() method is called by the Router when the backButtonDispatcher reports that the
operating system is requesting that the current route should be popped. This will likely cause
the route delegate to forward the pop to the Navigator previously returned by its build method. If
the routeDelegate was able to handle the pop, it should return true. Otherwise it should return
false. Returning false may pop the route of a surrounding Navigator (which may be the
SystemNavigator), depending on the concrete implementation of the backButtonDispatcher.

Back Button Dispatcher


As described in the Overview section, the framework will ship with two concrete
BackButtonDispatcher implementations (RootBackButtonDispatcher and
ChildBackButtonDispatcher), who both implement the following interface:

abstract class BackButtonDispatcher implements Listenable {

PUBLICLY SHARED
PUBLICLY SHARED
void takePriority();
void deferTo(ChildBackButtonDispatcher child);
void forget(ChildBackButtonDispatcher child);
}

When takePriority() is called on a ChildBackButtonDispatcher it will call deferTo() on its parent.


The parent remembers all children that have called that method in an order list. When it is
notified by the parent (or in the case of the RootBackButtonDispatcher by the operating system)
that the back button has been pressed, it forwards this notification to the last child in that list via
a method call. If the list is empty, it notifies its Router by calling notifyListeners() from its
superclass. A child can also call forget() on its parent if it no longer wants to receive back button
notifications. In that case, the parent removes the child from its internal list.

When takePriority() is called on any BackButtonDispatcher, the dispatcher will also clear its
internal child list to no longer forward the back button notification to any children.

Integration
Currently, the Navigator is integrated into WidgetsApp (and through that also into MaterialApp).
We would like to integrate the Router into those widgets as well so that its users can benefit
from it. Ultimately, using the Router will hopefully become the best way to interact with the
Navigator in a Flutter app.

While the Router (and the new declarative Navigator API) is designed to be used in parallel with
the existing imperative API of the Navigator, exposing both of them in the regular
MaterialApp/WidgetApp constructor may be confusing. To ease the confusion, we suggest
creating a new constructor WidgetApp.withRouter and MaterialApp.withRouter. These
constructors will no longer expose the Navigator API associated with the old imperative API.
Instead these constructors will allow users to pass in custom delegates for the Router. The old
constructors stay the same, people using these will not get the benefits of the Router.

Namely, the new constructors will no longer allow users to pass in the following parameters.
Their functionality should instead be implemented in the custom routerDelegate.
● navigatorKey
● initialRoute
● onGenerateRoute
● onUnknownRoute
● navigatorObservers
● pageRouteBuilder
● routes
Instead, they can pass in the various delegates for the Router described previously in this
document.

Credits
This document is in parts an extension of the work done by ianh@ in Hixie:router.

PUBLICLY SHARED
PUBLICLY SHARED

Document History
Date Author Description
2019-09-30 goderbauer Initial draft.
2019-10-07 goderbauer Copied draft into a shareable document.
2019-10-10 goderbauer Added section: "Example Usage"
2019-10-11 goderbauer Replaces Navigator.removePage with Navigator.onPopPage API

PUBLICLY SHARED

You might also like