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.
You can get the full source code on my GitHub repository.
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
.
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'),
),
],
),
),
);
}
}
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'),
);
},
),
);
}
}
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')),
);
}
}
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.
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();
Passing data between tabs
Few people have reached out to me asking how to share data between individual tabs. I've added this chapter to address this issue.
First of all: do not pass data through NavigationProvider
.
NavigationProvider
is strictly for handling which tab is currently active and
visible to the user. You can notice that the data of NavigationProvider
is
available in all tabs.
Therefore, you can create another providers that will be placed next to your
NavigationProvider
and their data will be accessible from within your tabs.
For example, your app allows users to login. You have User
class that holds
info about the user.
class User {
User({@required this.name}) : assert(name != null);
final String name;
}
You need to share instance of the User
class between tabs. Possibly, you want
to display name of the user both on first tab and last tab. Similarly to the way
NavigationProvider
holds the current tab index, UserProvider
will hold the
User
value you can access from within the app.
class UserProvider extends ChangeNotifier {
User user;
bool get isLogged => user != null;
void login() {
user = User(name: 'John Doe');
notifyListeners();
}
void logout() {
user = null;
notifyListeners();
}
}
UserProvider
has user
field that is null when no user is logged in and
User
instance otherwise. This is overly simplified example, you will need to
customize it to your needs. Provide the UserProvider
inside your App
widget.
class App extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => NavigationProvider()),
ChangeNotifierProvider(create: (_) => UserProvider()),
],
// ...
);
}
}
You can now access the UserProvider
to read and change user state. If you need
your page to be updated every time you change the user value, use
context.watch
. Following code show Login button when user is logged out and
vice versa.
class ThirdScreen extends StatelessWidget {
static const route = '/third';
@override
Widget build(BuildContext context) {
final user = context.watch<UserProvider>();
return Scaffold(
appBar: AppBar(title: Text('Third Screen')),
body: Center(
child: user.isLogged
? ElevatedButton(
onPressed: () {
user.logout();
},
child: Text('Logout'),
)
: ElevatedButton(
onPressed: () {
user.login();
},
child: Text('Login'),
),
),
);
}
}
We also want to display user state info on the first tab below the AppBar
.
class FirstScreen extends StatelessWidget {
static const route = '/first';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('First Screen'),
bottom: PreferredSize(
preferredSize: Size.fromHeight(40),
child: Padding(
padding: const EdgeInsets.all(12),
child: DefaultTextStyle(
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.white,
),
child: Builder(
builder: (context) {
final user = context.watch<UserProvider>().user;
if (user != null) {
return Text('Logged user: ${user.name}');
} else {
return Text('Logged out');
}
},
),
),
),
),
),
// ...
);
}
}
Using this code whenever you press Login button on third tab, the user info will be updated also on first tab.