State Persistence Techniques for the Flutter Bottom Navigation Bar

State Persistence Techniques for the Flutter Bottom Navigation Bar

Prevent rebuilding the bottom navigation bar screens in Flutter

ยท

7 min read

Overview

Building a bottom navigation bar is simple in Flutter given the BottomNavigationBar Widget. However, we want to add features to the standard Widget.

We'll set up a basic flutter app with bottom navigation and look at the problems we face. Like not initializing the bottom navigation bar pages every time we switch tabs or preserving the state of bottom tab pages.
Then we'll try a couple of approaches for resolving them. We'll compare the results and we can decide which one to go forward with.

Here's the GitHub repo with all the code.

Setup

We're going to start with the basic app containing bottom navigation with two tabs.

  • Tab 1: Scrollable list of items.
  • Tab 2: Displaying the escaped seconds of a Timer.

What do we want to achieve?

  1. Create the navigation bar page only when we open the page.
  2. Preserve scroll position of navigation bar page in Tab 1.
  3. Preserve the escaped time of Timer in Tab 2.

Implementation

Let's start with a new Flutter project.

Parent Widget: Bottom navigation bar
We have a simple Scaffold with BottomNavigationBar containing two Tabs.

class BasicBottomNavigation extends StatefulWidget {
  const BasicBottomNavigation({Key? key}) : super(key: key);

  @override
  State<BasicBottomNavigation> createState() => _BasicBottomNavigationState();
}

class _BasicBottomNavigationState extends State<BasicBottomNavigation> {
  int currentIndex = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: [         /// List of tab page widgets
        const _Tabbar1(),     
        const _Tabbar2(),
      ][currentIndex],
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: currentIndex,
        onTap: (index) {
          setState(() {
            currentIndex = index;     /// Switching tabs
          });
        },
        items: const [
          BottomNavigationBarItem(icon: Text("1"), label: "Tab"),
          BottomNavigationBarItem(icon: Text("2"), label: "Tab"),
        ],
      ),
    );
  }
}

Tab 1: A scrollable list of items
We have a ListView displaying the index inside a ListTile.

class _Tabbar1 extends StatelessWidget {
  const _Tabbar1({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    print("Tabbar 1 build");

    return Scaffold(
      appBar: AppBar(title: const Text("Tab bar 1")),
      body: ListView.builder(
        itemBuilder: (context, index) {
          return ListTile(
            title: Text("${index + 1}"),
          );
        },
        itemCount: 50,
      ),
    );
  }
}

Tab 2: Displaying escaped seconds of a Timer
We are using a Ticker to run the Timer and update our escaped duration every second.

Fun Fact: Ticker is used in Flutter for callbacks during Animation frames.


class _Tabbar2 extends StatefulWidget {
  const _Tabbar2({Key? key}) : super(key: key);

  @override
  State<_Tabbar2> createState() => _Tabbar2State();
}

class _Tabbar2State extends State<_Tabbar2>
    with SingleTickerProviderStateMixin {
  late final Ticker _ticker;
  Duration _escapedDuration = Duration.zero;

  get escapedSeconds => _escapedDuration.inSeconds.toString();

  @override
  void initState() {
    super.initState();
    print("Tabbar 2 initState");

    _ticker = createTicker((elapsed) {
      if (elapsed.inSeconds - _escapedDuration.inSeconds == 1) {
        setState(() {
          _escapedDuration = elapsed;
        });
      }
    });

    _ticker.start();
  }

  @override
  void dispose() {
    _ticker.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("Tab bar 2")),
      body: Center(
        child: Text(escapedSeconds),
      ),
    );
  }
}

Demo

demo_01.gif

Result

  1. Tabs are initialized only when we click on them.
  2. The scroll position is not preserved.
  3. Escaped time of the Timer is not preserved.

Nothing was preserved. We create new tab pages every time we click on them. The scroll position is lost we switch back to Tab 1. The Timer starts from 0 whenever we open Tab 2.

There is no problem with this approach as long as we don't need to preserve any state.
But since we do, let's look at how we can achieve it.

1. Stack and OffStage

One way to persist the bottom navigation bar page is to use Stack Widget. We'll add all the pages as children of Stack with the order of the bottom tabs and display one child at a time with respect to the currently selected bottom tab.

Implementation

We'll wrap the Tabbar widgets with OffStage and the children list with Stack. The offstage parameter takes a boolean value. If it's true, then the child is hidden or offstage, otherwise, the child is visible.
There is no change in the Tabbar classes.

Parent Widget: Bottom navigation bar

 return Scaffold(
      body: Stack(       ///  Added Stack Widget
        children: [
          Offstage(          /// Wrap Tab with OffStage 
            offstage: currentIndex != 0,
            child: const _Tabbar1(),
          ),
          Offstage(
            offstage: currentIndex != 1,
            child: const _Tabbar2(),
          ),
        ],
      ),

Demo

demo_02.gif

Result

  1. Tabs are not initialized only when we click on them.
  2. The scroll position is preserved.
  3. Escaped time of the Timer is preserved.

All the tabs are initialized with the parent Widget. Hence the timer in Tabbar 2 started before we even opened that Tab. The good thing is that it preserves the scroll position and escaped time.

If creating all the tabs at once does not affect the performance and is what we want, then we use this technique.

1. Alternative - Indexed Stack

Turns out there's a Widget (as always with Flutter ๐Ÿ˜‡) called IndexedStack that we can use. It's less code with the same result.

Parent Widget: Bottom navigation bar

return Scaffold(
      body: IndexedStack(      /// Replaced with IndexedStack
        index: currentIndex,
        children: const [
          _Tabbar1(),
          _Tabbar2(),
        ],
      ),

2. AutomaticKeepAliveClientMixin

As the name suggests, this mixin makes the client (Tabbar child widgets) keep themselves alive (not disposed of) after we switch the tabs. It also creates the Tab only when it is first clicked and not with the Parent Widget like the above methods.

Implementation

AutomaticKeepAliveClientMixin needs a PageView in the parent widget. So we'll wrap the body with PageView and pass the list of tabs as its children.

Further Reading: Other than PageView, there's a TabBarView (for top app bar tabs), which also makes AutomaticKeepAliveClientMixin work for tabs (child widgets) because it uses PageView internally.

Parent Widget: Bottom navigation bar

class AliveMixinDemo extends StatefulWidget {
  const AliveMixinDemo({Key? key}) : super(key: key);

  @override
  State<AliveMixinDemo> createState() => _AliveMixinDemoState();
}

class _AliveMixinDemoState extends State<AliveMixinDemo> {
  final PageController controller = PageController();  /// initializing controller for PageView

  int currentIndex = 0;
  final tabPages = [
    const _Tabbar1(),
    const _Tabbar2(),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: PageView(        /// Wrapping the tabs with PageView
        controller: controller,
        children: tabPages,
        onPageChanged: (index) {
          setState(() {
            currentIndex = index;     /// Switching bottom tabs
          });
        },
      ),
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: currentIndex,
        onTap: (index) {
          controller.jumpToPage(index);    /// Switching the PageView tabs
          setState(() {
            currentIndex = index;
          });
        },
        items: const [
          BottomNavigationBarItem(icon: Text("1"), label: "Tab"),
          BottomNavigationBarItem(icon: Text("2"), label: "Tab"),
        ],
      ),
    );
  }
}

Tab 1: A scrollable list of items
We replacing to StatefulWidget here because AutomaticKeepAliveClientMixin only works with the State Class as defined in its implementation.

"A mixin with convenience methods for clients of AutomaticKeepAlive. Used with State subclasses."

We need only two additions after this.
First, call the super.build() inside the build method. Second, override the wantKeepAlive and return true.

class _Tabbar1 extends StatefulWidget {
  const _Tabbar1({Key? key}) : super(key: key);

  @override
  State<_Tabbar1> createState() => _Tabbar1State();
}

class _Tabbar1State extends State<_Tabbar1> 
    with AutomaticKeepAliveClientMixin {     /// Using the mixin
  @override
  Widget build(BuildContext context) {
    super.build(context);    /// Calling build method of mixin
    print("Tabbar 1 build");
    return Scaffold(
      appBar: AppBar(title: const Text("Tab bar 1")),
      body: ListView.builder(
        itemBuilder: (context, index) {
          return ListTile(
            title: Text("${index + 1}"),
          );
        },
        itemCount: 50,
      ),
    );
  }

  @override
  bool get wantKeepAlive => true;    /// Overriding the value to preserve the state
}

Tab 2: Displaying escaped seconds of a Timer
The changes are the same as with the Tabbar 1 class above.

class _Tabbar2State extends State<_Tabbar2>
    with SingleTickerProviderStateMixin, 
    AutomaticKeepAliveClientMixin {    /// Using the mixin
  late final Ticker _ticker;
  @override
  Widget build(BuildContext context) {
    super.build(context);     /// Calling build method of mixin
    return Scaffold(
      appBar: AppBar(title: const Text("Tab bar 2")),
      body: Center(
        child: Text(escapedSeconds),
      ),
    );
  }

  @override
  bool get wantKeepAlive => true;   /// Overriding the value to preserve the state
}

Demo

demo_03.gif

Result

  1. Tabs are initialized only when we click on them.
  2. The scroll position is preserved.
  3. Escaped time of the Timer is preserved.

The Tabbar 2 is initialized only the first time when we click on it. The Timer preserves its state and so does the scrolling position in Tabbar 1.

If we want to programmatically change the keepAlive condition, then we can use the updateKeepAlive() method of AutomaticKeepAliveClientMixin. For further reading, refer to this StackOverflow answer.


Conclusion

We can choose any one approach from the above options according to our requirements.

  • Don't want to preserve any state -> standard BottomBarNavigation.
  • Want to preserve state but fine with creating all the tabs at once -> IndexedStack or Stack and OffStage.
  • Want to preserve state and build tabs only once when clicked on them -> AutomaticKeepAliveClientMixin.

IndexedStack is the simplest approach while AutomaticKeepAliveClientMixin covers our need. Since we usually have API calls in tabs and don't want to call them every time we switch to that tab.

Final Note

Thank you for reading this article. If you enjoyed it, consider sharing it with other people.
If you find any mistakes, please let me know.
Feel free to share your opinions below.

Did you find this article valuable?

Support Nikki Goel by becoming a sponsor. Any amount is appreciated!

ย