Flutter 튜토리얼 18편: 탭 바와 드로어 메뉴

요약#

핵심 요지#

  • 문제 정의: 앱의 주요 섹션 간 이동을 위한 네비게이션 UI가 필요하다.
  • 핵심 주장: TabBar1, Drawer2, BottomNavigationBar3로 일반적인 네비게이션 패턴을 구현한다.
  • 주요 근거: Material Design 가이드라인에 따른 표준 네비게이션 컴포넌트를 제공한다.
  • 실무 기준: 화면 크기와 콘텐츠 구조에 맞는 네비게이션 패턴을 선택해야 한다.
  • 한계: 복잡한 중첩 네비게이션은 상태 관리가 필요할 수 있다.

문서가 설명하는 범위#

  • TabBar와 TabBarView로 탭 인터페이스 구현
  • Drawer로 사이드 메뉴 구현
  • BottomNavigationBar로 하단 탭 구현
  • NavigationRail로 반응형 네비게이션 구현

읽는 시간: 15분 | 난이도: 초급


참고 자료#


문제 상황#

앱에는 여러 주요 섹션이 있고, 사용자가 쉽게 이동할 수 있어야 합니다. 홈, 검색, 프로필 같은 주요 화면을 빠르게 전환하는 UI가 필요합니다.

일반적인 네비게이션 패턴#

탭 바: 화면 상단, 같은 레벨의 콘텐츠 전환
하단 탭: 화면 하단, 주요 섹션 이동 (3-5개)
드로어: 사이드 메뉴, 많은 항목 또는 설정
네비게이션 레일: 태블릿/데스크톱 사이드바

문제는 다음과 같습니다.

  • 콘텐츠 구조에 맞는 네비게이션 패턴을 선택해야 한다.
  • 탭과 콘텐츠를 동기화해야 한다.
  • 선택 상태를 시각적으로 표시해야 한다.
  • 화면 크기에 따라 적절한 패턴을 적용해야 한다.

해결 방법#

Flutter는 Material Design 가이드라인을 따르는 표준 네비게이션 위젯을 제공합니다.

챕터 1: TabBar 기본 구현#

Why#

NOTE

같은 레벨의 관련 콘텐츠를 빠르게 전환할 때 탭을 사용합니다.
예를 들어 “전체/인기/최신” 같은 필터링이나 “소개/리뷰/관련 상품” 같은 정보 분류에 적합합니다.

탭 선택 → TabController 동기화 → TabBarView 콘텐츠 표시

What#

NOTE

TabBar는 탭 버튼들을, TabBarView는 각 탭의 콘텐츠를 표시합니다.
DefaultTabController가 둘을 동기화합니다.

How#

TIP

DefaultTabController 사용 (권장)

import 'package:flutter/material.dart';
class TabBarDemo extends StatelessWidget {
const TabBarDemo({super.key});
@override
Widget 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의 자식 개수가 일치해야 합니다.
또한 DefaultTabControllerlength도 같아야 합니다.

// ❌ 탭 개수 불일치 → 런타임 에러
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개
),
)

결론: DefaultTabControllerTabBarTabBarView를 동기화하고, 탭 개수를 일치시킵니다.


챕터 2: TabController로 세밀한 제어#

Why#

NOTE

프로그래밍 방식으로 탭을 전환하거나 탭 변경을 감지해야 할 때가 있습니다.
버튼 클릭 시 특정 탭으로 이동하거나, 탭 변경 시 데이터를 로드하는 경우입니다.

이벤트 발생 → tabController.animateTo(index) → 탭 전환

What#

NOTE

TabController를 직접 생성하면 탭 상태를 세밀하게 제어할 수 있습니다.
addListener()로 탭 변경을 감지할 수 있습니다.

How#

TIP

TabController 직접 관리

class ManualTabControllerDemo extends StatefulWidget {
const ManualTabControllerDemo({super.key});
@override
State<ManualTabControllerDemo> createState() => _ManualTabControllerDemoState();
}
class _ManualTabControllerDemoState extends State<ManualTabControllerDemo>
with TickerProviderStateMixin {
late TabController _tabController;
@override
void 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);
}
@override
void dispose() {
_tabController.removeListener(_handleTabChange);
_tabController.dispose();
super.dispose();
}
@override
Widget 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;
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this); // 에러!
}
}
// ✅ mixin으로 vsync 제공
class _MyState extends State<MyWidget> with TickerProviderStateMixin {
late TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this); // OK
}
}

결론: TabController로 탭을 프로그래밍 방식으로 제어하고, TickerProviderStateMixin을 믹스인합니다.


챕터 3: Drawer 사이드 메뉴#

Why#

NOTE

많은 네비게이션 항목이나 설정 메뉴는 드로어에 넣는 것이 좋습니다.
주요 섹션 외에 프로필, 설정, 도움말 등을 드로어에 배치합니다.

햄버거 메뉴 → 드로어 열림 → 항목 선택 → 드로어 닫힘 → 화면 이동

What#

NOTE

Drawer 위젯은 Scaffolddrawer 속성에 추가합니다.
AppBar가 있으면 자동으로 햄버거 메뉴 아이콘이 표시됩니다.

How#

TIP

기본 Drawer 구현

class DrawerDemo extends StatefulWidget {
const DrawerDemo({super.key});
@override
State<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); // 드로어 닫기
}
@override
Widget 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('홍길동'),
accountEmail: const Text('[email protected]'),
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');
},
)

드로어를 닫지 않고 화면을 이동하면 이상한 동작이 발생할 수 있습니다.

결론: DrawerScaffold.drawer에 추가하고, 항목 선택 시 Navigator.pop()으로 드로어를 닫습니다.


챕터 4: BottomNavigationBar 하단 탭#

Why#

NOTE

모바일 앱에서 3-5개의 주요 섹션을 하단 탭으로 제공합니다.
엄지손가락으로 쉽게 접근할 수 있어 모바일 UX에 적합합니다.

하단 탭 선택 → 상태 업데이트 → 화면 전환

What#

NOTE

BottomNavigationBarScaffoldbottomNavigationBar 속성에 추가합니다.
NavigationBar는 Material 3 스타일의 하단 네비게이션입니다.

How#

TIP

BottomNavigationBar 구현

class BottomNavDemo extends StatefulWidget {
const BottomNavDemo({super.key});
@override
State<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;
});
}
@override
Widget 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

속성BottomNavigationBarNavigationBar
스타일Material 2Material 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: [..., ..., ..., ...],
)

결론: 모바일 앱의 주요 섹션은 BottomNavigationBarNavigationBar로 구현합니다.


챕터 5: IndexedStack으로 상태 유지#

Why#

NOTE

탭을 전환할 때마다 화면이 새로 생성되면 스크롤 위치나 입력 상태가 초기화됩니다.
IndexedStack을 사용하면 화면 상태를 유지할 수 있습니다.

탭 A (스크롤 위치 유지) ↔ 탭 B (스크롤 위치 유지)

What#

NOTE

IndexedStack은 모든 자식을 메모리에 유지하고, 선택된 인덱스의 자식만 표시합니다.
화면 전환 시 상태가 보존됩니다.

How#

TIP

IndexedStack 사용

class StatefulBottomNav extends StatefulWidget {
const StatefulBottomNav({super.key});
@override
State<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')),
];
@override
Widget 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});
@override
Widget 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});
@override
State<ResponsiveNavDemo> createState() => _ResponsiveNavDemoState();
}
class _ResponsiveNavDemoState extends State<ResponsiveNavDemo> {
int _selectedIndex = 0;
@override
Widget 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을 사용할 때 RowExpanded를 올바르게 구성해야 합니다.
레일이 고정 너비를 차지하고 콘텐츠가 나머지 공간을 채워야 합니다.

// ✅ 올바른 구조
Row(
children: [
NavigationRail(...), // 고정 너비
Expanded(child: content), // 나머지 공간
],
)
// ❌ Expanded 없으면 오버플로우 발생
Row(
children: [
NavigationRail(...),
content, // 너비가 제한되지 않음
],
)

결론: MediaQuery로 화면 크기를 확인하고, 모바일은 하단 탭, 태블릿/데스크톱은 레일을 표시합니다.


한계#

탭과 드로어 네비게이션에는 몇 가지 한계가 있습니다.

  • 중첩 네비게이션: 각 탭에 독립적인 네비게이션 스택을 유지하기 어렵습니다.
  • 상태 관리: 화면 간 상태 공유는 별도 상태 관리 솔루션이 필요합니다.
  • 딥 링킹: 특정 탭의 특정 화면으로 직접 이동하려면 추가 설정이 필요합니다.
  • 애니메이션: 탭 전환 시 커스텀 애니메이션 적용이 제한적입니다.

Footnotes#

  1. TabBar(탭 바): 탭 버튼들을 가로로 배치하는 위젯이다. AppBar.bottom에 주로 배치한다.

  2. Drawer(드로어): 화면 가장자리에서 슬라이드되어 나오는 사이드 메뉴 패널이다.

  3. BottomNavigationBar(바텀 네비게이션 바): 화면 하단에 배치되는 탭 형태의 네비게이션이다.

공유

이 글이 도움이 되었다면 다른 사람과 공유해주세요!

Flutter 튜토리얼 18편: 탭 바와 드로어 메뉴
https://moodturnpost.net/posts/flutter/flutter-tabs-drawer/
작성자
Moodturn
게시일
2026-01-08
Moodturn

목차