Almost every production Flutter app has a bottom navigation bar. It is how users jump between top-level sections — Home, Search, Profile — without losing their place in any of them. It looks simple, but beginners hit the same bugs every time: tabs not switching, the selected highlight not updating, the bar disappearing after a navigation push, or scroll position resetting on every tab change.
This guide builds a complete bottom navigation bar step by step, starting from the minimal three-screen version and progressing to screen state persistence, the updated Material 3 NavigationBar widget, notification badges, and a properly structured multi-screen app shell. Every common mistake has a working fix.
- Comfortable with StatefulWidget and setState() — the entire nav bar depends on it
- Know the basics of Flutter Navigation: Push, Pop, and Named Routes — especially what happens when you push a new route
- Understand
Scaffoldand itsbodyproperty — covered in our Flutter Tutorial for Beginners
1. How BottomNavigationBar Works
Flutter’s BottomNavigationBar is a Material widget placed in Scaffold.bottomNavigationBar. It shows a row of labelled icon tabs at the bottom of the screen. The mental model has three moving parts:
| Part | What it is | Your responsibility |
|---|---|---|
currentIndex | Integer that tells the bar which tab is highlighted | Store it in state, pass it to the bar |
onTap | Callback fired with the tapped tab’s index | Call setState() to update currentIndex |
body | The main content area of the Scaffold | Show _screens[_selectedIndex] |
// The core loop — this is everything:
User taps tab 2
↓
onTap(2) fires
↓
setState(() { _selectedIndex = 2; })
↓
Flutter rebuilds Scaffold:
- body = _screens[2] ← shows the Profile screen
- currentIndex = 2 ← highlights the Profile tab
With 3 items, the bar defaults to
BottomNavigationBarType.fixed — all tabs are always visible with equal width and labels always shown. With 4 or more items, it defaults to BottomNavigationBarType.shifting — unselected tabs show smaller with no label. You can override the type explicitly if you want different behaviour.
2. The Minimal 3-Screen Example
Here is the smallest possible working bottom navigation bar. Read the comments — every line has a reason:
import 'package:flutter/material.dart';
void main() {
runApp(const MaterialApp(home: MainScreen()));
}
class MainScreen extends StatefulWidget {
const MainScreen({super.key});
@override
State<MainScreen> createState() => _MainScreenState();
}
class _MainScreenState extends State<MainScreen> {
// 1. The selected tab index — starts at 0 (first tab)
int _selectedIndex = 0;
// 2. One widget per tab — order must match the BottomNavigationBarItems below
final List<Widget> _screens = const [
HomeScreen(),
SearchScreen(),
ProfileScreen(),
];
// 3. Called when any tab is tapped — updates the index and rebuilds
void _onItemTapped(int index) {
setState(() {
_selectedIndex = index;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
// 4. Show the screen at the current index
body: _screens[_selectedIndex],
// 5. The bar — currentIndex and onTap must both be wired up
bottomNavigationBar: BottomNavigationBar(
currentIndex: _selectedIndex, // ← highlights the active tab
onTap: _onItemTapped, // ← fires when user taps a tab
items: const [
BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Search'),
BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profile'),
],
),
);
}
}
// Simple placeholder screens
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) =>
const Center(child: Text('Home', style: TextStyle(fontSize: 24)));
}
class SearchScreen extends StatelessWidget {
const SearchScreen({super.key});
@override
Widget build(BuildContext context) =>
const Center(child: Text('Search', style: TextStyle(fontSize: 24)));
}
class ProfileScreen extends StatelessWidget {
const ProfileScreen({super.key});
@override
Widget build(BuildContext context) =>
const Center(child: Text('Profile', style: TextStyle(fontSize: 24)));
}
3. Full Example with Titles, Colors, and Custom Icons
The minimal version works, but production apps need a few more things: a per-tab AppBar title, custom selected/unselected colours, and a slightly more polished look. Here is the complete version:
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
home: const MainScreen(),
);
}
}
class MainScreen extends StatefulWidget {
const MainScreen({super.key});
@override
State<MainScreen> createState() => _MainScreenState();
}
class _MainScreenState extends State<MainScreen> {
int _selectedIndex = 0;
// Tab configuration — title, icon, and screen widget in one place
static const List<_TabItem> _tabs = [
_TabItem(title: 'Home', icon: Icons.home_outlined, activeIcon: Icons.home, screen: HomeScreen()),
_TabItem(title: 'Search', icon: Icons.search_outlined, activeIcon: Icons.search, screen: SearchScreen()),
_TabItem(title: 'Profile', icon: Icons.person_outline, activeIcon: Icons.person, screen: ProfileScreen()),
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
// Title updates automatically as the tab changes
title: Text(_tabs[_selectedIndex].title),
centerTitle: true,
elevation: 0,
),
body: _tabs[_selectedIndex].screen,
bottomNavigationBar: BottomNavigationBar(
currentIndex: _selectedIndex,
onTap: (index) => setState(() => _selectedIndex = index),
selectedItemColor: Theme.of(context).colorScheme.primary,
unselectedItemColor: Colors.grey.shade500,
selectedLabelStyle: const TextStyle(fontWeight: FontWeight.w600),
showUnselectedLabels: true,
type: BottomNavigationBarType.fixed,
items: _tabs.map((tab) => BottomNavigationBarItem(
icon: Icon(tab.icon),
activeIcon: Icon(tab.activeIcon), // filled icon when selected
label: tab.title,
)).toList(),
),
);
}
}
// Helper class to keep tab config together
class _TabItem {
final String title;
final IconData icon;
final IconData activeIcon;
final Widget screen;
const _TabItem({
required this.title,
required this.icon,
required this.activeIcon,
required this.screen,
});
}
// ── Screen widgets ──────────────────────────────────────────────────────────
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.all(20),
children: [
const Text('Welcome back!', style: TextStyle(fontSize: 26, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Text('Here is your home feed.', style: TextStyle(fontSize: 16, color: Colors.grey.shade600)),
const SizedBox(height: 24),
...List.generate(8, (i) => Card(
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
leading: CircleAvatar(child: Text('${i + 1}')),
title: Text('Post ${i + 1}'),
subtitle: const Text('Tap to read more'),
trailing: const Icon(Icons.arrow_forward_ios, size: 14),
),
)),
],
);
}
}
class SearchScreen extends StatelessWidget {
const SearchScreen({super.key});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
decoration: InputDecoration(
hintText: 'Search...',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
filled: true,
fillColor: Colors.grey.shade100,
),
),
const SizedBox(height: 24),
const Text('Recent searches', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16)),
const SizedBox(height: 12),
Wrap(
spacing: 8,
children: ['Flutter', 'Dart', 'Widgets', 'Navigation', 'State'].map((term) =>
Chip(label: Text(term), onDeleted: () {}),
).toList(),
),
],
),
);
}
}
class ProfileScreen extends StatelessWidget {
const ProfileScreen({super.key});
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(height: 20),
const CircleAvatar(radius: 48, child: Icon(Icons.person, size: 48)),
const SizedBox(height: 16),
const Text('Flutter Developer', style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Text('[email protected]', style: TextStyle(color: Colors.grey.shade600)),
const SizedBox(height: 28),
...['Edit Profile', 'Notifications', 'Privacy', 'Help', 'Sign Out'].map((item) =>
ListTile(
title: Text(item),
trailing: const Icon(Icons.chevron_right),
onTap: () {},
),
),
],
),
);
}
}
4. Keeping Screen State Alive with IndexedStack
The examples above swap screens by showing _screens[_selectedIndex] — this works, but it destroys and recreates each screen every time you switch tabs. That means scroll position resets, form data is lost, and any async data fetching repeats.
The fix is IndexedStack. It keeps all screens mounted in the widget tree simultaneously but only makes the selected one visible. Scroll position, state, and loaded data all survive tab switches:
// Without IndexedStack — screens are destroyed and rebuilt on every tab switch
body: _screens[_selectedIndex], // ← HomeScreen is discarded when you leave it
// ─────────────────────────────────────────────────────────────────────────
// With IndexedStack — all screens stay alive, only visibility changes
body: IndexedStack(
index: _selectedIndex, // ← only this one is visible
children: const [
HomeScreen(),
SearchScreen(),
ProfileScreen(),
],
),
// HomeScreen is still mounted when you switch to Search — state is preserved
Here is a concrete demonstration — the counter on the Home tab survives switching away and back because IndexedStack keeps HomeScreen alive:
import 'package:flutter/material.dart';
void main() {
runApp(const MaterialApp(home: PersistentNavDemo()));
}
class PersistentNavDemo extends StatefulWidget {
const PersistentNavDemo({super.key});
@override
State<PersistentNavDemo> createState() => _PersistentNavDemoState();
}
class _PersistentNavDemoState extends State<PersistentNavDemo> {
int _selectedIndex = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('State Persistence Demo')),
// IndexedStack keeps all screens mounted at all times
body: IndexedStack(
index: _selectedIndex,
children: const [
CounterScreen(label: 'Home Counter'), // ← stays alive when hidden
CounterScreen(label: 'Search Counter'),
CounterScreen(label: 'Profile Counter'),
],
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _selectedIndex,
onTap: (i) => setState(() => _selectedIndex = i),
selectedItemColor: Colors.indigo,
items: const [
BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Search'),
BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profile'),
],
),
);
}
}
// A stateful screen with a counter — proves state is kept when tabs switch
class CounterScreen extends StatefulWidget {
final String label;
const CounterScreen({super.key, required this.label});
@override
State<CounterScreen> createState() => _CounterScreenState();
}
class _CounterScreenState extends State<CounterScreen> {
int _count = 0;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(widget.label, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
Text('$_count', style: const TextStyle(fontSize: 56)),
const SizedBox(height: 16),
// Switch to another tab and back — the count survives
ElevatedButton.icon(
onPressed: () => setState(() => _count++),
icon: const Icon(Icons.add),
label: const Text('Increment'),
),
],
),
);
}
}
Because all screens stay mounted simultaneously, they all consume memory at once. For simple apps this is fine. For screens that load large images or data sets, consider using
AutomaticKeepAliveClientMixin on individual screens that need persistence, rather than keeping everything alive.
5. Material 3: NavigationBar (The Modern Way)
Flutter’s docs say NavigationBar is the updated Material 3 replacement for BottomNavigationBar and is the preferred choice for new apps. It has a more modern pill-shaped indicator, cleaner typography, and better integration with ColorScheme. The API is very similar:
| Property | BottomNavigationBar | NavigationBar (M3) |
|---|---|---|
| Selected tab | currentIndex | selectedIndex |
| Tap handler | onTap | onDestinationSelected |
| Tab items | BottomNavigationBarItem | NavigationDestination |
| Selection indicator | Coloured label + icon | Pill-shaped background |
| Material version | Material 2 | Material 3 ✅ preferred |
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true, // ← required to get the M3 NavigationBar styling
),
home: const M3NavDemo(),
);
}
}
class M3NavDemo extends StatefulWidget {
const M3NavDemo({super.key});
@override
State<M3NavDemo> createState() => _M3NavDemoState();
}
class _M3NavDemoState extends State<M3NavDemo> {
int _selectedIndex = 0;
final List<Widget> _screens = const [
HomeScreen(),
SearchScreen(),
FavouritesScreen(),
ProfileScreen(),
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(
index: _selectedIndex,
children: _screens,
),
// NavigationBar replaces BottomNavigationBar in Material 3
bottomNavigationBar: NavigationBar(
selectedIndex: _selectedIndex, // ← note: selectedIndex, not currentIndex
onDestinationSelected: (index) => // ← note: onDestinationSelected, not onTap
setState(() => _selectedIndex = index),
destinations: const [
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home), // ← filled icon when selected
label: 'Home',
),
NavigationDestination(
icon: Icon(Icons.search_outlined),
selectedIcon: Icon(Icons.search),
label: 'Search',
),
NavigationDestination(
icon: Icon(Icons.favorite_outline),
selectedIcon: Icon(Icons.favorite),
label: 'Favourites',
),
NavigationDestination(
icon: Icon(Icons.person_outline),
selectedIcon: Icon(Icons.person),
label: 'Profile',
),
],
),
);
}
}
class FavouritesScreen extends StatelessWidget {
const FavouritesScreen({super.key});
@override
Widget build(BuildContext context) =>
const Center(child: Text('Favourites', style: TextStyle(fontSize: 24)));
}
// Re-use HomeScreen, SearchScreen, ProfileScreen from the earlier examples
If you are starting a new Flutter project in 2026, use
NavigationBar with useMaterial3: true in your ThemeData. It is the current recommended approach. Only use the older BottomNavigationBar if you are maintaining a legacy app that has not migrated to Material 3 yet.
6. Adding Notification Badges
Notification badges — the red dot or number on a tab — are common in real apps. With NavigationBar, wrap the icon in a Badge widget. With BottomNavigationBar, wrap it in a Stack manually:
// ── Badge with NavigationBar (Material 3) ──────────────────────────────────
NavigationDestination(
// Badge widget wraps the icon — shows a number or a plain dot
icon: Badge(
label: Text('3'), // shows "3" in the badge
child: Icon(Icons.notifications_outlined),
),
selectedIcon: Badge(
label: Text('3'),
child: Icon(Icons.notifications),
),
label: 'Alerts',
),
// For a plain dot badge (no number), omit the label:
NavigationDestination(
icon: Badge(
child: Icon(Icons.mail_outline),
),
selectedIcon: Badge(
child: Icon(Icons.mail),
),
label: 'Messages',
),
// ─────────────────────────────────────────────────────────────────────────
// ── Dynamic badge count from state ───────────────────────────────────────
class _M3NavDemoState extends State<M3NavDemo> {
int _selectedIndex = 0;
int _notificationCount = 5; // from your data source
@override
Widget build(BuildContext context) {
return Scaffold(
bottomNavigationBar: NavigationBar(
selectedIndex: _selectedIndex,
onDestinationSelected: (i) => setState(() => _selectedIndex = i),
destinations: [
const NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: 'Home',
),
NavigationDestination(
icon: _notificationCount > 0
? Badge(
label: Text('$_notificationCount'),
child: const Icon(Icons.notifications_outlined),
)
: const Icon(Icons.notifications_outlined),
selectedIcon: const Icon(Icons.notifications),
label: 'Alerts',
),
const NavigationDestination(
icon: Icon(Icons.person_outline),
selectedIcon: Icon(Icons.person),
label: 'Profile',
),
],
),
body: IndexedStack(
index: _selectedIndex,
children: const [HomeScreen(), AlertsScreen(), ProfileScreen()],
),
);
}
}
class AlertsScreen extends StatelessWidget {
const AlertsScreen({super.key});
@override
Widget build(BuildContext context) =>
const Center(child: Text('Alerts', style: TextStyle(fontSize: 24)));
}
7. Dynamic AppBar Title per Tab
Most real apps update the AppBar title when the active tab changes. The cleanest way is to derive the title from a list keyed to the tab index, so there is a single source of truth:
class _MainScreenState extends State<MainScreen> {
int _selectedIndex = 0;
// Titles indexed to match the tab order
static const List<String> _titles = ['Home', 'Search', 'Profile'];
static const List<Widget> _screens = [HomeScreen(), SearchScreen(), ProfileScreen()];
static const List<IconData> _icons = [Icons.home, Icons.search, Icons.person];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(_titles[_selectedIndex]), // ← updates automatically
actions: [
// Example: only show a settings icon on the Profile tab
if (_selectedIndex == 2)
IconButton(
icon: const Icon(Icons.settings),
onPressed: () {
// push a settings detail page
},
),
],
),
body: IndexedStack(
index: _selectedIndex,
children: _screens,
),
bottomNavigationBar: NavigationBar(
selectedIndex: _selectedIndex,
onDestinationSelected: (i) => setState(() => _selectedIndex = i),
destinations: List.generate(
_titles.length,
(i) => NavigationDestination(
icon: Icon(_icons[i]),
label: _titles[i],
),
),
),
);
}
}
8. Common Beginner Mistakes
Mistake 1: Not calling setState — bar highlights but screen doesn’t change (or vice versa)
// ❌ Wrong — _selectedIndex is updated but setState is not called
void _onItemTapped(int index) {
_selectedIndex = index; // value changes in memory but Flutter doesn't know
// UI never rebuilds — body still shows the old screen
}
// ✅ Correct — setState triggers a rebuild so body and bar both update
void _onItemTapped(int index) {
setState(() {
_selectedIndex = index; // Flutter now knows to rebuild
});
}
Mistake 2: _selectedIndex lives in the wrong widget — bar and screen are out of sync
// ❌ Wrong — BottomNavigationBar is in a separate child widget from the body.
// When the child bar calls setState, it only rebuilds the child —
// the parent Scaffold body never updates.
// ✅ Correct — _selectedIndex, body: _screens[_selectedIndex], AND
// the BottomNavigationBar must all live in the SAME StatefulWidget.
// The setState there rebuilds the Scaffold with the new body AND new currentIndex together.
class _MainScreenState extends State<MainScreen> {
int _selectedIndex = 0; // ← here
Widget build(BuildContext context) {
return Scaffold(
body: _screens[_selectedIndex], // ← and here
bottomNavigationBar: BottomNavigationBar(
currentIndex: _selectedIndex, // ← and here
onTap: (i) => setState(() => _selectedIndex = i),
...
),
);
}
}
Mistake 3: Bar disappears after Navigator.push
// ❌ Wrong — pushing a new route replaces the whole screen including the Scaffold
// that holds the bar. The detail page has no bottom nav bar.
onTap: () {
Navigator.push(context, MaterialPageRoute(builder: (_) => const DetailPage()));
}
// ✅ Correct — push detail pages from INSIDE a tab's screen widget, not from the
// MainScreen level. The MainScreen Scaffold (with the bar) stays behind the pushed page.
// The bar reappears when the user pops back.
// In HomeScreen:
onTap: () {
Navigator.push(context, MaterialPageRoute(builder: (_) => const ArticleDetailPage()));
// ArticleDetailPage is a full-page route — the bar is gone while it's open,
// and comes back when the user presses Back. This is the correct pattern.
}
// If you need the bar visible on a detail page, keep it as a tab body instead
// of pushing it as a new route.
Mistake 4: Screens order in the list doesn’t match items order in the bar
// ❌ Wrong — screens list order doesn't match bar items order
final List<Widget> _screens = [HomeScreen(), ProfileScreen(), SearchScreen()];
// 0 1 2
BottomNavigationBar(items: [
BottomNavigationBarItem(label: 'Home'), // index 0 → shows HomeScreen ✅
BottomNavigationBarItem(label: 'Search'), // index 1 → shows ProfileScreen ❌ WRONG
BottomNavigationBarItem(label: 'Profile'), // index 2 → shows SearchScreen ❌ WRONG
])
// ✅ Correct — always match the screen list order to the bar items order exactly
final List<Widget> _screens = [HomeScreen(), SearchScreen(), ProfileScreen()];
// 0 1 2
BottomNavigationBar(items: [
BottomNavigationBarItem(label: 'Home'), // 0 → HomeScreen ✅
BottomNavigationBarItem(label: 'Search'), // 1 → SearchScreen ✅
BottomNavigationBarItem(label: 'Profile'), // 2 → ProfileScreen ✅
])
Mistake 5: Confusing BottomNavigationBar and NavigationBar property names
// ❌ Wrong — using BottomNavigationBar property names on NavigationBar
NavigationBar(
currentIndex: _selectedIndex, // ← wrong, this property doesn't exist
onTap: (i) => ..., // ← wrong, this property doesn't exist
items: [...], // ← wrong, NavigationBar uses 'destinations'
)
// ✅ Correct — NavigationBar uses different property names
NavigationBar(
selectedIndex: _selectedIndex, // ← correct
onDestinationSelected: (i) => setState(() => _selectedIndex = i), // ← correct
destinations: [ // ← correct
NavigationDestination(icon: ..., label: ...),
],
)
9. Interview Q&A
A: It is a Material widget placed in
Scaffold.bottomNavigationBar that shows a row of icon-and-label tabs at the bottom of the screen. It is used for switching between a small number of top-level views — typically 3 to 5. The selected tab is controlled with currentIndex and taps are handled with onTap.
A:
currentIndex tells the bar which tab to highlight — you pass your state variable here. onTap is a callback that fires when the user taps a tab, receiving the tapped index. Inside onTap you call setState() to update currentIndex, which causes both the bar highlight and the body content to update together.
A: The two most common causes are: (1) you are updating
_selectedIndex but not inside a setState() call — Flutter never rebuilds, so the screen never changes; or (2) the _selectedIndex variable, the body, and the BottomNavigationBar live in different widgets — setState in one widget only rebuilds that widget, not the others. All three must live in the same StatefulWidget.
A: Because
Navigator.push() places a completely new page on top of the current one — including on top of the Scaffold that contains the bar. The new page has no bar of its own. This is actually correct behaviour for a detail page (users expect to go back). If you need the bar to stay visible, keep that screen as a tab body and swap it in instead of pushing a new route.
A: Use
IndexedStack as the body instead of _screens[_selectedIndex]. IndexedStack keeps all its children mounted in the widget tree simultaneously and only shows the one at the selected index. Scroll position, form data, and loaded state all survive tab switches.
A: Yes —
NavigationBar is the Material 3 replacement and is the recommended choice for new projects. It uses selectedIndex and onDestinationSelected instead of currentIndex and onTap, and its items are NavigationDestination widgets instead of BottomNavigationBarItem. Enable it by setting useMaterial3: true in your ThemeData.
10. Related Posts
| Post | Why it’s relevant |
|---|---|
| Flutter Widgets Explained: Stateless vs Stateful | The navigation bar depends entirely on StatefulWidget and setState() — this is the prerequisite to understand first. |
| Flutter Navigation Explained: Push, Pop, and Named Routes | Explains what happens when you call Navigator.push() — essential for understanding why the nav bar disappears on pushed routes. |
| Flutter Layout Made Easy: Row, Column, Flex and Expanded | Understanding layout fundamentals makes it easier to build the screen widgets that sit inside each tab. |
| Building a Notes App – Part 1 | A real Flutter project that puts Scaffold, AppBar, and list widgets together — the same structure used for each tab screen here. |
| Flutter Search Bar from Scratch | The Search tab in this guide is a perfect starting point — add a real-time filtering search bar to complete it. |
