I have written an updated post about bottom navigation architecture for Flutter that I use. You can read the post here.
Using BottomNavigationBar
1 is unreasonably cumbersome in Flutter. Especially when you need to handle own Navigator
stack for each tab. Or want the Android back button to—uhm—go back instead of closing the whole app.
I would like to present you a working navigation architecture which uses BottomNavigationBar
, allows separate Navigator
2 for each tab and lets you customize Android’s back button behavior. And, as a cherry on top, you can easily implement iOS style scroll to top action when tapping on the current tab in your bottom bar. Put your seatbelts on and get ready.
Architecture overview
We will create a stateless widget Root
(with route name /
) which should serve as a single entry point for our app. Root
is stateless because all navigation state management will be moved into our custom Provider
(see below) that will control Root
widget.
Moving state management into Provider
3 class is important for easy tab switching from inner screens. Also, it looks much cleaner and you can brag how you separate your concerns.
Individual screens will have own Scaffold
and can be either stateless or stateful, depending on your needs. Each screen is going to be wrapped in its own Navigator
.
File tree for quick reference:
lib
├── main.dart
├── models
│ └── screen.dart
├── providers
│ └── navigation_provider.dart
├── screens
│ ├── first_screen.dart
│ ├── pushed_screen.dart
│ ├── root.dart
│ ├── second_screen.dart
│ └── third_screen.dart
└── widgets
└── exit_dialog.dart
Navigation logic
Before creating our core component, NavigationProvider
, we need to create a data class to hold all information about the given screen. Create file screen.dart
and put Screen
definition inside.
Screen model
/// [Screen] holds all information required to build screen.
class Screen {
/// String title used inside bottom navigation bar items
final String title;
/// Screen content
final Widget child;
/// Route generator for this screen's inner [Navigator]
final RouteFactory onGenerateRoute;
/// Initial route needs to be handled in [onGenerateRoute] implementation
final String initialRoute;
/// Navigator state for this screen
final GlobalKey<NavigatorState> navigatorState;
/// (Optional) Scroll controller for manipulating scroll views from
/// [NavigationProvider].
final ScrollController scrollController;
Screen({
@required this.title,
@required this.child,
@required this.onGenerateRoute,
@required this.initialRoute,
@required this.navigatorState,
this.scrollController,
});
}
The important part here is child
which holds the screen widget (i.e. content inside Scaffold
) and navigatorState
. Using GlobalKey
4 allows us to reference navigator state from different contexts.
I found that this way of encapsulating all parts together makes working with multiple tabs significantly easier.
Screen implementations
Create a file navigation_provider.dart
and put all the following code there.
Each screen needs to be referenced by int
index. Define following constants at the top of navigation_provider.dart
file. You could also use static
constants and place them inside NavigationProvider
class. Its up to you. This is the most concise way I found.
const FIRST_SCREEN = 0;
const SECOND_SCREEN = 1;
const THIRD_SCREEN = 2;
Now we need to create Screen
instance for every screen. I created three screens for this example: FirstScreen
, SecondScreen
and—what a surprise—ThirdScreen
. One more screen is required for examples of screen pushing. Lets call (creatively) it PushedScreen
.
first_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_bottom_navigation/screens/pushed_screen.dart';
class FirstScreen extends StatelessWidget {
static const route = '/first';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('First Screen')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
RaisedButton(
onPressed: () {
Navigator.of(context, rootNavigator: false).pushNamed(PushedScreen.route);
},
child: Text('Push route with bottom bar'),
),
RaisedButton(
onPressed: () {
Navigator.of(context, rootNavigator: true).pushNamed(PushedScreen.route);
},
child: Text('Push route without bottom bar'),
),
],
),
),
);
}
}
second_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_bottom_navigation/navigation_provider.dart';
class SecondScreen extends StatelessWidget {
static const route = '/second';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Second Screen')),
body: ListView.builder(
controller: NavigationProvider.of(context)
.screens[SECOND_SCREEN]
.scrollController,
itemBuilder: (context, index) {
return ListTile(
title: Text('Tile $index'),
);
},
),
);
}
}
third_screen.dart
import 'package:flutter/material.dart';
class ThirdScreen extends StatelessWidget {
static const route = '/third';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Third Screen')),
);
}
}
pushed_screen.dart
import 'package:flutter/material.dart';
class PushedScreen extends StatelessWidget {
static const route = '/first/pushed';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Pushed screen')),
body: Center(
child: Text('Hello world!'),
),
);
}
}
PushedScreen
does not have its own constant because it will not be shown inside bottom navigation tab view.
Navigation Provider
class NavigationProvider extends ChangeNotifier {
int _currentScreenIndex = FIRST_SCREEN;
int get currentTabIndex => _currentScreenIndex;
final Map<int, Screen> _screens = {
FIRST_SCREEN: Screen(...),
SECOND_SCREEN: Screen(...),
THIRD_SCREEN: Screen(...),
};
List<Screen> get screens => _screens.values.toList();
Screen get currentScreen => _screens[_currentScreenIndex];
// ...
}
NavigationProvider
needs to hold currentTabIndex
and all screens. Nice way to store screens is using Map<int, Screen>
where int
is the constant we defined earlier. Expand following source code to view every detail.
_screens content
class NavigationProvider extends ChangeNotifier {
// ...
final Map<int, Screen> _screens = {
FIRST_SCREEN: Screen(
title: 'First',
child: FirstScreen(),
initialRoute: FirstScreen.route,
navigatorState: GlobalKey<NavigatorState>(),
onGenerateRoute: (settings) {
switch (settings.name) {
case PushedScreen.route:
return MaterialPageRoute(builder: (_) => PushedScreen());
default:
return MaterialPageRoute(builder: (_) => FirstScreen());
}
},
scrollController: ScrollController(),
),
SECOND_SCREEN: Screen(
title: 'Second',
child: SecondScreen(),
initialRoute: SecondScreen.route,
navigatorState: GlobalKey<NavigatorState>(),
onGenerateRoute: (settings) {
switch (settings.name) {
default:
return MaterialPageRoute(builder: (_) => SecondScreen());
}
},
scrollController: ScrollController(),
),
THIRD_SCREEN: Screen(
title: 'Third',
child: ThirdScreen(),
initialRoute: ThirdScreen.route,
navigatorState: GlobalKey<NavigatorState>(),
onGenerateRoute: (settings) {
switch (settings.name) {
default:
return MaterialPageRoute(builder: (_) => ThirdScreen());
}
},
scrollController: ScrollController(),
),
};
// ...
}
Now for the interesting part: changing tabs. We need to create a public method setTab(int tab)
. In this method we check if new tab
is same as currentTabIndex
. If they differ, we change current tab index and notify all listeners (so they rebuild).
class NavigationProvider extends ChangeNotifier {
// ...
void setTab(int tab) {
if (tab == currentTabIndex) {
// TODO: Scroll to start.
} else {
_currentScreenIndex = tab;
notifyListeners();
}
}
// ...
}
Provider Shortcut
When you need to access Provider
widget in app, typing Provider.of<NavigationProvider>(context, listen: false)
gets old pretty quickly. We can make this tedious process slightly easier to use.
class NavigationProvider extends ChangeNotifier {
// ...
static NavigationProvider of(BuildContext context) =>
Provider.of<NavigationProvider>(context, listen: false);
// ...
}
Now you can get NavigationProvider
just by using NavigationProvider.of(context)
. Same syntax is used for Theme
s or other InheritedWidget
s.
Root Widget
Root
should be very simple now thanks to our provider. Wrap everything in Consumer<NavigationProvider>
to listen for changes.
class Root extends StatelessWidget {
static const route = '/';
@override
Widget build(BuildContext context) {
return Consumer<NavigationProvider>(
builder: (context, provider, child) {
final bottomNavigationBarItems = provider.screens
.map((screen) => BottomNavigationBarItem(
icon: Icon(Icons.home), title: Text(screen.title)))
.toList();
final screens = provider.screens
.map(
(screen) => Offstage(
offstage: screen != provider.currentScreen,
child: Navigator(
key: screen.navigatorState,
onGenerateRoute: screen.onGenerateRoute,
),
),
)
.toList();
return WillPopScope(
onWillPop: provider.onWillPop,
child: Scaffold(
body: IndexedStack(
children: screens,
index: provider.currentTabIndex,
),
bottomNavigationBar: BottomNavigationBar(
items: bottomNavigationBarItems,
currentIndex: provider.currentTabIndex,
onTap: provider.setTab,
),
),
);
},
);
}
}
There are two important parts: WillPopScope
and IndexedStack
.
IndexedStack
5 takes a list of widgets and holds all their states. You can switch which widget is currently visible by index
argument. This comes handy because we don’t want to loose state of tabs that are not currently visible (for example scroll position).
WillPopScope
6 is capable of catching Android back button touch. It takes a callback that returns a boolean depending on if we want system to handle back button or will handle it by ourselves.
As you can see we have provided onWillPop
callback as provider.onWillPop
. So lets implement one. Add following callback method to your NavigationProvider
and keep it all together.
class NavigationProvider extends ChangeNotifier {
// ...
Future<bool> onWillPop() async {
final currentNavigatorState = currentScreen.navigatorState.currentState;
if (currentNavigatorState.canPop()) {
currentNavigatorState.pop();
return false;
} else {
if (currentTabIndex != FIRST_SCREEN) {
setTab(FIRST_SCREEN);
notifyListeners();
return false;
} else {
return true;
}
}
}
// ...
}
We first check whether it’s possible to pop current route. This will return true in case there is a route we pushed manually. In this case we want to pop the route to return to Root
widget and prevent back button from exiting the app, hence returning false
.
In case there is no route to pop, we are already in Root
. You could return true
here to close the app when using back button on every tab. But I prefer different behavior. Closing app should be possible only from first tab. So, per our implementation, we will exit app only when there is no route available to pop and current tab is FIRST_SCREEN
.
Routes
Route handling is done inside route factories. When you push named route, Navigator
will find its closest instance and invoke onGenerateRoute(RouteSettings settings)
method.
We currently have 3 Navigator
s. One implicit in MaterialApp
, to which we provide provider.onGenerateRoute
callback below, and 3 inner Navigator
s (one for each tab).
Route generators for inner Navigator
can be found above in _screens
content.
class NavigationProvider extends ChangeNotifier {
// ...
Route<dynamic> onGenerateRoute(RouteSettings settings) {
print('Generating route: ${settings.name}');
switch (settings.name) {
case PushedScreen.route:
return MaterialPageRoute(builder: (_) => PushedScreen());
default:
return MaterialPageRoute(builder: (_) => Root());
}
}
/...
}
When you want to push a route simply call Navigator.push()
. This will push new route while keeping bottom navigation bar visible.
To push a route without bottom navigation bar, you must specify which Navigator
instance to use. In this case we need to use root Navigator
, that is the one in MaterialApp
.
// Push route without bottom navigation bar
Navigator.of(context, rootNavigator: true).pushNamed(PushedScreen.route);
You can also push non-named routes just as easily. However, make sure that initial screen must be handled inside nested Navigator
for bottom navigation to work properly.
Main widget
We need to provide NavigationProvide
to our app. Without using Builder
here we wouldn’t be able to access NavigationProvider.of(context)
right inside MultiProvider
widget. Remember that current context
symbolizes widget above in the tree.
void main() => runApp(App());
class App extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => NavigationProvider()),
],
child: Builder(
builder: (context) {
return MaterialApp(
onGenerateRoute: NavigationProvider.of(context).onGenerateRoute,
);
},
),
);
}
}
Switching tabs
Pushing tabs was already discussed above. What if you need to switch tabs and keep current state? For example you might want to have a button on FirstScreen
which open SecondScreen
. Pushing SecondScreen
using Navigator
would immediately break things and Flutter would gladly tell you that with a lot of error logs.
What we want to do is access our NavigationProvider
from the pushed screen and call setTab()
. Without using Provider
this would be harder. You would probably have to pass a callback inside every screen. But using this architecture, we can use of()
shortcut we implemented earlier.
NavigationProvider.of(context).setTab(SECOND_SCREEN);
Scroll to top action
You might want to scroll your list view to the top when tapping on the same bottom navigation item twice. Screen
class already contains a attribute scrollController
. So all we need to do is provide this controller into screen widget and then invoke methods on it inside NavigationProvider
.
Make sure you pass the correct scroll controller (SECOND_SCREEN
in this example).
ListView.builder(
controller: NavigationProvider.of(context)
.screens[SECOND_SCREEN]
.scrollController,
itemBuilder: (context, index) {
return ListTile(
title: Text('Tile $index'),
);
},
),
Then create scrollToStart()
method inside our provider. Remember to check that current screen has its own ScrollController
to prevent null pointer exceptions.
class NavigationProvider extends ChangeNotifier {
// ...
void _scrollToStart() {
if (currentScreen.scrollController != null) {
currentScreen.scrollController.animateTo(
0,
duration: const Duration(seconds: 1),
curve: Curves.easeInOut,
);
}
}
// ...
}
Now we need to call it inside setTab()
method. You don’t need to call notifyListeners()
here because the controller will handle UI updates by itself.
//...
void setTab(int tab) {
if (tab == currentTabIndex) {
_scrollToStart();
} else {
_currentScreenIndex = tab;
notifyListeners();
}
}
//...
Animated tab transition
Currently, when you change tab it instantly rebuilds. We can use Flutter’s AnimatedSwitcher
7 widget to add fade transition between screens. However, AnimatedSwitcher
will not keep state of all children the way IndexedStack
does.
Check this gist wit code describing how to create animated indexed stack.
Exit confirmation dialog
This is little tricky. To show AlertDialog
we need to provide current context. And because onWillPop()
resides withing NavigationProvider
it does not have any. We can use a little hack.
First, let create ExitAlertDialog
widget.
class ExitAlertDialog extends StatelessWidget {
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('Exit?'),
actions: <Widget>[
FlatButton(
onPressed: () {
Navigator.of(context).pop(false);
},
child: Text(
'Cancel',
style: Theme.of(context).textTheme.button.copyWith(
fontWeight: FontWeight.normal,
),
),
),
FlatButton(
onPressed: () {
Navigator.of(context).pop(true);
},
child: Text('Exit'),
),
],
);
}
}
Now we need to change onWillPop
callback. So far this this callback has no arguments. We need to change its signature and pass BuildContext
inside it. We can then show dialog with this context.
class NavigationProvider extends ChangeNotifier {
// ...
Future<bool> onWillPop(BuildContext context) async {
final currentNavigatorState = currentScreen.navigatorState.currentState;
if (currentNavigatorState.canPop()) {
// ...
} else {
if (currentTabIndex != FIRST_SCREEN) {
// ...
} else {
return await showDialog(
context: context,
builder: (context) => ExitAlertDialog(),
);
return false;
}
}
}
// ...
}
ExitAlertDialog
will return boolean value when popped (i.e. when user clicks on either Cancel or Exit). So simply await pop result and return it.
However, now you cannot pass onWillPop
into WillPopScope
as it has different signature. You can fix this by using anonymous function which invokes our custom callback inside Root
widget.
return WillPopScope(
onWillPop: () => provider.onWillPop(context),
child: Scaffold(...),
);
TickerMode
Using Offstage
widget will build widget without actually painting it. However, ongoing animations will not be paused and widgets put offstage will still be rebuild.
To prevent this you can use TickerMode
8 which pauses every AnimationController
inside its child widget tree. Inside your root widget wrap Offstage
into TickerMode
.
final screens = provider.screens
.map(
(screen) => TickerMode(
enabled: screen == provider.currentScreen,
child: Offstage(
offstage: screen != provider.currentScreen,
child: Navigator(
key: screen.navigatorState,
onGenerateRoute: screen.onGenerateRoute,
),
),
),
)
.toList();