Building a Simple Stack Navigator with Flutter Navigation 2.0

John Cassidy
10 min readApr 22, 2021

This article will demonstrate a simple approach to utilizing Flutter Navigation 2.0 to construct an application built as a Stack.

The full code for this sample can be found here.

While the following may look like a lot of information to build a simple Stack Navigator, it will actually provide a solid base for expansion into more complex navigation strategies. Additionally, implementing the following allows for simpler handling of Deep Linking (Universal Links / App Links) if you wish to implement them in your application at a later date.

Basic Application Structure

In the following visual, the elements shaded in blue represent Widgets that ultimately draw to the screen. The remaining elements are helper classes that facilitate what information is used when drawing to the screen.

MaterialApp.router

Top level component that indicates the intention of using Navigation 2.0 with a RouterDelegate and a RouteInformationParser.

@override
Widget build(BuildContext context) {
return MaterialApp.router(
key: ValueKey('main_router'),
routeInformationParser: MyRouteInformationParser(),
routerDelegate: MyRouterDelegate()
);
}

RouterDelegate

Primarily responsible for maintaining stateful information on what should be present on the navigation stack. It is a widget that builds out the stack itself, and as such is also responsible for tracking which items are on the stack.

In the following basic example, a single class member bShowDetails is used to determined if DetailsPage should be added to the stack. If this state changes, the pages on the stack change.

final navigatorKey = GlobalKey<NavigatorState>();
bool bShowDetails;
String currentDetailsId;
...
Widget build(BuildContext context) {
return Navigator(
key: navigatorKey,
pages: [
LanderPage(),
if (bShowDetails)
DetailsPage(id: currentDetailsId),
],
onPopPage: (route, result) {
// callback when a page is popped.
...
}
);
}

The RouterDelegate implementation extends a number of classes and mixins, that rely on a user defined Type. This Type is used to provide information about any potential Path.

class AppRouterDelegate extends RouterDelegate<RoutePath>
with ChangeNotifier, PopNavigatorRouterDelegateMixin<RoutePath> {
@override
final navigatorKey = GlobalKey<NavigatorState>();
@override
Widget build(BuildContext context) {
...
}
@override
Future<void> setNewRoutePath(RoutePath configuration) {
...
}

In the code above, the custom defined Type is RoutePath. It is a class implementation that contains any information that would be useful in determining which Page should be pushed onto the stack.

Here is an example of RoutePath with definitions that would be useful in describing LanderPage and DetailsPage, but don’t specifically create the Pages directly, just the data to describe them (such as an ID).

class RoutePath {
final String id;
final String argument;
RoutePath.lander()
: id = 'lander',
argument = null;
RoutePath.details(String itemId)
: id = 'details',
argument = itemId;
bool get isLanderPage => id == 'lander';
bool get isDetailsPage => id == 'details';
}

The above data structures can be used to describe LanderPage and DetailsPage when sending information around.

The remaining method that has not been overridden yet, will make a bit more sense with the above Data Structure. Following the same basic example above, where a boolean flag determines if the details page is to be shown, this is where such a state change could occur on application launch.

@override
Future<void> setNewRoutePath(RoutePath configuration) {
// receive configuration data that is desired to be
// displayed. This information comes from the OS and is typically
// presented on application launch.

if (configuration.isDetailsPage()) {
bShowDetails = true;
currentDetailsId = configuration.argument;
} else {
currentDetailsId = "";
bShowDetails = false;
}
// trigger a re-draw of the RouterDelegate
// by notifying of the change
notifyListeners();
}

In the above situation the Router Delegate Widget implementation will re-draw when listeners are notified and add the DetailsPage to the Navigator stack with appropriate data.

RouteInformationParser

Responsible for receiving routing events from the OS. Typically this would involve handling the path the application was launched with (such as with deep linking). It receives the path URI, and uses that to return information to the RouterDelegate so it can determine how to handle the launch data.

To keep things simple, this example will assume regardless of how the application launches, it will also default to the LanderPage as the intended target screen.

class MyRouteInformationParser extends 
RouteInformationParser<RoutePath> {
@override
Future<RoutePath> parseRouteInformation(
RouteInformation routeInformation) async {
return RoutePath.lander();
}
}

The result of this call above is the Router Delegate is provided the information in SetNewRoutePath.

Navigator

As seen above, the Navigator is a simple to use flutter widget that renders out the actual stack. It takes in a collection of Pages (Widgets) that are rendered in order that they are received. [LanderPage(), DetailsPage()] would render the DetailsScreen on-top of the LanderPage.

Page

A Page is a widget that is provided to the Navigator widget. It can be defined inline using MaterialPage such as

pages: [ MaterialPage(
key: someKey,
child: Scaffold(
appBar: AppBar(...),
body: Center(...),
)
)
]

or by extending the class and implementing it as a Page or MaterialPage directly, or by some combination of the above depending on how you want to share widgets in your code and what makes sense to you.

PathStack

So far, the stack has been determined by a simple variable to determine if a details page should be pushed on it along with the appropriate ID. This is not exactly scalable, and we also have a data structure that encapsulates this data in a much friendlier manner, RoutePath.

Since we know that this Router Delegate is returning a Navigator which is a traditional stack, it makes sense to store the information on which screens are present on the stack in a collection that can be referenced by the Router Delegate when it’s time to re-draw.

This can be constructed as a simple class (that notifies of changes) that stores information in a List, and provides abilities to push, pop, and reset. This implementation should not contain any app specific business logic or be aware of screens, but simply exist as a structure to hold the information used elsewhere.

import 'dart:collection';
import 'package:flutter/material.dart';
import 'route_path.dart';
class PathStack with ChangeNotifier { // define root of stack which is always present
RoutePath _rootPath;
// store all the paths in your stack to be rendered out
List<RoutePath> _paths;
// accept root in stack creation
PathStack({RoutePath root}) {
_rootPath = root;
_paths = [_rootPath];
}
UnmodifiableListView<RoutePath> get items =>
UnmodifiableListView(_paths);
void push(RoutePath path) {

// prevent root pushing on root
if (path.id == _rootPath.id) {
_paths = [_rootPath];
notifyListeners();
}
// add route path to our navigation stack
_paths.add(path);
// notify listeners that change has occurred
notifyListeners();
}
RoutePath pop() { try {
final poppedItem = _paths.removeLast();
notifyListeners();
return poppedItem;
} catch (e) {
print(e);
return null;
}
}
// reset the stack to be just to root path
void reset() {
_paths = [_rootPath];
}
}

The above should probably be templated out to accept any type of configuration data, one that matches the Type defined for the Router Delegate itself, but for the purposes of this demonstration the above is fine.

You may notice that the PathStack implements as a ChangeNotifier and that listeners are notified whenever something changes. The owner of this implementation will be the Router Delegate, which will use the data structure as stateful data to determine which pages should be shown on the stack. If you recall, when stateful data changed above (bShowDetails), the Router Delegate was required to notify to listeners before it would re-draw and provide new information to Navigator. This can be bypassed by bubbling up the notification from the PathStack directly, so any changes to PathStack will immediately cause the Router Delegate to re-draw itself with new potential information.

// data structure containing information on pages to be shown
final PathStack stack = PathStack(root: RoutePath.lander());
AppRouterDelegate({Key key}) : super() {
// bubble up any notifications from PathStack
stack.addListener(notifyListeners);
}

Populating Navigator from PathStack

In our basic example at the beginning, we had a rudimentary check to see if we should display the DetailsPage or not based on a boolean state. Now that there is a stateful data structure, PathStack, that contains the information needed to populate our Navigator, we can map through that to determine what pages should be shown.

final navigatorKey = GlobalKey<NavigatorState>();
PathStack stack = PathStack(root: RoutePath.lander());
...
Widget build(BuildContext context) {
return Navigator(
key: navigatorKey,
pages: stack.items.map((e) {
// unique key determine if page is re-drawn
ValueKey key = ValueKey('${e.id}-${e.argument}');
if (e.isLanderPage)
return LanderPage();
if (e.isDetailsPage)
return DetailsPage(id: e.argument);
}).toList(),
onPopPage: (route, result) {
// callback when a page is popped.
...
}
);
}

Updating PathStack from Navigator Events

A callback noted above in Navigator is onPopPage. This is a callback that occurs whenever the OS pops a page from the stack by using the Soft or Hard Back Button. Since this is an action that occurs in the Navigator directly, we need to react to this and update our PathStack accordingly.

It is important to note that this is where you can also define the logic of what to do when the very last page in your stack is popped. The return of this callback indicates if you would like to consume the event (stop it from going further) or allow the event to pass to the operating system, in which case it would simply close the application.

...
onPopPage: (route, result) {
// let the OS handle the back press if there was nothing to pop
if (!route.didPop(result)) return false;
// if we are on root, let OS close app
if (stack.items.length == 1) return false;
// otherwise, pop the stack and consume the event
stack.pop();
return true;
},
...

Navigating Within the App

At this point the application has the correct components to display a stack of Pages, including a data structure to store the current state of the stack and handle changes, but there is still no mechanism for any of the child pages to actually push() or pop() from the stack.

Creating a Navigation Manager

A simple interface is what needs to be created in order to receive intent from throughout the application.

class NavigationManager {
void push(RoutePath configuration) {}
void pop() {}
void reset() {}
}

This class alone will not do much, because even if you exposed it to the pages in the Navigator some way, it has no contextual information on how to pass the intent to the Router Delegate. To solve this problem, some delegate should be provided to receive the information collected by the above interface.

class NavigationManagerDelegate {
Function(RoutePath) _onPush;
Function _onPop;
Function _onReset;
// setters to allow override of callbacks
set onPush(Function(RoutePath) callback) => _onPush = callback;
set onPop(Function callback) => _onPop = callback;
set onReset(Function callback) => _onReset = callback;
void push(RoutePath path) {
if (_onPush != null) _onPush(path);
}
void pop() {
if (_onPop != null) _onPop();
}
void reset() {
if (_onReset != null) _onReset();
}
}

A slight modification to the NavigationManager to accept the NavigationManagerDelegate.

class NavigationManager {
NavigationManagerDelegate _delegate;
NavigationManager({NavigationManagerDelegate delegate}) {
this._delegate = delegate;
}
void push(RoutePath path) {
if (_delegate != null) _delegate.push(path);
}
void pop() {
if (_delegate != null) _delegate.pop();
}
void reset() {
if (_delegate != null) _delegate.reset();
}
}

This now allows us to define handlers inside the Router Delegate to handle the intentions of the application.

final PathStack stack = PathStack(root: RoutePath.lander());// instantiate our delegate
NavigationManagerDelegate _navigationManagerDelegate =
NavigationManagerDelegate();
// create navigation manager with supplied delegate
NavigationManager _navManager =
NavigationManager(delegate: _navigationManagerDelegate);
AppRouterDelegate({Key key}) : super() {

// define inline handlers for navigation manager
_navigationManagerDelegate.onPush = (RoutePath path) {
stack.push(path);
};
_navigationManagerDelegate.onPop = () {
stack.pop();
};
_navigationManagerDelegate.onReset = () {
stack.reset();
};
// bubble up any notifications from PathStack
stack.addListener(notifyListeners);
}

Providing a Mechanism for Pages to Navigate

The last item to cover before the application has a mechanism to push and pop screens in this example, is to provide a way for the individual pages of the Navigator to fire intentions for new screens. This will be achieved by exposing the NavigationManager instance to all child pages of the Navigator with a Provider.

@override
Widget build(BuildContext context) {
return Provider(
create: (_) => NavigationManager(
delegate: _navigationManagerDelegate),

child: Navigator(
key: navigatorKey,
pages: stack.items.map((e) {
// unique key determine if page is re-drawn
ValueKey key = ValueKey('${e.id}-${e.argument}');
if (e.isLanderPage)
return LanderPage();
if (e.isDetailsPage)
return DetailsPage(id: e.argument);
}).toList(),
onPopPage: (route, result) {
// nothing to pop
if (!route.didPop(result)) return false;
// if we are on root, let OS close app
if (stack.items.length == 1) return false;
// otherwise, pop the stack and consume the event
stack.pop();
return true;
},
));
}

All subsequent child pages of the Navigator can now access the instance of the NavigationManager through the Provider.

void navigateToDetails(String id) {
Provider.of<NavigationManager>(
context,
listen: false
).push(RoutePath.details(id));

}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Lander Screen'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
ElevatedButton(
onPressed: () => navigateToDetails('9999'),
child: Text('Launch Details Screen')),
],
),
),
);
}

I hope the above demonstration is clear, as I personally found the documentation and reference material on Navigation 2.0 a bit all over the place. My intent was to demonstrate a few key concepts that I hope were understood:

  • The Router Delegate is responsible for providing pages to the Navigator
  • The Router Delegate maintains stateful data to know which pages to provide
  • A Data Structure can be used to store the stateful data and keep it neat and tidy
  • A nice pattern of using an interface with a delegate (NavigationManager) can be provided to child pages by way of a Provider in order to receive intentions from the user at the Router Delegate, where it can act on it.

Please leave a comment below if you feel there are errors or ways this could be made better.

--

--