Bottom Menus
Implement bottom navigation with BottomNavigationBar, use IndexedStack to preserve page state, and manage tabs with PageController.
1. Basic BottomNavigationBar with IndexedStack
IndexedStack is the preferred approach for bottom navigation because it keeps all pages in memory and preserves their scroll position and state between tab switches — unlike switching widgets directly.
import "package:flutter/material.dart";
class MainScreen extends StatefulWidget {
const MainScreen({super.key});
@override
State<MainScreen> createState() => _MainScreenState();
}
class _MainScreenState extends State<MainScreen> {
int _selectedIndex = 0;
// Pages are kept alive with IndexedStack
final List<Widget> _pages = [
const HomeTab(),
const SearchTab(),
const FavoritesTab(),
const ProfileTab(),
];
void _onItemTapped(int index) {
setState(() => _selectedIndex = index);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(
index: _selectedIndex,
children: _pages,
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _selectedIndex,
onTap: _onItemTapped,
type: BottomNavigationBarType.fixed, // Required for 4+ items
selectedItemColor: Colors.indigo,
unselectedItemColor: Colors.grey,
selectedLabelStyle: const TextStyle(fontWeight: FontWeight.bold),
backgroundColor: Colors.white,
elevation: 8,
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.favorite_outline),
activeIcon: Icon(Icons.favorite),
label: "Favorites",
),
BottomNavigationBarItem(
icon: Icon(Icons.person_outline),
activeIcon: Icon(Icons.person),
label: "Profile",
),
],
),
);
}
}
2. BottomNavigationBar with PageController
Use PageController with PageView to get swipe-between-tabs behavior. Sync the controller with the bottom bar index so both stay in sync on tap or swipe.
class MainScreenWithPageView extends StatefulWidget {
const MainScreenWithPageView({super.key});
@override
State<MainScreenWithPageView> createState() => _MainScreenWithPageViewState();
}
class _MainScreenWithPageViewState extends State<MainScreenWithPageView> {
int _selectedIndex = 0;
late PageController _pageController;
@override
void initState() {
super.initState();
_pageController = PageController(initialPage: _selectedIndex);
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
void _onTabTapped(int index) {
setState(() => _selectedIndex = index);
_pageController.animateToPage(
index,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: PageView(
controller: _pageController,
physics: const NeverScrollableScrollPhysics(), // Disable swipe
onPageChanged: (index) {
setState(() => _selectedIndex = index);
},
children: const [
HomeTab(),
SearchTab(),
ProfileTab(),
],
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _selectedIndex,
onTap: _onTabTapped,
items: const [
BottomNavigationBarItem(icon: Icon(Icons.home), label: "Home"),
BottomNavigationBarItem(icon: Icon(Icons.search), label: "Search"),
BottomNavigationBarItem(icon: Icon(Icons.person), label: "Profile"),
],
),
);
}
}
3. Custom Styled Bottom Navigation Bar
Customize the bottom bar with a floating effect using Container and BoxDecoration with shadow, or use the NavigationBar (Material 3) widget for a modern pill-indicator style.
// Material 3 NavigationBar (modern pill indicator)
Scaffold(
bottomNavigationBar: NavigationBar(
selectedIndex: _selectedIndex,
onDestinationSelected: (index) {
setState(() => _selectedIndex = index);
},
backgroundColor: Colors.white,
indicatorColor: Colors.indigo.withOpacity(0.15),
destinations: const [
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home, color: Colors.indigo),
label: "Home",
),
NavigationDestination(
icon: Icon(Icons.search_outlined),
selectedIcon: Icon(Icons.search, color: Colors.indigo),
label: "Search",
),
NavigationDestination(
icon: Icon(Icons.notifications_outlined),
selectedIcon: Icon(Icons.notifications, color: Colors.indigo),
label: "Alerts",
),
NavigationDestination(
icon: Icon(Icons.person_outline),
selectedIcon: Icon(Icons.person, color: Colors.indigo),
label: "Profile",
),
],
),
body: IndexedStack(
index: _selectedIndex,
children: _pages,
),
)
// Floating bottom nav with Card + shadow
Scaffold(
extendBody: true, // Body extends under nav bar
bottomNavigationBar: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: ClipRRect(
borderRadius: BorderRadius.circular(24),
child: BottomNavigationBar(
currentIndex: _selectedIndex,
onTap: (i) => setState(() => _selectedIndex = i),
elevation: 0,
items: const [
BottomNavigationBarItem(icon: Icon(Icons.home), label: "Home"),
BottomNavigationBarItem(icon: Icon(Icons.explore), label: "Explore"),
BottomNavigationBarItem(icon: Icon(Icons.person), label: "Me"),
],
),
),
),
)
4. Preserving State with AutomaticKeepAliveClientMixin
When using PageView (not IndexedStack), pages get disposed on tab switch. Add AutomaticKeepAliveClientMixin to a tab's state to prevent it from being destroyed.
class HomeTab extends StatefulWidget {
const HomeTab({super.key});
@override
State<HomeTab> createState() => _HomeTabState();
}
// Add AutomaticKeepAliveClientMixin to preserve state
class _HomeTabState extends State<HomeTab>
with AutomaticKeepAliveClientMixin {
int _counter = 0;
@override
bool get wantKeepAlive => true; // Return true to keep alive
@override
Widget build(BuildContext context) {
super.build(context); // Must call super.build()
return Scaffold(
appBar: AppBar(title: const Text("Home")),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text("Counter: $_counter", style: const TextStyle(fontSize: 24)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => setState(() => _counter++),
child: const Text("Increment"),
),
],
),
),
);
}
}
5. Bottom Navigation with Badge (Notification Count)
Use the Badge widget (Flutter 3.7+) or wrap an icon with a Stack to show notification counts on bottom nav items.
// Using Flutter 3.7+ Badge widget
BottomNavigationBarItem(
icon: Badge(
label: const Text("3"),
child: const Icon(Icons.notifications_outlined),
),
label: "Alerts",
)
// Custom badge with Stack (older Flutter)
Widget _iconWithBadge(IconData icon, int count) {
return Stack(
clipBehavior: Clip.none,
children: [
Icon(icon),
if (count > 0)
Positioned(
right: -6,
top: -4,
child: Container(
padding: const EdgeInsets.all(3),
decoration: const BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
),
constraints: const BoxConstraints(minWidth: 16, minHeight: 16),
child: Text(
count > 99 ? "99+" : "$count",
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
),
],
);
}
// Use in BottomNavigationBarItem
BottomNavigationBarItem(
icon: _iconWithBadge(Icons.notifications_outlined, 5),
activeIcon: _iconWithBadge(Icons.notifications, 5),
label: "Alerts",
)