This example demonstrates how to implement nested navigation with tabs, including multi-level nesting and synchronized state management.
Overview
The nested tabs example shows:
- Two-level navigation rail with tabs
- Nested tab controllers within pages
- Synchronized navigation state across UI components
- Animated transitions between nested pages
- URL routing for nested content
Architecture
The example uses a three-level navigation hierarchy:
- Top level: Navigation rail with First/Second pages
- Second level: Tab bar within Second page
- Nested content: Individual pages within tabs
├─ NavigationRail
│ ├─ First Page (/)
│ └─ Second Page (/second)
│ └─ TabBar
│ ├─ Nested First (/second)
│ └─ Nested Second (/second/2)
Implementation
Define nested routes
Create routes for all navigation levels:List<NavigationData> routes = [
NavigationData(
label: FirstPage.name,
url: '/',
builder: (context, routeData, globalData) => const MyHomePage(),
group: MyHomePage.name),
NavigationData(
label: SecondPage.name,
url: '/second',
builder: (context, routeData, globalData) => const MyHomePage(),
group: MyHomePage.name),
NavigationData(
label: NestedSecondPage.name,
url: '/second/2',
builder: (context, routeData, globalData) => const MyHomePage(),
group: MyHomePage.name),
];
All routes share the same group so they reuse the parent MyHomePage widget instance.
Build the top-level navigation
Create a navigation rail that listens to route changes:class _MyHomePageState extends State<MyHomePage> {
int selectedIndex = 0;
List<NavigationData> pages = [
NavigationData(
label: FirstPage.name,
url: '/',
builder: (context, routeData, globalData) =>
const FirstPage(key: ValueKey(FirstPage.name)),
),
NavigationData(
label: SecondPage.name,
url: '/second',
builder: (context, routeData, globalData) =>
const SecondPage(key: ValueKey(SecondPage.name)),
),
];
late StreamSubscription navigationListener;
@override
void initState() {
super.initState();
setTab(NavigationManager.instance.currentRoute?.label);
navigationListener = NavigationManager.instance.getCurrentRoute
.listen((route) => setTab(route.label));
}
@override
void dispose() {
navigationListener.cancel();
super.dispose();
}
void setTab(String? tab) {
if (tab == null) return;
int tabIndex = pages.indexWhere((element) => element.label == tab);
if (tabIndex > -1 && tabIndex <= 1) {
selectedIndex = tabIndex;
setState(() {});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Row(
children: [
NavigationRail(
selectedIndex: selectedIndex,
onDestinationSelected: (int index) {
if (index == 0) {
NavigationManager.instance.push(FirstPage.name);
} else {
NavigationManager.instance.push(SecondPage.name);
}
setState(() {
selectedIndex = index;
});
},
labelType: NavigationRailLabelType.all,
destinations: const [
NavigationRailDestination(
icon: Icon(Icons.looks_one_outlined),
selectedIcon: Icon(Icons.looks_one_rounded),
label: Text('First'),
),
NavigationRailDestination(
icon: Icon(Icons.looks_two_outlined),
selectedIcon: Icon(Icons.looks_two_rounded),
label: Text('Second'),
),
],
),
Expanded(
child: Align(
alignment: Alignment.topCenter,
child: AnimatedStack(
duration: const Duration(milliseconds: 500),
crossFadePosition: 0.3,
alignment: Alignment.topCenter,
initialAnimation: false,
animation: (child, animation) {
return SharedAxisAnimation(
key: child.key,
animation: animation,
transitionType: SharedAxisAnimationType.vertical,
child: child);
},
children: NavigationManager.instance
.nested(context: context, routes: pages),
),
),
),
],
),
);
}
}
Use NavigationManager.instance.nested() to build nested page widgets with proper key management.
Add nested tab navigation
Implement tabs within the second page:class _SecondPageState extends State<SecondPage> with TickerProviderStateMixin {
List<NavigationData> pages = [
NavigationData(
label: SecondPage.name,
url: '/second',
builder: (context, routeData, globalData) =>
const NestedFirstPage(key: ValueKey(NestedFirstPage.name)),
),
NavigationData(
label: NestedSecondPage.name,
url: '/second/2',
builder: (context, routeData, globalData) =>
const NestedSecondPage(key: ValueKey(NestedSecondPage.name))),
];
List<Tab> tabs = const [
Tab(text: 'Nested First'),
Tab(text: 'Nested Second'),
];
late TabController tabController;
late StreamSubscription navigationListener;
@override
void initState() {
super.initState();
int initialIndex = pages.indexWhere((element) =>
element.path == NavigationManager.instance.currentRoute?.path);
tabController = TabController(
initialIndex: initialIndex,
length: tabs.length,
vsync: this,
);
tabController.addListener(tabControllerListener);
navigationListener = NavigationManager.instance.getCurrentRoute
.listen((route) => setTab(route.label));
}
@override
void dispose() {
tabController.dispose();
navigationListener.cancel();
super.dispose();
}
void setTab(String? tab) {
if (tab == null) return;
int tabIndex = pages.indexWhere((element) => element.label == tab);
if (tabIndex > -1) {
tabController.index = tabIndex;
setState(() {});
}
}
void tabControllerListener() {
NavigationManager.instance.routerDelegate
.push(pages[tabController.index].label!);
}
@override
Widget build(BuildContext context) {
return Container(
color: Colors.blueAccent,
alignment: Alignment.center,
child: Column(
children: [
const Text(
'Second Page',
style: TextStyle(color: Colors.white),
),
TabBar(
controller: tabController,
tabs: tabs,
),
Expanded(
child: AnimatedStack(
duration: const Duration(milliseconds: 500),
crossFadePosition: 0,
animation: (child, animation) {
return FadeThroughAnimation(
key: child.key, animation: animation, child: child);
},
alignment: Alignment.topLeft,
children: NavigationManager.instance
.nested(context: context, routes: pages),
),
),
],
),
);
}
}
Always dispose of StreamSubscriptions and TabControllers to prevent memory leaks.
Create nested page content
Implement the actual nested page widgets:class FirstPage extends StatefulWidget {
static const String name = 'first';
const FirstPage({super.key});
@override
State<FirstPage> createState() => _FirstPageState();
}
class _FirstPageState extends State<FirstPage> {
@override
Widget build(BuildContext context) {
return Container(
color: Colors.amber,
alignment: Alignment.center,
child: const Text(
'First Page',
style: TextStyle(color: Colors.white),
),
);
}
}
class NestedFirstPage extends StatefulWidget {
static const String name = 'nested_first';
const NestedFirstPage({super.key});
@override
State<NestedFirstPage> createState() => _NestedFirstPageState();
}
class _NestedFirstPageState extends State<NestedFirstPage> {
@override
Widget build(BuildContext context) {
return Container(
color: Colors.deepOrangeAccent,
alignment: Alignment.center,
child: const Text(
'Nested First Page',
style: TextStyle(color: Colors.white),
),
);
}
}
class NestedSecondPage extends StatefulWidget {
static const String name = 'nested_second';
const NestedSecondPage({super.key});
@override
State<NestedSecondPage> createState() => _NestedSecondPageState();
}
class _NestedSecondPageState extends State<NestedSecondPage> {
@override
Widget build(BuildContext context) {
return Container(
color: Colors.lightGreen,
alignment: Alignment.center,
child: const Text(
'Nested Second Page',
style: TextStyle(color: Colors.white),
),
);
}
}
Key concepts
AnimatedStack for transitions
Use AnimatedStack to animate between nested pages:
AnimatedStack(
duration: const Duration(milliseconds: 500),
crossFadePosition: 0.3,
alignment: Alignment.topCenter,
initialAnimation: false,
animation: (child, animation) {
return SharedAxisAnimation(
key: child.key,
animation: animation,
transitionType: SharedAxisAnimationType.vertical,
child: child);
},
children: NavigationManager.instance.nested(context: context, routes: pages),
)
Synchronized state management
The navigation state is synchronized through StreamSubscriptions:
-
TabController changes trigger navigation:
void tabControllerListener() {
NavigationManager.instance.routerDelegate
.push(pages[tabController.index].label!);
}
-
Navigation changes update TabController:
navigationListener = NavigationManager.instance.getCurrentRoute
.listen((route) => setTab(route.label));
Always use ValueKey with nested pages to ensure proper widget reuse:
builder: (context, routeData, globalData) =>
const FirstPage(key: ValueKey(FirstPage.name)),
Without ValueKey, Flutter may incorrectly reuse widgets when switching between pages.
Complete runnable example
The complete example is available at:
example/lib/main_nested_tabs.dart
Run it with:
cd example
flutter run -t lib/main_nested_tabs.dart
URL structure
The nested navigation produces these URLs:
/ - First page
/second - Second page with first nested tab
/second/2 - Second page with second nested tab
All URLs are deep-linkable and maintain state correctly when navigating via URL.
Next steps
Custom transitions
Add custom animations to your nested pages
Basic setup
Review the fundamentals of NavigationUtils