Flutter Navigator 2.0 for mobile dev: Nested navigators basics

Lulupointu
7 min readDec 20, 2020

--

This article is part of a series aiming at making you a master of Navigator 2.0 while showing you that it is not as hard as people think. The other articles of this series are:

This article assumes that you know the basics of navigator 2.0, if you don’t, check out my other article: Navigator 2.0 for mobile dev: 101.

We are going to built on the basics of navigator and try a feature which was greatly improve in 2.0: nested navigator. Having nested navigator has some major advantages:

  • It is easier to handle complex app architecture
  • It allows you to only redraw the part of the screen you need. It can for example be used to have the same bottom navigation bar for two screens. Having only one instance that does not rebuild is especially useful when the bottom navigation bar is animated for example.

And two major question come to mind when handling nested navigators:

  • How to handle the android back button
  • How to handle a pop event (which can be triggered by the BackButtonof the AppBar for example

And two fix to put in place to fix animation:

  • GlobalKey for the navigators, for page route transition
  • HeroController() as navigator observer, for hero animation

Note 1: This article is for mobile dev and try to be as simple as possible so it won’t handle web features such as navigation URL.

Note 2: This is a guide of basic nesting guide so we won’t look into features such as BackButtonDispatchers.

The example app:

You can find the code at the end of the article

Our App layout:

The important thing to understand is that nesting navigator is pretty simple, just nest them.

As you see, nothing complicated. One navigator handles the outer part while the nested one handles the part inside the scaffold.

What this article is really about is the complications which comes from having nested navigators. And there are two:

  1. Handling a pop event
  2. Handling the android back Button

Handling a pop event:

This can be caused by the AppBar BackButton, since it calls Navigator.maybePop(context)by default.

This event is handled by the Naviatorvia the onPopPage method. The important thing to understand is that this event is local. This means that it will only call the navigator which we call it on.

You should be on the page and watch your console:

The two different behaviors are :

Clicking the custom red BackButton calls the NestedRouterDelegate Navigator onPopPage since it is the one in the context.

Clicking the scaffold BackButton calls the Authentication RouterDelegate Navigator onPopPage since it is the one in the context of the scaffold.

If you don’t understand the code in this method checkout my article Navigator 2.0 for mobile dev: 101

Note that even if we returned false in the custom red BackButton onPopPage (meaning that we don’t handle the pop event), the event does not propagate to the other navigator. This action is local.

If you are not in the right context and you want to call the onPop method of a given navigator you should get the right navigator key and then call navigatorKey.currentState.pop(). But note that this should not be the way to go since it is an imperative way of doing things and Navigator 2.0 is all about being declarative.

Handling the android back button:

This event is handled completely differently and is global.

With each Router you can get the event, the way it works is:

  1. The root Router creates a RootBackButtonDispatcher
  2. Any nested Rooter who want to get the event create a ChildBackButtonDispatcher which should call takePriority() . This ChildBackButtonDispatcher should take Router.of(context).backButtonDispatcher as the parent argument.

Then when an event if fired:

  1. The popRoute method of the RouteDelegate associated with the most nested ChildBackButtonDispatcher which called takePriority() is called
  2. If this method return true, end. Else call the popRoute method of the RouteDelegate of the parent argument of the ChildBackButtonDispatcher.
  3. Repeat 2 until true or at the RouteDelegate corresponding to the RootBackButtonDispatcher.

Important note: If even the popRoutemethod of the RouteDelegate corresponding to the RootBackButtonDispatcher does not return true, then the event is handle by the system and closes the app causing it to crash. To avoid this behavior, I would advice you to use the move_to_background library and call MoveToBackground.moveTaskToBack() before returning true. This will put the application in the background gracefully.

Fixing animation problems:

Nested navigator break 2 things: PageRouteTransitions and HeroAnimations. Hopefully this will be fix in the future, however in the meantime, luckily for us, there is a fix for each of those.

PageRouteTransitions

The issue here occures when the key given to the Navigator is rebuilt. The fix here is to create them as global variable (or equivalent, depending on your state managment choice):

HeroAnimations

The issue here has to do with hero controller, though I can’t really explain what is going on, the fix is to give a new HeroController to the Navigator observers attribute:

TL;DR:

Nesting navigator is pretty simple, just nest them. The only issue come from:

  1. Handle a pop event called by the BackButton of the AppBar
  2. Handle Android back button

Handle a pop event a local event and calls the onPopPage method of the closest navigator in the context.

Handle Android back button is a global event, handled with a stack. The element are inserted in the stack by passing BackButtonDispacher to your routers [RootBackButtonDispacher for the root navigator, ChildBackButtonDispacherwhich call takePriority for the others]. At each event, the popRoute method of the RouteDelegate associated lest element in the stack is called. If this method returns true we stop, else we call the previous element in the stack and so on.

Animations get broken, here are the two fix:

  • Fix PageRouteTransition: use a global variable to store your nested navigator key
  • Fix HeroAnimation: use observers=[HeroController()] is you nested navigator

The example code

Not prettified, just for easy copy paste:

import 'package:flutter/material.dart';
import 'package:move_to_background/move_to_background.dart';

void main() {
runApp(MyApp());
}

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Router(
routerDelegate: AuthenticationRouterDelegate(),
backButtonDispatcher: RootBackButtonDispatcher(),
),
);
}
}

class AuthenticationRouterDelegate extends RouterDelegate with ChangeNotifier {
bool isAuthenticated = false;
final GlobalKey<NavigatorState> navigatorKey;

AuthenticationRouterDelegate() : navigatorKey = GlobalKey<NavigatorState>();

@override
Future<bool> popRoute() async {
print('popRoute AuthenticationRouterDelegate');
MoveToBackground.moveTaskToBack();
return true;
}

@override
Widget build(BuildContext context) {
return Navigator(
key: navigatorKey,
observers: [HeroController()],
pages: [
MaterialPage(
key: ValueKey('MyHomePage'),
child: MyAuthenticationWidget(
onPressed: () {
isAuthenticated = true;
notifyListeners();
},
),
),
if (isAuthenticated)
MaterialPage(
key: ValueKey('NestedNavigatorPage'),
child: NestedRouterWidget(),
),
],
onPopPage: (route, result) {
print('onPopPage AuthenticationRouterDelegate');
if (!route.didPop(result)) return false;

isAuthenticated = false;
notifyListeners();
return true;
},
);
}

// We don't use named navigation so we don't use this
@override
Future<void> setNewRoutePath(configuration) async => null;
}

class MyAuthenticationWidget extends StatelessWidget {
final VoidCallback onPressed;

MyAuthenticationWidget({@required this.onPressed}) : super();

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('You are connected'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
TextButton(
child: Container(
padding: EdgeInsets.all(8.0),
color: Colors.greenAccent,
child: Text('Click me to connect.'),
),
onPressed: onPressed,
)
],
),
),
);
}
}

class NestedRouterWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final childBackButtonDispatcher =
ChildBackButtonDispatcher(Router.of(context).backButtonDispatcher);
childBackButtonDispatcher.takePriority();
return Router(
routerDelegate: NestedRouterDelegate(),
backButtonDispatcher: childBackButtonDispatcher,
);
}
}

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

class NestedRouterDelegate extends RouterDelegate with ChangeNotifier {
int selectedIndex = 0;

@override
Future<bool> popRoute() async {
print('popRoute NestedRouterDelegate');
return false;
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('You are connected'),
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: selectedIndex,
items: [
BottomNavigationBarItem(icon: Icon(Icons.person_outline), label: 'Profile'),
BottomNavigationBarItem(icon: Icon(Icons.settings_outlined), label: 'Settings'),
],
onTap: onNewIndexSelected,
),
body: Navigator(
key: _nestedNavigatorKey,
observers: [HeroController()],
pages: [
if (selectedIndex == 0)
MaterialPage(
key: ValueKey('ProfilePage'),
child: ProfileWidget(
onPressed: () {},
),
),
if (selectedIndex == 1)
MaterialPage(
key: ValueKey('NestedNavigatorPage'),
child: SettingWidget(),
),
],
onPopPage: (route, result) {
print('onPopPage NestedRouterDelegate');
return false;
},
),
);
}

// We don't use named navigation so we don't use this
@override
Future<void> setNewRoutePath(configuration) async => null;

void onNewIndexSelected(int value) {
selectedIndex = value;
notifyListeners();
}
}

class ProfileWidget extends StatelessWidget {
final VoidCallback onPressed;

ProfileWidget({@required this.onPressed}) : super();

@override
Widget build(BuildContext context) {
return Center(
child: Text('Your profile'),
);
}
}

class SettingWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Your settings'),
TextButton(
child: Container(color: Colors.redAccent, child: Icon(Icons.arrow_back_ios)),
onPressed: () {
return Navigator.pop(context);
},
)
],
),
);
}
}

--

--