Bottom navigation and a top TabBar solve genuinely different navigation problems in Flutter — bottom navigation switches between separate top-level screens (Home, Search, Profile), while a top TabBar switches between views within one single screen.
Bottom navigation bar
int _currentIndex = 0;
final List _screens = [HomeScreen(), SearchScreen(), ProfileScreen()];
Scaffold(
body: _screens[_currentIndex],
bottomNavigationBar: BottomNavigationBar(
currentIndex: _currentIndex,
onTap: (index) {
setState(() {
_currentIndex = index;
});
},
items: const [
BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Search'),
BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profile'),
],
),
)
Swapping the body widget based on _currentIndex is the standard pattern — each tab represents a genuinely distinct screen, and keeping them in a simple list indexed by the current selection keeps the switching logic straightforward.
Customizing bottom navigation's appearance
BottomNavigationBar(
type: BottomNavigationBarType.fixed,
selectedItemColor: Colors.deepPurple,
unselectedItemColor: Colors.grey,
currentIndex: _currentIndex,
onTap: (index) => setState(() => _currentIndex = index),
items: const [ /* ... */ ],
)
type: BottomNavigationBarType.fixed keeps all tab labels visible regardless of how many tabs exist — without it, Flutter's default "shifting" behavior for more than 3 items hides labels on unselected tabs, which isn't always the desired look.
A top TabBar, for switching views within one screen
DefaultTabController(
length: 3,
child: Scaffold(
appBar: AppBar(
bottom: const TabBar(
tabs: [
Tab(text: 'All'),
Tab(text: 'Active'),
Tab(text: 'Completed'),
],
),
),
body: const TabBarView(
children: [
AllTasksList(),
ActiveTasksList(),
CompletedTasksList(),
],
),
),
)
DefaultTabController is what links the TabBar (the tab labels) and TabBarView (the actual swipeable content) together — its length must match the number of tabs and views exactly, or Flutter throws an assertion error at runtime.
Controlling the TabBar programmatically with a TabController
class _MyScreenState extends State with SingleTickerProviderStateMixin {
late TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
}
Managing a TabController explicitly (rather than relying on DefaultTabController) is needed when the current tab index must be read or changed programmatically from outside the tab widgets themselves — SingleTickerProviderStateMixin is required because the controller drives the tab-switch animation.
Styling the TabBar's indicator and colors
TabBar(
indicatorColor: Colors.deepPurple,
labelColor: Colors.deepPurple,
unselectedLabelColor: Colors.grey,
tabs: const [Tab(text: 'All'), Tab(text: 'Active'), Tab(text: 'Completed')],
)
Choosing between the two for a given app
Bottom navigation suits the app's main top-level sections that a user should be able to jump between at any time — a top TabBar suits sub-views of one specific screen's content (like filtering one list by status), and the two are commonly combined, with each bottom-navigation screen optionally having its own internal top tabs.