Building a Simple Stack Navigator with Flutter Navigation 2.0

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.

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.
...
}
);
}
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) {
...
}
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';
}
@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();
}

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.

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

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(...),
)
)
]

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.

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];
}
}
// 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.

...
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() {}
}
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();
}
}
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();
}
}
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;
},
));
}
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')),
],
),
),
);
}
  • 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.

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store