Nested Navigation with Navigator
Nested Navigation with Navigator
In Flutter, every application has a root Navigator — the top-level navigation stack that manages full-screen routes. However, many real-world apps contain sections (tabs, drawers, or dedicated sub-flows) that need their own independent history stack. Nested navigation solves this by embedding a second Navigator widget inside a part of the UI so that inner screens can push and pop independently without disturbing the top-level navigator.
A classic example is a BottomNavigationBar app where each tab maintains its own back-stack. When the user navigates deep into Tab A and then switches to Tab B, the Tab A stack is preserved exactly as they left it. Pressing the device back button only pops within the current tab's navigator, not at the root level.
Navigator widget is itself just a widget — you can place it anywhere in the tree. Each Navigator instance maintains its own History stack independently of every other Navigator in the widget tree.Why Use Nested Navigators?
- Preserved tab state: Each tab remembers its own screen stack when the user switches tabs.
- Isolated back behavior: Back navigation inside a nested flow does not accidentally pop the root screen.
- Scoped sub-flows: Onboarding, checkout, or wizard flows can live inside a nested
Navigatorand be dismissed as a unit when complete. - Cleaner architecture: Each section of the app owns its routing logic without polluting the global route table.
The Navigator Widget Hierarchy
When Flutter processes navigation, it walks up the widget tree looking for the nearest Navigator ancestor. If you embed a second Navigator, calls to Navigator.of(context) from within that subtree will resolve to the inner navigator unless you explicitly pass rootNavigator: true.
Basic Nested Navigator Setup (Tab Navigation)
import 'package:flutter/material.dart';
class MainShell extends StatefulWidget {
const MainShell({super.key});
@override
State<MainShell> createState() => _MainShellState();
}
class _MainShellState extends State<MainShell> {
int _currentIndex = 0;
// One GlobalKey per tab so Flutter can keep each Navigator alive
final List<GlobalKey<NavigatorState>> _navigatorKeys = [
GlobalKey<NavigatorState>(),
GlobalKey<NavigatorState>(),
GlobalKey<NavigatorState>(),
];
Widget _buildTabNavigator(int index) {
return Navigator(
key: _navigatorKeys[index],
onGenerateRoute: (settings) {
// Each tab starts at its own home screen
return MaterialPageRoute(
builder: (_) => _tabHome(index),
settings: settings,
);
},
);
}
Widget _tabHome(int index) {
const titles = ['Home', 'Search', 'Profile'];
return Scaffold(
appBar: AppBar(title: Text(titles[index])),
body: Center(
child: ElevatedButton(
onPressed: () {
// Pushes onto THIS tab's nested Navigator, not the root
_navigatorKeys[index].currentState!.push(
MaterialPageRoute(builder: (_) => const DetailScreen()),
);
},
child: const Text('Go to Detail'),
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(
index: _currentIndex,
children: List.generate(3, _buildTabNavigator),
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _currentIndex,
onTap: (i) => setState(() => _currentIndex = i),
items: const [
BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Search'),
BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profile'),
],
),
);
}
}
class DetailScreen extends StatelessWidget {
const DetailScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Detail')),
body: const Center(child: Text('Detail Screen')),
);
}
}
IndexedStack instead of a plain Stack or Offstage to keep all tab navigators alive simultaneously. IndexedStack preserves each child's state while rendering only the active index, which is exactly what you need for persistent tab stacks.Handling the Android Back Button
By default, the system back button (Android hardware back or predictive back gesture) is handled by the root navigator. To intercept it and pop the active tab's nested navigator first, wrap the Scaffold in a PopScope (Flutter 3.12+) or the older WillPopScope:
Intercepting Back Button for Nested Navigators
@override
Widget build(BuildContext context) {
return PopScope(
// canPop: false prevents root-level pop while we handle it
canPop: false,
onPopInvokedWithResult: (didPop, result) {
if (didPop) return;
final innerNav = _navigatorKeys[_currentIndex].currentState;
if (innerNav != null && innerNav.canPop()) {
// Pop the inner (tab) navigator first
innerNav.pop();
} else {
// Nothing left in this tab — allow the system to handle
// (e.g., exit the app if on the root route)
Navigator.of(context).pop();
}
},
child: Scaffold(
body: IndexedStack(
index: _currentIndex,
children: List.generate(3, _buildTabNavigator),
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _currentIndex,
onTap: (i) => setState(() => _currentIndex = i),
items: const [
BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Search'),
BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profile'),
],
),
),
);
}
Navigator.of(context, rootNavigator: true) to push routes inside a tab. Doing so bypasses the nested navigator and pushes onto the root stack, which makes the new screen cover the entire shell (bottom bar disappears) and breaks the isolated back-stack behaviour you set up.Accessing the Correct Navigator
Inside a widget that lives within a nested navigator's subtree, Navigator.of(context) resolves to the nearest ancestor — the inner navigator. This is usually what you want. Use rootNavigator: true only when you deliberately want to show a modal or a screen above the entire shell (e.g., a full-screen payment flow that covers everything).
Navigator.of(context)— nearest navigator (nested tab navigator inside tabs)Navigator.of(context, rootNavigator: true)— the root navigator (covers entire app shell)navigatorKey.currentState!— direct access viaGlobalKeyfrom anywhere
Summary
Nested navigation is an essential pattern for any Flutter app that has sections requiring independent history stacks. The key points are: embed a Navigator widget with a GlobalKey for each independent section, use IndexedStack to keep all sections alive, intercept the system back button with PopScope to pop the inner navigator first, and be deliberate about whether you push onto the inner or root navigator by choosing between Navigator.of(context) and Navigator.of(context, rootNavigator: true).