Flutter 튜토리얼 18편: 탭 바와 드로어 메뉴
요약
핵심 요지
문서가 설명하는 범위
- TabBar와 TabBarView로 탭 인터페이스 구현
- Drawer로 사이드 메뉴 구현
- BottomNavigationBar로 하단 탭 구현
- NavigationRail로 반응형 네비게이션 구현
읽는 시간: 15분 | 난이도: 초급
참고 자료
- Work with tabs - 탭 구현
- Add a drawer to a screen - 드로어 구현
- NavigationBar - 하단 네비게이션
- NavigationRail - 사이드 네비게이션
문제 상황
앱에는 여러 주요 섹션이 있고, 사용자가 쉽게 이동할 수 있어야 합니다. 홈, 검색, 프로필 같은 주요 화면을 빠르게 전환하는 UI가 필요합니다.
일반적인 네비게이션 패턴
탭 바: 화면 상단, 같은 레벨의 콘텐츠 전환하단 탭: 화면 하단, 주요 섹션 이동 (3-5개)드로어: 사이드 메뉴, 많은 항목 또는 설정네비게이션 레일: 태블릿/데스크톱 사이드바문제는 다음과 같습니다.
- 콘텐츠 구조에 맞는 네비게이션 패턴을 선택해야 한다.
- 탭과 콘텐츠를 동기화해야 한다.
- 선택 상태를 시각적으로 표시해야 한다.
- 화면 크기에 따라 적절한 패턴을 적용해야 한다.
해결 방법
Flutter는 Material Design 가이드라인을 따르는 표준 네비게이션 위젯을 제공합니다.
챕터 1: TabBar 기본 구현
Why
NOTE같은 레벨의 관련 콘텐츠를 빠르게 전환할 때 탭을 사용합니다.
예를 들어 “전체/인기/최신” 같은 필터링이나 “소개/리뷰/관련 상품” 같은 정보 분류에 적합합니다.탭 선택 → TabController 동기화 → TabBarView 콘텐츠 표시
What
NOTE
TabBar는 탭 버튼들을,TabBarView는 각 탭의 콘텐츠를 표시합니다.
DefaultTabController가 둘을 동기화합니다.
How
TIPDefaultTabController 사용 (권장)
import 'package:flutter/material.dart';class TabBarDemo extends StatelessWidget {const TabBarDemo({super.key});@overrideWidget build(BuildContext context) {return DefaultTabController(length: 3, // 탭 개수child: Scaffold(appBar: AppBar(title: const Text('탭 데모'),bottom: const TabBar(tabs: [Tab(icon: Icon(Icons.home), text: '홈'),Tab(icon: Icon(Icons.search), text: '검색'),Tab(icon: Icon(Icons.person), text: '프로필'),],),),body: const TabBarView(children: [Center(child: Text('홈 화면')),Center(child: Text('검색 화면')),Center(child: Text('프로필 화면')),],),),);}}Tab 위젯 옵션
속성 설명 icon탭 아이콘 text탭 텍스트 child커스텀 위젯 (text/icon 대신) TabBar 스타일링
TabBar(tabs: [...],// 선택된 탭 스타일labelColor: Colors.white,unselectedLabelColor: Colors.white60,// 인디케이터 스타일indicatorColor: Colors.white,indicatorWeight: 3,indicatorSize: TabBarIndicatorSize.tab,// 탭 간격isScrollable: true, // 탭이 많을 때 스크롤 가능)
Watch out
WARNING
TabBar의 탭 개수와TabBarView의 자식 개수가 일치해야 합니다.
또한DefaultTabController의length도 같아야 합니다.// ❌ 탭 개수 불일치 → 런타임 에러DefaultTabController(length: 3, // 3개child: Scaffold(appBar: AppBar(bottom: TabBar(tabs: [Tab(...), Tab(...)]), // 2개!),body: TabBarView(children: [..., ..., ...]), // 3개),)// ✅ 모두 동일한 개수DefaultTabController(length: 3,child: Scaffold(appBar: AppBar(bottom: TabBar(tabs: [Tab(...), Tab(...), Tab(...)]), // 3개),body: TabBarView(children: [..., ..., ...]), // 3개),)
결론: DefaultTabController로 TabBar와 TabBarView를 동기화하고, 탭 개수를 일치시킵니다.
챕터 2: TabController로 세밀한 제어
Why
NOTE프로그래밍 방식으로 탭을 전환하거나 탭 변경을 감지해야 할 때가 있습니다.
버튼 클릭 시 특정 탭으로 이동하거나, 탭 변경 시 데이터를 로드하는 경우입니다.이벤트 발생 → tabController.animateTo(index) → 탭 전환
What
NOTE
TabController를 직접 생성하면 탭 상태를 세밀하게 제어할 수 있습니다.
addListener()로 탭 변경을 감지할 수 있습니다.
How
TIPTabController 직접 관리
class ManualTabControllerDemo extends StatefulWidget {const ManualTabControllerDemo({super.key});@overrideState<ManualTabControllerDemo> createState() => _ManualTabControllerDemoState();}class _ManualTabControllerDemoState extends State<ManualTabControllerDemo>with TickerProviderStateMixin {late TabController _tabController;@overridevoid initState() {super.initState();_tabController = TabController(length: 3,vsync: this, // TickerProviderStateMixin 제공initialIndex: 0, // 초기 탭 인덱스);// 탭 변경 리스너_tabController.addListener(_handleTabChange);}void _handleTabChange() {if (_tabController.indexIsChanging) {// 탭이 변경 중print('탭 변경 중: ${_tabController.index}');} else {// 탭 변경 완료print('현재 탭: ${_tabController.index}');_loadDataForTab(_tabController.index);}}void _loadDataForTab(int index) {// 탭별 데이터 로드}void _goToTab(int index) {_tabController.animateTo(index);}@overridevoid dispose() {_tabController.removeListener(_handleTabChange);_tabController.dispose();super.dispose();}@overrideWidget build(BuildContext context) {return Scaffold(appBar: AppBar(title: const Text('탭 컨트롤러 데모'),bottom: TabBar(controller: _tabController,tabs: const [Tab(text: '첫 번째'),Tab(text: '두 번째'),Tab(text: '세 번째'),],),),body: TabBarView(controller: _tabController,children: const [Center(child: Text('첫 번째 탭')),Center(child: Text('두 번째 탭')),Center(child: Text('세 번째 탭')),],),floatingActionButton: FloatingActionButton(onPressed: () => _goToTab(2), // 세 번째 탭으로 이동child: const Icon(Icons.arrow_forward),),);}}TabController 주요 속성/메서드
API 설명 index현재 선택된 탭 인덱스 animateTo(index)애니메이션과 함께 탭 전환 indexIsChanging탭 전환 중 여부 addListener()변경 리스너 등록 previousIndex이전 탭 인덱스
Watch out
WARNING
TabController를 사용하려면TickerProviderStateMixin이 필요합니다.
또한dispose()에서 컨트롤러를 해제해야 합니다.// ❌ vsync 제공 안 함 → 에러class _MyState extends State<MyWidget> {late TabController _tabController;@overridevoid initState() {super.initState();_tabController = TabController(length: 3, vsync: this); // 에러!}}// ✅ mixin으로 vsync 제공class _MyState extends State<MyWidget> with TickerProviderStateMixin {late TabController _tabController;@overridevoid initState() {super.initState();_tabController = TabController(length: 3, vsync: this); // OK}}
결론: TabController로 탭을 프로그래밍 방식으로 제어하고, TickerProviderStateMixin을 믹스인합니다.
챕터 3: Drawer 사이드 메뉴
Why
NOTE많은 네비게이션 항목이나 설정 메뉴는 드로어에 넣는 것이 좋습니다.
주요 섹션 외에 프로필, 설정, 도움말 등을 드로어에 배치합니다.햄버거 메뉴 → 드로어 열림 → 항목 선택 → 드로어 닫힘 → 화면 이동
What
NOTE
Drawer위젯은Scaffold의drawer속성에 추가합니다.
AppBar가 있으면 자동으로 햄버거 메뉴 아이콘이 표시됩니다.
How
TIP기본 Drawer 구현
class DrawerDemo extends StatefulWidget {const DrawerDemo({super.key});@overrideState<DrawerDemo> createState() => _DrawerDemoState();}class _DrawerDemoState extends State<DrawerDemo> {int _selectedIndex = 0;final List<DrawerItem> _items = const [DrawerItem(icon: Icons.home, title: '홈'),DrawerItem(icon: Icons.search, title: '검색'),DrawerItem(icon: Icons.favorite, title: '즐겨찾기'),DrawerItem(icon: Icons.settings, title: '설정'),];void _onItemTapped(int index) {setState(() {_selectedIndex = index;});Navigator.pop(context); // 드로어 닫기}@overrideWidget build(BuildContext context) {return Scaffold(appBar: AppBar(title: Text(_items[_selectedIndex].title),),drawer: Drawer(child: ListView(padding: EdgeInsets.zero,children: [// 드로어 헤더const DrawerHeader(decoration: BoxDecoration(color: Colors.blue),child: Column(crossAxisAlignment: CrossAxisAlignment.start,mainAxisAlignment: MainAxisAlignment.end,children: [CircleAvatar(radius: 30,backgroundImage: NetworkImage('https://i.pravatar.cc/150'),),SizedBox(height: 8),Text('홍길동',style: TextStyle(color: Colors.white, fontSize: 18),),Text(style: TextStyle(color: Colors.white70),),],),),// 네비게이션 항목...List.generate(_items.length, (index) {return ListTile(leading: Icon(_items[index].icon),title: Text(_items[index].title),selected: _selectedIndex == index,onTap: () => _onItemTapped(index),);}),const Divider(),ListTile(leading: const Icon(Icons.logout),title: const Text('로그아웃'),onTap: () {Navigator.pop(context);// 로그아웃 처리},),],),),body: Center(child: Text('${_items[_selectedIndex].title} 화면'),),);}}class DrawerItem {final IconData icon;final String title;const DrawerItem({required this.icon, required this.title});}UserAccountsDrawerHeader 사용
UserAccountsDrawerHeader(accountName: const Text('홍길동'),currentAccountPicture: const CircleAvatar(backgroundImage: NetworkImage('https://i.pravatar.cc/150'),),decoration: const BoxDecoration(color: Colors.blue),otherAccountsPictures: const [CircleAvatar(backgroundImage: NetworkImage('https://i.pravatar.cc/100'),),],)
Watch out
WARNING드로어 항목을 탭하면
Navigator.pop(context)로 드로어를 닫아야 합니다.
드로어는 네비게이션 스택에 추가되므로pop()으로 제거합니다.ListTile(title: const Text('홈'),onTap: () {// 먼저 드로어 닫기Navigator.pop(context);// 그 다음 화면 이동 (필요시)Navigator.pushNamed(context, '/home');},)드로어를 닫지 않고 화면을 이동하면 이상한 동작이 발생할 수 있습니다.
결론: Drawer를 Scaffold.drawer에 추가하고, 항목 선택 시 Navigator.pop()으로 드로어를 닫습니다.
챕터 4: BottomNavigationBar 하단 탭
Why
NOTE모바일 앱에서 3-5개의 주요 섹션을 하단 탭으로 제공합니다.
엄지손가락으로 쉽게 접근할 수 있어 모바일 UX에 적합합니다.하단 탭 선택 → 상태 업데이트 → 화면 전환
What
NOTE
BottomNavigationBar는Scaffold의bottomNavigationBar속성에 추가합니다.
NavigationBar는 Material 3 스타일의 하단 네비게이션입니다.
How
TIPBottomNavigationBar 구현
class BottomNavDemo extends StatefulWidget {const BottomNavDemo({super.key});@overrideState<BottomNavDemo> createState() => _BottomNavDemoState();}class _BottomNavDemoState extends State<BottomNavDemo> {int _selectedIndex = 0;static const List<Widget> _pages = [HomePage(),SearchPage(),NotificationsPage(),ProfilePage(),];void _onItemTapped(int index) {setState(() {_selectedIndex = index;});}@overrideWidget build(BuildContext context) {return Scaffold(appBar: AppBar(title: const Text('하단 네비게이션'),),body: _pages[_selectedIndex],bottomNavigationBar: BottomNavigationBar(type: BottomNavigationBarType.fixed, // 4개 이상일 때 필요currentIndex: _selectedIndex,onTap: _onItemTapped,items: const [BottomNavigationBarItem(icon: Icon(Icons.home),label: '홈',),BottomNavigationBarItem(icon: Icon(Icons.search),label: '검색',),BottomNavigationBarItem(icon: Icon(Icons.notifications),label: '알림',),BottomNavigationBarItem(icon: Icon(Icons.person),label: '프로필',),],),);}}Material 3 NavigationBar
NavigationBar(selectedIndex: _selectedIndex,onDestinationSelected: _onItemTapped,destinations: const [NavigationDestination(icon: Icon(Icons.home_outlined),selectedIcon: Icon(Icons.home),label: '홈',),NavigationDestination(icon: Icon(Icons.search_outlined),selectedIcon: Icon(Icons.search),label: '검색',),NavigationDestination(icon: Icon(Icons.notifications_outlined),selectedIcon: Icon(Icons.notifications),label: '알림',),],)BottomNavigationBar vs NavigationBar
속성 BottomNavigationBar NavigationBar 스타일 Material 2 Material 3 아이콘 상태 같은 아이콘 선택/비선택 아이콘 인디케이터 없음 필 인디케이터 권장 레거시 앱 새 앱
Watch out
WARNING
BottomNavigationBar에 4개 이상의 항목이 있으면type: BottomNavigationBarType.fixed를 설정해야 합니다.
기본값shifting은 3개 이하일 때만 잘 작동합니다.// ❌ 4개 이상에서 shifting 사용 → 아이콘 위치가 이상해짐BottomNavigationBar(items: [..., ..., ..., ...], // 4개// type 미지정 (기본값: shifting))// ✅ 4개 이상에서 fixed 사용BottomNavigationBar(type: BottomNavigationBarType.fixed,items: [..., ..., ..., ...],)
결론: 모바일 앱의 주요 섹션은 BottomNavigationBar나 NavigationBar로 구현합니다.
챕터 5: IndexedStack으로 상태 유지
Why
NOTE탭을 전환할 때마다 화면이 새로 생성되면 스크롤 위치나 입력 상태가 초기화됩니다.
IndexedStack을 사용하면 화면 상태를 유지할 수 있습니다.탭 A (스크롤 위치 유지) ↔ 탭 B (스크롤 위치 유지)
What
NOTE
IndexedStack은 모든 자식을 메모리에 유지하고, 선택된 인덱스의 자식만 표시합니다.
화면 전환 시 상태가 보존됩니다.
How
TIPIndexedStack 사용
class StatefulBottomNav extends StatefulWidget {const StatefulBottomNav({super.key});@overrideState<StatefulBottomNav> createState() => _StatefulBottomNavState();}class _StatefulBottomNavState extends State<StatefulBottomNav> {int _selectedIndex = 0;// 화면들을 미리 생성 (상태 유지를 위해)final List<Widget> _pages = [const HomePage(key: PageStorageKey('home')),const SearchPage(key: PageStorageKey('search')),const ProfilePage(key: PageStorageKey('profile')),];@overrideWidget build(BuildContext context) {return Scaffold(body: IndexedStack(index: _selectedIndex,children: _pages,),bottomNavigationBar: NavigationBar(selectedIndex: _selectedIndex,onDestinationSelected: (index) {setState(() {_selectedIndex = index;});},destinations: const [NavigationDestination(icon: Icon(Icons.home), label: '홈'),NavigationDestination(icon: Icon(Icons.search), label: '검색'),NavigationDestination(icon: Icon(Icons.person), label: '프로필'),],),);}}// 스크롤 위치 유지를 위한 PageStorageKey 사용class HomePage extends StatelessWidget {const HomePage({super.key});@overrideWidget build(BuildContext context) {return ListView.builder(key: const PageStorageKey('home_list'),itemCount: 100,itemBuilder: (context, index) => ListTile(title: Text('항목 $index'),),);}}PageStorageKey로 스크롤 위치 유지
ListView.builder(key: const PageStorageKey('unique_key'),// ...)
Watch out
WARNING
IndexedStack은 모든 자식을 메모리에 유지하므로 리소스를 더 사용합니다.
화면이 많거나 무거운 위젯이 있으면 지연 로딩을 고려하세요.// ⚠️ 모든 화면이 메모리에 유지됨IndexedStack(children: [HeavyWidget1(), // 메모리 사용HeavyWidget2(), // 메모리 사용HeavyWidget3(), // 메모리 사용],)// 대안: 필요할 때만 생성body: _selectedIndex == 0? const HomePage(): _selectedIndex == 1? const SearchPage(): const ProfilePage(),
결론: 화면 상태 유지가 필요하면 IndexedStack을, 메모리 효율이 중요하면 조건부 렌더링을 사용합니다.
챕터 6: NavigationRail로 반응형 네비게이션
Why
NOTE태블릿이나 데스크톱에서는 하단 탭보다 사이드 레일이 더 적합합니다.
화면 크기에 따라 네비게이션 스타일을 전환하는 반응형 디자인이 필요합니다.모바일: BottomNavigationBar태블릿: NavigationRail데스크톱: NavigationRail (확장)
What
NOTE
NavigationRail은 화면 왼쪽에 수직으로 배치되는 네비게이션입니다.
extended속성으로 레이블을 표시하거나 숨길 수 있습니다.
How
TIP반응형 네비게이션 구현
class ResponsiveNavDemo extends StatefulWidget {const ResponsiveNavDemo({super.key});@overrideState<ResponsiveNavDemo> createState() => _ResponsiveNavDemoState();}class _ResponsiveNavDemoState extends State<ResponsiveNavDemo> {int _selectedIndex = 0;@overrideWidget build(BuildContext context) {final screenWidth = MediaQuery.of(context).size.width;final isWideScreen = screenWidth >= 600;final isVeryWideScreen = screenWidth >= 900;return Scaffold(body: Row(children: [// 태블릿/데스크톱: NavigationRail 표시if (isWideScreen)NavigationRail(extended: isVeryWideScreen, // 넓은 화면에서 레이블 표시selectedIndex: _selectedIndex,onDestinationSelected: (index) {setState(() {_selectedIndex = index;});},leading: isVeryWideScreen? const Padding(padding: EdgeInsets.all(8.0),child: Text('내 앱',style: TextStyle(fontSize: 20,fontWeight: FontWeight.bold,),),): const SizedBox(height: 8),destinations: const [NavigationRailDestination(icon: Icon(Icons.home_outlined),selectedIcon: Icon(Icons.home),label: Text('홈'),),NavigationRailDestination(icon: Icon(Icons.search_outlined),selectedIcon: Icon(Icons.search),label: Text('검색'),),NavigationRailDestination(icon: Icon(Icons.notifications_outlined),selectedIcon: Icon(Icons.notifications),label: Text('알림'),),NavigationRailDestination(icon: Icon(Icons.person_outlined),selectedIcon: Icon(Icons.person),label: Text('프로필'),),],),// 콘텐츠 영역Expanded(child: _buildPage(_selectedIndex),),],),// 모바일: BottomNavigationBar 표시bottomNavigationBar: isWideScreen? null: NavigationBar(selectedIndex: _selectedIndex,onDestinationSelected: (index) {setState(() {_selectedIndex = index;});},destinations: const [NavigationDestination(icon: Icon(Icons.home_outlined),selectedIcon: Icon(Icons.home),label: '홈',),NavigationDestination(icon: Icon(Icons.search_outlined),selectedIcon: Icon(Icons.search),label: '검색',),NavigationDestination(icon: Icon(Icons.notifications_outlined),selectedIcon: Icon(Icons.notifications),label: '알림',),NavigationDestination(icon: Icon(Icons.person_outlined),selectedIcon: Icon(Icons.person),label: '프로필',),],),);}Widget _buildPage(int index) {switch (index) {case 0:return const Center(child: Text('홈 화면'));case 1:return const Center(child: Text('검색 화면'));case 2:return const Center(child: Text('알림 화면'));case 3:return const Center(child: Text('프로필 화면'));default:return const Center(child: Text('알 수 없는 화면'));}}}
Watch out
WARNING
NavigationRail을 사용할 때Row와Expanded를 올바르게 구성해야 합니다.
레일이 고정 너비를 차지하고 콘텐츠가 나머지 공간을 채워야 합니다.// ✅ 올바른 구조Row(children: [NavigationRail(...), // 고정 너비Expanded(child: content), // 나머지 공간],)// ❌ Expanded 없으면 오버플로우 발생Row(children: [NavigationRail(...),content, // 너비가 제한되지 않음],)
결론: MediaQuery로 화면 크기를 확인하고, 모바일은 하단 탭, 태블릿/데스크톱은 레일을 표시합니다.
한계
탭과 드로어 네비게이션에는 몇 가지 한계가 있습니다.
- 중첩 네비게이션: 각 탭에 독립적인 네비게이션 스택을 유지하기 어렵습니다.
- 상태 관리: 화면 간 상태 공유는 별도 상태 관리 솔루션이 필요합니다.
- 딥 링킹: 특정 탭의 특정 화면으로 직접 이동하려면 추가 설정이 필요합니다.
- 애니메이션: 탭 전환 시 커스텀 애니메이션 적용이 제한적입니다.
Footnotes
공유
이 글이 도움이 되었다면 다른 사람과 공유해주세요!