Using BottomNavigationBar1 is unreasonably cumbersome in Flutter. Especially when you need to handle own Navigator stack for each tab. Or want Android back button to—ehm—go back instead of closing whole app.

I would like to present you a working navigation architecture which uses BottomNavigationBar, allows separate Navigator2 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 current tab in 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 stateless widget Root (with route name /) which should serve as 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 Provider3 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

Before creating our core component, NavigationProvider, we need to create a data class to hold all information about 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,
  });
}

Important part here is child which holds the screen widget (i.e. content inside Scaffold) and navigatorState. Using GlobalKey4 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 file navigation_provider.dart and put all 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.

first_screen.dart
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'),
            ),
          ],
        ),
      ),
    );
  }
}
second_screen.dart
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'),
          );
        },
      ),
    );
  }
}
third_screen.dart
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')),
    );
  }
}
pushed_screen.dart
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.

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.

_screens content
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 Themes or other InheritedWidgets.

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.

IndexedStack5 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).

WillPopScope6 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 Navigators. One implicit in MaterialApp, to which we provide provider.onGenerateRoute callback below, and 3 inner Navigators (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.7

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 AnimatedSwitcher8 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(...),
);

  1. BottomNavigationBar (Flutter API) ↩︎

  2. Navigator (Flutter API) ↩︎

  3. Provider (pub) ↩︎

  4. GlobalKey (Flutter API) ↩︎

  5. IndexedStack (Flutter API) ↩︎

  6. WillPopScope (Flutter API) ↩︎

  7. const constructor in Dart allows to store and reuse given object at compile time. It is basically caching and you can improve app performance! ↩︎

  8. AnimatedSwitcher (Flutter API) ↩︎