Tab Navigation with BottomNavigationBar
Tab Navigation with BottomNavigationBar
Most mobile applications provide a persistent bottom bar that lets users jump between the app's top-level sections with a single tap. In Flutter, BottomNavigationBar is the canonical widget for this pattern. Combined with IndexedStack, it keeps each tab's widget subtree alive so that scroll positions, form inputs, and loaded data are preserved when the user switches tabs — exactly the behaviour users expect from a polished app.
How BottomNavigationBar Works
A BottomNavigationBar is placed inside a Scaffold's bottomNavigationBar slot. It holds a list of BottomNavigationBarItem widgets — each with an icon and a label. The bar does not navigate on its own; it reports the tapped index through its onTap callback. Your StatefulWidget stores the current index in state and uses it to decide which screen to display in the body.
BottomNavigationBar requires at least two items. When you have more than three, consider using a BottomNavigationBarType.fixed type so all labels remain visible. The default type is shifting, which animates labels and hides unselected ones.Naive Body Swap vs IndexedStack
The simplest approach is a conditional expression in the body that returns a different widget for each index. This works for static screens, but it destroys and recreates each screen every time the user switches tabs, losing all local state (scroll position, fetched data, text input, etc.).
IndexedStack solves this problem elegantly. It builds all child widgets at once and keeps them alive in memory, but only makes the child at index visible. Switching tabs merely changes which child is rendered — the others remain intact.
Example 1 — Basic BottomNavigationBar with IndexedStack
import 'package:flutter/material.dart';
class MainShell extends StatefulWidget {
const MainShell({super.key});
@override
State<MainShell> createState() => _MainShellState();
}
class _MainShellState extends State<MainShell> {
int _selectedIndex = 0;
// All top-level screens declared once and kept alive
static const List<Widget> _screens = [
HomeScreen(),
SearchScreen(),
ProfileScreen(),
];
void _onItemTapped(int index) {
setState(() {
_selectedIndex = index;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(
index: _selectedIndex,
children: _screens,
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _selectedIndex,
onTap: _onItemTapped,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.home_outlined),
activeIcon: Icon(Icons.home),
label: 'Home',
),
BottomNavigationBarItem(
icon: Icon(Icons.search_outlined),
activeIcon: Icon(Icons.search),
label: 'Search',
),
BottomNavigationBarItem(
icon: Icon(Icons.person_outline),
activeIcon: Icon(Icons.person),
label: 'Profile',
),
],
),
);
}
}
_screens list as static const. This ensures the screen widgets are created only once for the lifetime of the state object — not rebuilt on every setState call. For screens that require constructor parameters, initialise the list in initState() instead.Styling the Bar
You can customise every visual aspect of the bar without leaving the widget's own constructor:
selectedItemColorandunselectedItemColor— icon and label colours.backgroundColor— the bar's background.selectedFontSize/unselectedFontSize— label sizes.elevation— shadow depth above the bar.type: BottomNavigationBarType.fixed— keep all labels visible (required for four or more items).
Preserving State Inside Tabs
Because IndexedStack keeps every child widget in the widget tree, any StatefulWidget nested inside a tab retains its state automatically. However, if a tab widget rebuilds (for example, because it receives new data from a stream), only that tab's subtree is redrawn — the other tabs are unaffected.
IndexedStack's children list inside the build method if those widgets carry heavy state. Every call to build would instantiate new widget objects (though Flutter reconciles them via keys). The static list pattern shown above is the correct approach.Example 2 — Four-Tab App with Styling and Badge Counter
import 'package:flutter/material.dart';
class AppShell extends StatefulWidget {
const AppShell({super.key});
@override
State<AppShell> createState() => _AppShellState();
}
class _AppShellState extends State<AppShell> {
int _currentIndex = 0;
int _notificationCount = 3; // Simulated badge count
late final List<Widget> _pages;
@override
void initState() {
super.initState();
_pages = [
const DashboardPage(),
const ExplorePage(),
const NotificationsPage(),
const SettingsPage(),
];
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(
index: _currentIndex,
children: _pages,
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _currentIndex,
onTap: (index) => setState(() => _currentIndex = index),
type: BottomNavigationBarType.fixed,
selectedItemColor: Theme.of(context).colorScheme.primary,
unselectedItemColor: Colors.grey,
elevation: 8,
items: [
const BottomNavigationBarItem(
icon: Icon(Icons.dashboard_outlined),
activeIcon: Icon(Icons.dashboard),
label: 'Dashboard',
),
const BottomNavigationBarItem(
icon: Icon(Icons.explore_outlined),
activeIcon: Icon(Icons.explore),
label: 'Explore',
),
BottomNavigationBarItem(
icon: Badge(
label: Text('$_notificationCount'),
isLabelVisible: _notificationCount > 0,
child: const Icon(Icons.notifications_outlined),
),
activeIcon: Badge(
label: Text('$_notificationCount'),
isLabelVisible: _notificationCount > 0,
child: const Icon(Icons.notifications),
),
label: 'Alerts',
),
const BottomNavigationBarItem(
icon: Icon(Icons.settings_outlined),
activeIcon: Icon(Icons.settings),
label: 'Settings',
),
],
),
);
}
}
When to Use NavigationBar Instead
Flutter 3 introduced NavigationBar (Material 3) as the modern successor to BottomNavigationBar. It follows the Material You design language with pill-shaped indicators and larger touch targets. The API is nearly identical — swap the widget name and replace BottomNavigationBarItem with NavigationDestination. Both widgets integrate seamlessly with IndexedStack.
Summary
Tab navigation in Flutter is straightforward: a StatefulWidget tracks the selected index, BottomNavigationBar (or NavigationBar) reports taps, and IndexedStack renders all tab bodies while keeping their state alive. This three-widget combination is the foundation for virtually every tab-based Flutter application.
IndexedStack over a naive body swap when your tabs contain stateful widgets. The performance cost of keeping invisible widgets alive is negligible compared with the user-experience gain of preserved scroll positions and loaded data.