- Published on
Navigators in Flutter
- Authors
- Name
- Loi Tran
Introduction

Most applications make use of the following navigators in some form.
- Drawer Navigator
- Tab Navigator
- Stack Navigator
The hard part is using them together seamlessly where accompanying widgets behave as we expect. For example:
- Outter Drawer Navigator shared between multiple screens
- Nested Tab Navigators
- Nested Stack Navigators
- Nested Tab Navigators
- Dynamic header on certain screens.
- Hamburger/Back icons when & where appropriate.
- Navigation state of differing tabs maintain state across tab changes.
This is how I'd achieve this functionality in Dart/Flutter
import 'package:client/features/presentation/pages/explore_screen.dart';
import 'package:flutter/material.dart';
class AppRoot extends StatelessWidget {
const AppRoot({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'Datelendar',
debugShowCheckedModeBanner: true,
initialRoute: '/app',
routes: {
'/onboarding': (_) => const OnboardingScreen(),
'/app': (_) => const MainDrawerScreen(),
},
);
}
}
class CalendarDetail extends StatelessWidget {
const CalendarDetail({super.key});
Widget build(BuildContext context) {
return const Center(child: Text('Details inside Calendar tab'));
}
}
class CalendarTab extends StatelessWidget {
const CalendarTab({super.key});
Widget build(BuildContext context) {
return _TabScaffold(
title: 'Calendar',
child: Center(
child: ElevatedButton(
onPressed: () {
final parent = context
.findAncestorStateOfType<_MainDrawerScreenState>();
parent?.notifyInnerNavChanged();
Navigator.of(context)
.push(MaterialPageRoute(builder: (_) => const CalendarDetail()))
.then((_) => parent?.notifyInnerNavChanged());
},
child: const Text('Open Calendar Detail'),
),
),
);
}
}
class HomeDetail extends StatelessWidget {
const HomeDetail({super.key});
Widget build(BuildContext context) {
return const Center(child: Text('Details inside Home tab'));
}
}
class HomeTab extends StatelessWidget {
const HomeTab({super.key});
Widget build(BuildContext context) {
return _TabScaffold(
title: 'Home',
child: Center(
child: ElevatedButton(
onPressed: () {
final parent = context
.findAncestorStateOfType<_MainDrawerScreenState>();
parent?.notifyInnerNavChanged();
Navigator.of(context)
.push(MaterialPageRoute(builder: (_) => const HomeDetail()))
.then((_) => parent?.notifyInnerNavChanged());
},
child: const Text('Open Home Detail'),
),
),
);
}
}
class MainDrawerScreen extends StatefulWidget {
const MainDrawerScreen({super.key});
State<MainDrawerScreen> createState() => _MainDrawerScreenState();
static dynamic of(BuildContext context) =>
context.findAncestorStateOfType<_MainDrawerScreenState>();
}
class OnboardingScreen extends StatelessWidget {
const OnboardingScreen({super.key});
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Welcome to Datelendar')),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'Onboarding goes here. Swipe or tap continue to enter the app.',
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () {
Navigator.of(context).pushReplacementNamed('/app');
},
child: const Text('Continue to app'),
),
],
),
),
);
}
}
class ProfileDetail extends StatelessWidget {
const ProfileDetail({super.key});
Widget build(BuildContext context) {
return const Center(child: Text('Details inside Profile tab'));
}
}
class ProfileTab extends StatelessWidget {
const ProfileTab({super.key});
Widget build(BuildContext context) {
return _TabScaffold(
title: 'Profile',
child: Center(
child: ElevatedButton(
onPressed: () {
final parent = context
.findAncestorStateOfType<_MainDrawerScreenState>();
parent?.notifyInnerNavChanged();
Navigator.of(context)
.push(MaterialPageRoute(builder: (_) => const ProfileDetail()))
.then((_) => parent?.notifyInnerNavChanged());
},
child: const Text('Open Profile Detail'),
),
),
);
}
}
class SearchDetail extends StatelessWidget {
const SearchDetail({super.key});
Widget build(BuildContext context) {
return const Center(child: Text('Details inside Search tab'));
}
}
class SearchTab extends StatelessWidget {
const SearchTab({super.key});
Widget build(BuildContext context) {
return _TabScaffold(
title: 'Search',
child: Center(
child: ElevatedButton(
onPressed: () {
final parent = context
.findAncestorStateOfType<_MainDrawerScreenState>();
parent?.notifyInnerNavChanged();
Navigator.of(context)
.push(MaterialPageRoute(builder: (_) => const SearchDetail()))
.then((_) => parent?.notifyInnerNavChanged());
},
child: const Text('Open Search Detail'),
),
),
);
}
}
class _BottomNavBar extends StatelessWidget {
final int currentIndex;
final ValueChanged<int> onTap;
const _BottomNavBar({
required this.currentIndex,
required this.onTap,
super.key,
});
Widget build(BuildContext context) {
return BottomNavigationBar(
type: BottomNavigationBarType.fixed,
currentIndex: currentIndex,
onTap: onTap,
backgroundColor: Theme.of(context).colorScheme.surface,
elevation: 8,
selectedItemColor: Theme.of(context).colorScheme.primary,
unselectedItemColor: Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.7),
showUnselectedLabels: true,
items: [
BottomNavigationBarItem(
icon: Icon(
Icons.home,
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
),
activeIcon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle,
),
child: Icon(
Icons.home,
color: Theme.of(context).colorScheme.onPrimary,
),
),
label: 'Home',
),
BottomNavigationBarItem(
icon: Icon(
Icons.search,
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
),
activeIcon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle,
),
child: Icon(
Icons.search,
color: Theme.of(context).colorScheme.onPrimary,
),
),
label: 'Search',
),
BottomNavigationBarItem(
icon: Icon(
Icons.calendar_today,
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
),
activeIcon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle,
),
child: Icon(
Icons.calendar_today,
color: Theme.of(context).colorScheme.onPrimary,
),
),
label: 'Calendar',
),
BottomNavigationBarItem(
icon: Icon(
Icons.person,
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
),
activeIcon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle,
),
child: Icon(
Icons.person,
color: Theme.of(context).colorScheme.onPrimary,
),
),
label: 'Profile',
),
],
);
}
}
class _MainDrawerScreenState extends State<MainDrawerScreen> {
int _currentIndex = 0;
final ValueNotifier<Widget?> _headerNotifier = ValueNotifier<Widget?>(null);
final List<BuildContext?> _navigatorContexts = List<BuildContext?>.filled(
4,
null,
);
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
final List<String> _titles = const ['Home', 'Search', 'Calendar', 'Profile'];
int get currentIndex => _currentIndex;
Widget build(BuildContext context) {
final bool canPop = _navigatorContexts[_currentIndex] != null
? Navigator.of(
_navigatorContexts[_currentIndex]!,
rootNavigator: false,
).canPop()
: false;
return WillPopScope(
onWillPop: _onWillPop,
child: Scaffold(
key: _scaffoldKey,
appBar: AppBar(
title: ValueListenableBuilder<Widget?>(
valueListenable: _headerNotifier,
builder: (context, header, _) {
if (header != null) return header;
return Text(_titles[_currentIndex]);
},
),
leading: canPop
? BackButton(
onPressed: () {
final ctx = _navigatorContexts[_currentIndex];
if (ctx != null)
Navigator.of(ctx, rootNavigator: false).pop();
setState(() {});
},
)
: IconButton(
icon: const Icon(Icons.menu),
onPressed: () => _scaffoldKey.currentState?.openDrawer(),
),
),
drawer: Drawer(
child: SafeArea(
child: Column(
children: [
const DrawerHeader(
child: Text('Menu', style: TextStyle(fontSize: 24)),
),
ListTile(
leading: const Icon(Icons.home),
title: const Text('Home'),
onTap: () {
Navigator.of(context).pop();
_setTab(0);
},
),
ListTile(
leading: const Icon(Icons.search),
title: const Text('Search'),
onTap: () {
Navigator.of(context).pop();
_setTab(1);
},
),
ListTile(
leading: const Icon(Icons.calendar_today),
title: const Text('Calendar'),
onTap: () {
Navigator.of(context).pop();
_setTab(2);
},
),
ListTile(
leading: const Icon(Icons.person),
title: const Text('Profile'),
onTap: () {
Navigator.of(context).pop();
_setTab(3);
},
),
const Spacer(),
ListTile(
leading: const Icon(Icons.logout),
title: const Text('Restart onboarding'),
onTap: () {
Navigator.of(context).pushReplacementNamed('/onboarding');
},
),
],
),
),
),
body: IndexedStack(
index: _currentIndex,
children: List.generate(4, (index) => _buildNavigator(index)),
),
bottomNavigationBar: _BottomNavBar(
currentIndex: _currentIndex,
onTap: (i) => _setTab(i),
),
),
);
}
void notifyInnerNavChanged() {
setState(() {});
}
void setHeader(Widget? header) {
_headerNotifier.value = header;
}
Widget _buildNavigator(int index) {
return Navigator(
key: ValueKey('nested-navigator-$index'),
onGenerateRoute: (settings) {
late Widget page;
switch (index) {
case 0:
page = const ExploreScreen();
break;
case 1:
page = const SearchTab();
break;
case 2:
page = const CalendarTab();
break;
case 3:
page = const ProfileTab();
break;
default:
page = const SizedBox.shrink();
}
return MaterialPageRoute(
builder: (ctx) {
_navigatorContexts[index] = ctx;
return page;
},
);
},
);
}
Widget? _defaultHeaderForIndex(int index) {
switch (index) {
case 1:
return const _SearchHeader();
default:
return null;
}
}
Future<bool> _onWillPop() async {
final ctx = _navigatorContexts[_currentIndex];
if (ctx != null) {
final currentNavigator = Navigator.of(ctx, rootNavigator: false);
if (currentNavigator.canPop()) {
currentNavigator.pop();
setState(() {});
return false;
}
}
return true;
}
void _setTab(int index) {
if (index < 0 || index >= _navigatorContexts.length) return;
setState(() => _currentIndex = index);
setHeader(_defaultHeaderForIndex(index));
}
}
class _SearchHeader extends StatelessWidget {
const _SearchHeader({super.key});
Widget build(BuildContext context) {
return SizedBox(
height: 40,
child: TextField(
textInputAction: TextInputAction.search,
decoration: InputDecoration(
hintText: 'Search venues…',
filled: true,
fillColor: Theme.of(context).colorScheme.surfaceContainerHighest,
prefixIcon: const Icon(Icons.search),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
),
onSubmitted: (query) {
},
),
);
}
}
class _TabScaffold extends StatelessWidget {
final String title;
final Widget child;
const _TabScaffold({required this.title, required this.child, super.key});
Widget build(BuildContext context) {
final double bottomInset =
MediaQuery.of(context).padding.bottom + kBottomNavigationBarHeight;
return SafeArea(
child: Padding(
padding: EdgeInsets.fromLTRB(16.0, 16.0, 16.0, 16.0 + bottomInset),
child: child,
),
);
}
}