What problem does the article solve?

Recently I looked into Flutter and had a headache when using Navigator. The Flutter would rebuild when we toggle navigation buttons back and forth, causing the control to rebuild and lose its browsing history. This experience is definitely not good. Later I read this article and finally solved this problem. The original text is here

The body of the

Today we will look at the Navigation of the Flutter.

But not just any boring Navigation. 😉

No, ladies and gentlemen, let’s make Navigation interesting. Here’s an app with BottomNavigationBar:


1_yptwp6Ahe_-yhrLTg-NqwQ.png

What we want is for each TAB to have its own Navigation stack. This way we don’t lose Navigation history when switching tabs. The diagram below:




multiple-navigators-BottomNavigationBar-animation.gif

How is this implemented? To make a long story short:

  • Create a tapeScaffoldandBottomNavigationBarThe app.
  • In each oneScaffoldCreate a subitem for each TABStack.
  • Each child layout is an addchildNavigatortheOffstageThe control.
  • Don’t forget to use WillPopScope to handle Android backward navigation.

Want a longer and more interesting explanation? First, take a look at the disclaimer:

  • This article assumes that you are familiar with navigation in Flutter. More knowledge, please refer to the basic knowledge of Navigation tutorial, and (the Navigator) [docs. Flutter. IO/flutter/wid…, (MaterialPageRoute) [docs. Flutter. IO/flutter/mat… And (MaterialApp) [docs. Flutter. IO/flutter/mat…
  • Some of this code is experimental. If you know a better way, please let me know.

All right, let’s get started.

It’s all about Navigator

All Flutter applications are defined as MaterialApp. Normally, the MaterialApp is located at the root of the control tree:

void main() => runApp(new MyApp()); class MyApp extends StatelessWidget { // This widget is the root of your application. @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', theme: ThemeData( primarySwatch: Colors.red, ), home: App(), ); }}Copy the code

We can then define our App class as follows:

enum TabItem { red, green, blue }

class App extends StatefulWidget {
  @override
  State<StatefulWidget> createState() => AppState();
}

class AppState extends State<App> {

  TabItem currentTab = TabItem.red;

  void _selectTab(TabItem tabItem) {
    setState(() {
      currentTab = tabItem;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: _buildBody(),
      bottomNavigationBar: BottomNavigation(
        currentTab: currentTab,
        onSelectTab: _selectTab,
      ),
    );
  }
  
  Widget _buildBody() {
    // return a widget representing a page
  }
}
Copy the code

Here, BottomNavigation is a custom control that uses the BottomNavigationBar to draw three tabs with the correct color. It takes currentTab as input and calls the _selectTab method to update the state as needed.

The interesting part is the _buildBody () method. For simplicity, we can first add a callback FlatButton to push the new page:

Widget _buildBody() { return Container( color: TabHelper.color(TabItem.red), alignment: Alignment.center, child: FlatButton(Child: Text('PUSH', style: TextStyle(fontSize: 32.0, color: color.white),), onPressed: _push,); } void _push() { Navigator.of(context).push(MaterialPageRoute( // we'll look at ColorDetailPage later builder: (context) => ColorDetailPage( color: TabHelper.color(TabItem.red), title: TabHelper.description(TabItem.red), ), )); }Copy the code

How does the _push() method work?

  • MaterialPageRouteResponsible for creating new routes to push.
  • The Navigator of (context)In the window control treeNavigatorAnd use it to push a new route.

You might be wondering where Navigator came from.

We didn’t create one ourselves, and the parent of our App class is the MaterialApp at the root of the control tree.

As it turns out, MaterialApp creates its own Navigator internally.

However, if we only use navigator.of (context) to push new routes, something unexpected happens.

When a new page appears, the entire BottomNavigationBar and its contents will slide. Not cool. 🤨


1_k5yMOPCem_z5JZVpa6RJCQ.gif

What we really want is to push the detail page to the main page, but keep the BottomNavigationBar at the bottom.

This doesn’t work because navigator.of (context) finds the ancestor of the BottomNavigatorBar itself. In fact, the control tree looks like this:

MyApp â–¼ MaterialApp â–¼ < Some Other Widgets > â–¼ Navigator â–¼ App â–¼ Scaffold â–¼ Body: <some other widgets> â–¼ BottomNavigationBarCopy the code

If we open the Flutter Inspector:




1_zSeQkAGwARf2KtSkZqgRSg.png

If we can use a Navigator that is not the ancestor of our BottomNavigationBar, then it will work as expected.

goodNavigatorWhat can we do

The solution was to wrap the body of our Scaffold object with the new Navigator““.

But before we do that, let’s introduce the new class we’ll use to showcase the final UI.

The first class is called TabNavigator:

class TabNavigatorRoutes { static const String root = '/'; static const String detail = '/detail'; } class TabNavigator extends StatelessWidget { TabNavigator({this.navigatorKey, this.tabItem}); final GlobalKey<NavigatorState> navigatorKey; final TabItem tabItem; void _push(BuildContext context, {int materialIndex: 500}) { var routeBuilders = _routeBuilders(context, materialIndex: materialIndex); Navigator.push( context, MaterialPageRoute( builder: (context) => routeBuilders[TabNavigatorRoutes.detail](context))); } Map<String, WidgetBuilder> _routeBuilders(BuildContext context, {int materialIndex: 500}) { return { TabNavigatorRoutes.root: (context) => ColorsListPage( color: TabHelper.color(tabItem), title: TabHelper.description(tabItem), onPush: (materialIndex) => _push(context, materialIndex: materialIndex), ), TabNavigatorRoutes.detail: (context) => ColorDetailPage( color: TabHelper.color(tabItem), title: TabHelper.description(tabItem), materialIndex: materialIndex, ), }; } @override Widget build(BuildContext context) { var routeBuilders = _routeBuilders(context); return Navigator( key: navigatorKey, initialRoute: TabNavigatorRoutes.root, onGenerateRoute: (routeSettings) { return MaterialPageRoute( builder: (context) => routeBuilders[routeSettings.name](context)); }); }}Copy the code

How does this work?

  • In lines 1-4, we define two route names:/and/ detail

    In line 7, we defineTabNavigatorConstructor of. It takes anavigatorKeyAnd atabItem.
  • Please note that,navigatorKeyThe type ofGlobalKey <NavigatorState>. We need this to uniquely identify the navigator (Here,Read more about GlobalKey).
  • In line 22, we define one_routeBuildersMethod, which will WidgetBuilderAssociated with each of the two paths we defined. We'll check it out in a secondColorsListPageandColorDetailPage ` ` `.
  • In line 38, we implement itBuild (Method that returns a new Navigator object.
  • It takes akeyAnd ainitialRouteParameters.
  • It also has oneonGenerateRouteMethod, which is called each time a route needs to be generated. This uses what we defined above_routeBuilders()Methods.
  • In lines 11-19, we define one_push ()Method, which is used to push the detail path using ColorDetailPage.

This is the ColorsListPage class:

class ColorsListPage extends StatelessWidget { ColorsListPage({this.color, this.title, this.onPush}); final MaterialColor color; final String title; final ValueChanged<int> onPush; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text( title, ), backgroundColor: color, ), body: Container( color: Colors.white, child: _buildList(), )); } final List<int> materialIndices = [900, 800, 700, 600, 500, 400, 300, 200, 100, 50]; Widget _buildList() { return ListView.builder( itemCount: materialIndices.length, itemBuilder: (BuildContext content, int index) { int materialIndex = materialIndices[index]; return Container( color: color[materialIndex], child: ListTile( title: Text('$materialIndex', style: TextStyle(fontSize: 24.0), trailing: Icon(icon.chevron_right), onTap: () => onPush(materialIndex),); }); }}Copy the code

The purpose of this class is to display a ListView of all the color shadows of the MaterialColor that can be input. The MaterialColor is simply a ColorSwatch with ten different hues.

For completeness, here is ColorDetailPage:

class ColorDetailPage extends StatelessWidget { ColorDetailPage({this.color, this.title, this.materialIndex: 500}); final MaterialColor color; final String title; final int materialIndex; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( backgroundColor: color, title: Text( '$title[$materialIndex]', ), ), body: Container( color: color[materialIndex], ), ); }}Copy the code

This is simple: it just displays a page with an AppBar and displays the MaterialColor selected earlier. It looks something like this:


1_u3V51SHLSoR4q0_OD45bQg.png

Let’s put this together

Now that we have our own TabNavigator, let’s go back to our App and use it:

final navigatorKey = GlobalKey<NavigatorState>();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: TabNavigator(
        navigatorKey: navigatorKey,
        tabItem: currentTab,
      ),
      bottomNavigationBar: BottomNavigation(
        currentTab: currentTab,
        onSelectTab: _selectTab,
      ),
    );
  }
Copy the code
  • First of all, let’s define anavigatorKey.
  • And then in ourbuild()Method, we use it to create aTabNavigatorAnd the incomingcurrentTab.

    If we run the application now, we can see that the push works when the list item is selected, andBottomNavigationBarStay the same. Great! 😀


multiple-navigators-BottomNavigationBar-animation.gif

But there’s a problem. Switching between tags does not seem to work because we always display red pages within the Scaffold body.

Multiple Navigator

This is because we have defined a new navigator, but this is shared across all three tabs.

Remember: we want a separate navigation stack for each tag!

We solve this problem:

class App extends StatefulWidget {
  @override
  State<StatefulWidget> createState() => AppState();
}

class AppState extends State<App> {

  TabItem currentTab = TabItem.red;
  Map<TabItem, GlobalKey<NavigatorState>> navigatorKeys = {
    TabItem.red: GlobalKey<NavigatorState>(),
    TabItem.green: GlobalKey<NavigatorState>(),
    TabItem.blue: GlobalKey<NavigatorState>(),
  };

  void _selectTab(TabItem tabItem) {
    setState(() {
      currentTab = tabItem;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Stack(children: <Widget>[
        _buildOffstageNavigator(TabItem.red),
        _buildOffstageNavigator(TabItem.green),
        _buildOffstageNavigator(TabItem.blue),
      ]),
      bottomNavigationBar: BottomNavigation(
        currentTab: currentTab,
        onSelectTab: _selectTab,
      ),
    );
  }

  Widget _buildOffstageNavigator(TabItem tabItem) {
    return Offstage(
      offstage: currentTab != tabItem,
      child: TabNavigator(
        navigatorKey: navigatorKeys[tabItem],
        tabItem: tabItem,
      ),
    );
  }
}
Copy the code

A few notes:

  • In lines 9-13, we define a map of the global navigation keys. This is what we need to make sure we use multiple navigators.
  • The body of our scaffolding is now a stack with three children.
  • Every subterm is there_buildOffstageNavigator()Method.
  • This uses the Offstage control with the child TabNavigator. If the TAB being rendered does not match the current TAB, the offstage property is true.
  • We will benavigatorKey [tabItem]Passed to theTabNavigatorTo ensure that each TAB has a separate navigation key.
  • If we compile and run the application, everything now works as expected. We can push/pop each navigator independently and the background navigator keeps their state. 🚀

One more thing

If we run an application on Android, when we press the back button, we notice something interesting:




1_4_rjL1Hh_zKHJHjO4MNOIg.gif

The app is gone and we’re back on the home screen!

This is because we did not specify how the back button should be handled.

Let’s solve this problem:

@override Widget build(BuildContext context) { return WillPopScope( onWillPop: () async => ! await navigatorKeys[currentTab].currentState.maybePop(), child: Scaffold( body: Stack(children: <Widget>[ _buildOffstageNavigator(TabItem.red), _buildOffstageNavigator(TabItem.green), _buildOffstageNavigator(TabItem.blue), ]), bottomNavigationBar: BottomNavigation( currentTab: currentTab, onSelectTab: _selectTab, ), ), ); }Copy the code

This is done through WillPopScope, which controls how to unroute. Take a look at the WillPopScope documentation:

In line 4, we define an onWillPop () callback that returns false if the current navigator can pop up, and true otherwise.

If we run the application again, we can see that pressing the back button undoes all push routes, and we only leave the application when we press it again.




1_qQW2iGXiWL2F1tu6cLQfwg.gif

One thing to note is that when we push a new route on Android, it slides in from the bottom. Instead, the convention is to slide in from the right on iOS.

Also, the transition on Android is a bit tense for some reason. I’m not sure if this is an emulator issue, it looks good on real devices.

Credits

Credits go to]Brian Egan](github.com/brianegan) to find a way to make Navigator work. His idea was to use Stack with Offstage to keep the navigator in state.

review

Today we learned a lot about Flutter navigation and how to combine the BottomNavigationBar, Stack, Offstage and Navigator controls to implement multiple navigation stacks.

Using the Offstage widget ensures that all our navigators retain their state because they remain in the control tree. This can lead to some performance penalty, so IF you choose to use it, I recommend that you analyze your application.

You can find the full source code for this article here