DEV3 Technologies home

Bottom navigation with nested navigators

Keep each tab's navigation stack alive with an IndexedStack, one Navigator per tab and correct back-button handling.

FlutterNavigationflutternavigationmaterial3navigator

Code

import 'package:flutter/material.dart';

class TabSpec {
  const TabSpec(this.label, this.icon, this.root);
  final String label;
  final IconData icon;
  final WidgetBuilder root; // first screen of the tab
}

class TabbedShell extends StatefulWidget {
  const TabbedShell({super.key, required this.tabs});
  final List<TabSpec> tabs;

  @override
  State<TabbedShell> createState() => _TabbedShellState();
}

class _TabbedShellState extends State<TabbedShell> {
  late final List<GlobalKey<NavigatorState>> _keys =
      List.generate(widget.tabs.length, (_) => GlobalKey<NavigatorState>());
  int _index = 0;

  void _select(int i) {
    if (i == _index) {
      // Re-tapping the active tab pops it back to its root screen.
      _keys[i].currentState?.popUntil((r) => r.isFirst);
    } else {
      setState(() => _index = i);
    }
  }

  @override
  Widget build(BuildContext context) {
    final canPopTab = _keys[_index].currentState?.canPop() ?? false;
    return PopScope(
      // Let the system pop the app only when the active tab is at its root.
      canPop: !canPopTab && _index == 0,
      onPopInvokedWithResult: (didPop, _) {
        if (didPop) return;
        final nav = _keys[_index].currentState;
        if (nav != null && nav.canPop()) {
          nav.pop();
        } else {
          setState(() => _index = 0);
        }
      },
      child: Scaffold(
        body: IndexedStack(
          index: _index,
          children: [
            for (var i = 0; i < widget.tabs.length; i++)
              Navigator(
                key: _keys[i],
                onGenerateRoute: (_) =>
                    MaterialPageRoute(builder: widget.tabs[i].root),
              ),
          ],
        ),
        bottomNavigationBar: NavigationBar(
          selectedIndex: _index,
          onDestinationSelected: _select,
          destinations: [
            for (final t in widget.tabs)
              NavigationDestination(icon: Icon(t.icon), label: t.label),
          ],
        ),
      ),
    );
  }
}

How it works

What it solves

A bottom bar that simply swaps the body loses where the user was: open a product, switch to the cart tab, come back, and the product page is gone. Apps with several browsing flows need each tab to keep its own history, and the Android back button to respect it.

How it works

Every tab owns a Navigator with its own GlobalKey. An IndexedStack keeps all of them mounted and only shows the selected one, so scroll positions and pushed routes survive tab switches. Tapping the already-active destination pops that tab back to its first route, matching what users expect from native apps.

Back handling uses PopScope. While the active tab can still pop, the system pop is blocked and we pop the inner navigator instead. At a tab root on a non-home tab, back jumps to the first tab, and only from the first tab at its root does the app actually close.

Adapting it

Replace TabSpec.root with your real screens. For named routes inside a tab, pass onGenerateRoute from a per-tab router instead of the single MaterialPageRoute. To open a full-screen page above the bar (a camera, a checkout), push it with Navigator.of(context, rootNavigator: true).

If a tab should rebuild lazily, build its Navigator only after the first visit by tracking a set of visited indexes.

Pitfalls

  • canPop is read in build, so it only refreshes when the shell rebuilds. Add a NavigatorObserver that calls setState on push and pop if the system back gesture feels one step late.
  • All tabs stay in memory; heavy tabs (maps, video) may need lazy creation.
  • Do not reuse the same GlobalKey across two shells.

How to use it

Copy the file into your project, then show the shell as your home screen. No extra packages are needed.

home: TabbedShell(tabs: [
  TabSpec('Home', Icons.home_outlined, (_) => const HomePage()),
  TabSpec('Cart', Icons.shopping_cart_outlined, (_) => const CartPage()),
]),

Use Navigator.of(context).push(...) inside a tab to stay within that tab's stack.

Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.

Related snippets