Flutter Navigator 2.0 for mobile dev: Nested navigators basics
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:
- Flutter Navigator 2.0: 101 for mobile dev: Were to start if you have no idea what navigator 2.0 is.
- Flutter Navigator 2.0 for mobile dev: Transitions: How to implement Hero or Page route transitions with navigator 2.0
- Flutter Navigator 2.0 for mobile dev: Bloc state management integration: How to use navigator 2.0 and the bloc library
- Flutter Navigator 2.0 for web dev: Url handling: The basics of URL handling.
- Flutter web: URL handling made simple with simple_url_handler: How to use the simple_url_handler package to easily manage the url in flutter web.
- Flutter web: A complete example using simple_url_handler: A complete example, using everything seen above, to build the routing and url handling of a more complete app.
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
BackButton
of theAppBar
for example
And two fix to put in place to fix animation:
GlobalKey
for the navigators, for page route transitionHeroController()
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:
- Handling a pop event
- 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 Naviator
via 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:
- The root
Router
creates aRootBackButtonDispatcher
- Any nested
Rooter
who want to get the event create aChildBackButtonDispatcher
which should calltakePriority()
. ThisChildBackButtonDispatcher
should takeRouter.of(context).backButtonDispatcher
as theparent
argument.
Then when an event if fired:
- The
popRoute
method of theRouteDelegate
associated with the most nestedChildBackButtonDispatcher
which calledtakePriority()
is called - If this method return
true
, end. Else call thepopRoute
method of theRouteDelegate
of theparent
argument of theChildBackButtonDispatcher
. - Repeat 2 until
true
or at theRouteDelegate
corresponding to theRootBackButtonDispatcher
.
Important note: If even the popRoute
method 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:
- Handle a pop event called by the BackButton of the AppBar
- 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, ChildBackButtonDispacher
which 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);
},
)
],
),
);
}
}