Flutter UIPersistent Tabs (The Easy Way)
Want to skip the read? Find the complete code here.

Welcome! This is going to be a (hopefully) short rundown on how to achieve persistent tabs using a BottomNavigationBar in Flutter.

Before getting into it, let's better understand what "Persistent Tabs" means. Basically, it is a method of navigation in an app where the state and history of each tab you navigate through is kept alive. This means that as you switch between the tabs (typically from a bottom navigation bar), you can continue from where you last left off in that tab.

By default, using the Material BottomNavigationBar gives us access to tabs we can use to set the selected index and display the corresponding widget. This is great! We will use this. Here is a basic example of what that code looks like.

class PersistentTabsDemo extends StatefulWidget {
    @override
    _PersistentTabsDemoState createState() => _PersistentTabsDemoState();
  }

  class _PersistentTabsDemoState extends State<PersistentTabsDemo> {
    int currentTabIndex;

    @override
    void initState() {
      super.initState();
      currentTabIndex = 0;
    }

    void setCurrentIndex(int val) {
      setState(() {
        currentTabIndex = val;
      });
    }

    @override
    Widget build(BuildContext context) {
      return Scaffold(
        body: [Home(), Explore()][currentTabIndex],
        bottomNavigationBar: BottomNavigationBar(
          onTap: setCurrentIndex,
          currentIndex: currentTabIndex,
          items: [
            BottomNavigationBarItem(
              icon: Icon(Icons.home),
              label: "Home",
            ),
            BottomNavigationBarItem(
              icon: Icon(Icons.explore),
              label: "Explore",
            ),
          ],
        ),
      );
    }
  };

And here is that code on an emulator. Your Home and Explore widgets could be any screen with a Scaffold!

See how the input text in the TextField disappears after switching tabs? And that the bottom navbar disappears after pushing a new screen? These are things we (depending on the app) want to avoid.

Let's fix this!

Introducing, the PersistentTabs Widget

First, I will show the full code of PersistentTabs and then we will break it down.

class PersistentTabs extends StatelessWidget {
    const PersistentTabs({
      @required this.screenWidgets,
      this.currentTabIndex = 0,
    });
    final int currentTabIndex;
    final List<Widget> screenWidgets;
  
    List<Widget> _buildOffstageWidgets() {
      return screenWidgets
          .map(
            (w) => Offstage(
              offstage: currentTabIndex != screenWidgets.indexOf(w),
              child: Navigator(
                onGenerateRoute: (routeSettings) {
                  return MaterialPageRoute(builder: (_) => w);
                },
              ),
            ),
          )
          .toList();
    }
  
    @override
    Widget build(BuildContext context) {
      return Stack(
        children: _buildOffstageWidgets(),
      );
    }
  }

Pretty simple right?

Here are some key things to notice.

  • This widget knows the current index of a selected tab.
  • PersistentTabs takes in a list of widgets called screenWidgets.
  • Each screenWidget passed in is wrapped as a child to both Navigator and Offstage.
  • The newly built list of screen widgets is then placed as children to a Stack

By wrapping each screen with a Navigator, we are creating an independent point of navigation in our Widget tree. Now, if we reference this newly created Navigator when pushing a screen, our BottomNavigationBar will no longer disappear. If you need to push a fullscreen route, you can still do so by using a reference to the Navigator that is created with MaterialApp at the root of your app.

By wrapping each screen with an Offstage widget, we can control whether or not it should be hidden from view. By using this in conjunction with the Stack, we can keep the history of our route with no problem.

With this setup, we can now utilize PersistentTabs to fix the problems we had above!

Putting it all together

Using our PersistentTabs widget along with the same BottomNavigationBar from before, we can get the results we're looking for. Here is the final code!

class PersistentTabsDemo extends StatefulWidget {
    @override
    _PersistentTabsDemoState createState() => _PersistentTabsDemoState();
  }
  
  class _PersistentTabsDemoState extends State<PersistentTabsDemo> {
    int currentTabIndex;
  
    @override
    void initState() {
      super.initState();
      currentTabIndex = 0;
    }
  
    void setCurrentIndex(int val) {
      setState(() {
        currentTabIndex = val;
      });
    }
  
    @override
    Widget build(BuildContext context) {
      return Scaffold(
        body: PersistentTabs(
          currentTabIndex: currentTabIndex,
          screenWidgets: [Home(), Explore()],
        ),
        bottomNavigationBar: BottomNavigationBar(
          onTap: setCurrentIndex,
          currentIndex: currentTabIndex,
          items: [
            BottomNavigationBarItem(
              icon: Icon(Icons.home),
              label: "Home",
            ),
            BottomNavigationBarItem(
              icon: Icon(Icons.explore),
              label: "Explore",
            ),
          ],
        ),
      );
    }
  }

And again, here is that code on an emulator.

Fantastic! The problems we had above are now fixed. We can now successfully implement this style of navigation in any app.

Get the PersistentTabs code here!

References

I definitely did not go down this rabbit hole already knowing exactly how to implement something like this. It took a bit of time Googling and trying to understand how Flutter's navigation works.

The best article I found that really made it click for me is by CodeWithAndrea and can be found here. I drew a lot of my inspiration from his article in my code and definitely would not have made it as far without it.

He also has a ton of other great resources for learning Flutter related things, so I highly recommend checking him out.

Thanks for reading!

- Zach