Bottom navigation in mobile apps is popular because our phones keep getting bigger but our thumbs do not. The Material Design specification describes a bottom navigation bar as a row of three to five buttons at the bottom of the screen that are used to select among as many destinations.
Flutter provides a BottomNavigationBar widget that’s essentially just a row of destination buttons and a single callback that’s passed the index of the button that was tapped. All the app has to do is to rebuild with a “destination view” widget that corresponds to the button the user tapped. Easy. There’s even a short example that demonstrates as much in the API docs. We’re done right?
If only it were that simple.
Stateful Destination Views
Destination views are likely to be stateful. They may contain text fields, selection controls, scrollables, or other widgets that depend on state which should not be discarded when the user selects a different destination. In other words, when the user returns to a destination, the destination view should be just as they left it.
A trivial implementation of the bottom navigation, where only the selected destination view is part of the widget tree, will not retain the state of the other destination views. The simplest way to ensure that the state for all views is retained, is to keep all of the views in the widget tree, while only showing the selected view. Fortunately, there’s a widget for that.
An IndexedStack displays only one of its children. All of its children are always part of the widget tree so their state is never discarded.
To demonstrate using an IndexedStack for displaying the selected destination, I’ve created a few small support classes and variables.
The Destination class contains a few visual properties that identify one destination. There’s also a list of the app’s four destinations, allDestinations.
Each stateful DestinationView contains a TextField to demonstrate that the keyboard focus and the text field’s value persist upon switching destinations.
Each DestinationView has its own scaffold. Not to put too fine a point on it: we’re nesting scaffolds here. That’s OK.
Finally the point of this whole exercise is revealed: the app’s HomePage, featuring a scaffold with a BottomNavigationBar for the destinations, and an IndexedStack for the destination views. As you can see, tapping on a destination (on a BottomNavigationBarItem) causes the home page to rebuild with a new value for_currentIndex. The indexed stack displays the destination view selected by the current index.
And that’s that, you now have bottom navigation with stateful destination views. Everyone is happy.
Actually, everyone is not happy. The Material Design spec makes it clear that destination views should cross-fade into view. The indexed stack demo unceremoniously snaps the selected destination into view within a single frame. The spec also suggests that if a destination view is scrollable, the bottom navigation bar should optionally slide off screen when the user scrolls up, and only reappear when the user scrolls down. And finally there’s navigation: many apps require each destination view to be hosted by its own Navigator, so that the view can display a stack of routes rather than a single interactive page.
The following sections will make everyone happy. Happier.
Navigator per Destination View
A Flutter Navigator manages a stack of Route objects and a stack of overlays that are displayed on top. Routes aren’t widgets, they’re objects that have-a widget. A route’s widget can be a fully opaque page or a smaller user interface part like a dialog or a menu.
A complete discussion of navigators and routes is beyond the scope of this article. To help you understand the version of the demo where each destination view includes a navigator, here are a few facts to keep in mind:
- Navigators have push and pop methods for managing the stack of routes.
- Navigators support lazily creating routes. Lazily created routes are identified by a path name.
- By default the navigator displays the route called ‘/’.
- Any widget can push or pop routes on its navigator ancestor with the static Navigator.push and Navigator.pop methods.
The code that follows is a version of the destination view’s state which builds a navigator instead of just building a text field. Routes are lazily constructed by the navigator’s onGenerateRoute callback and each route’s widget is constructed by a MaterialPageRoute builder callback.
The RootPage widget handles taps by pushing the route called ‘/list’ that contains a ListPage widget:
The ListPage widget is similar. Tapping any list item causes it to push a ‘/text’ route which will contain a TextPage widget. The TextPage widget is essentially the same as the original DestinationView (it contains a single TextField).
Cross Fading Destination Views
The basic idea here is pretty simple: stack the destination views, fade in the selected view, and fade out the unselected view[s]. Once a destination has faded out we’ll move it Offstage so that it’s no longer rendered or hit tested. To ensure that moving views offstage preserves their state, give them a Global Key.
A brief word about global keys and preserving a widget’s state.
Each time the widget tree is rebuilt, Flutter preserves the state of widgets that occupy the same place in the tree, and have the same key as they did in the previous frame. Most widgets are created without a key, so this test simplifies to: same runtime type and same tree location. A widget with a global key is treated differently. Its state and its subtree are moved if the keyed widget has moved to a new location when the tree rebuilds.
This version of the demo requires some additional HomePage state for each destination view: a fader animation and a global key.
In the cross fading version of the demo, the original indexed stack has been replaced by an ordinary Stack where each destination view has been wrapped with a FadeTransition and been assigned a global key with a KeyedSubtree. Each fade transition is driven by one of the animation controllers in the list of _faders shown above.
The fader for the currently selected destination view is driven forwards, which causes it to fade in. The faders for the other views are driven in reverse, and those which have completely faded out are moved offstage. While a view is fading out it’s wrapped with IgnorePointer, so that it doesn’t respond to user gestures.
That’s it for cross fading. Hopefully it’s obvious that it would be easy to substitute a different transition for the fade.
Hiding the Bottom Navigation Bar on Scroll
In this version of the demo, any destination view with scrollable content causes the bottom navigation bar to slide off screen when the content is scrolled downwards, and back on screen when the content scrolls upwards. We’ll use a NotificationListener<ScrollNotification> at the root of each destination view to detect changes in the scroll direction. Wrapping the bottom navigation bar with a SizeTransition makes it possible to animate the bar on and off screen.
This version of the demo requires adding one more animation controller for showing and hiding the bottom navigation bar the destination view’s state:
When the scroll direction changes, the scroll notification’s callback runs the _hide animation controller forward to hide the bottom navigation bar, and in reverse to show it. We use the notification’s depth to distinguish the topmost scrollable from nested ones.
The size transition keeps the child of the navigation bar top-aligned as it grows and shrinks, by specifying axisAlignment as -1.
That’s just about that for adding support for changing the visibility of the bottom navigation bar when the current destination is scrolled. If you read the source for the complete demo you’ll see one additional tweak. Each time a destination view’s navigator pushes or pops a new route, the bottom navigation bar is shown. This is done by giving each destination view navigator a NavigatorObserver, which ensures that the bottom navigation bar is visible.
This article explains how to create a bottom navigation bar app using Flutter. The effect is similar to tabbed web browsing with the tabs on the bottom: each tab (or “destination”) selects a view that provides a navigation stack.
In addition to the BottomNavigationBar widget, the demo implementation uses the Stack, Navigator, IgnorePointer, and Offstage widgets to manage the destination views, the SizeTransition and FadeTransition widgets for animation, and the NotificationListener and NavigatorObserver widgets to track state changes.
This article is based on a talk I gave at the NYC Flutter Meetup on 5/22/2019.
Many of the examples depend on Flutter 1.7.1 or a newer version.