BottomNavigationBar 사용하기
위와 같은 화면을 구성할 때 BottomNavigationBar
를 많이 사용하게 됩니다. BottomNavigationBar
은 TabBarView
와 같이 사용하게 되는데, 사용자의 입력에 따라서 두 위젯의 index가 잘 맞아야 하기 때문에 은근히 구현하기 어려운 부분입니다.
이 글에서 TabBarView
와 BottomNavigationBar
를 이용해서 위와 같은 UI를 구현해보겠습니다.
BottomNavigationBar와 TabBarView 이용해서 화면 전환 구현하기
StatefulWidget 만들기
우선, BottomNavigationBar를 담는 View부터 만들어야 하는데, 이를 TabView
라고 하겠습니다. 당연히 사용자의 입력에 따라서 UI가 달라져야 하므로, StatefulWidget
으로 선언합니다.
class TabView extends StatefulWidget {
const TabView({super.key});
@override
State<TabView> createState() => _TabViewState();
}
class _TabViewState extends State<TabView> {
int _index = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
bottomNavigationBar: // 밑에서 구현할 예정
);
}
}
여기서 index
는, BottomNavigationBar에서 나타낼 아이템 인덱스입니다. 위 그림에서 [’홈’, ‘음식’, ‘주문’, ‘프로필’] 아이템들의 인덱스를 말합니다.
BottomNavigationBar
Scaffold(
bottomNavigationBar: BottomNavigationBar(
onTap: (int index) {
_tabController.animateTo(index); // TabController의 animateTo 함수로, index 위치로 화면 전환
},
currentIndex: _index, // 인덱스는 위에서 정의하였음
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.home_outlined),
label: '홈',
),
BottomNavigationBarItem(
icon: Icon(Icons.fastfood_outlined),
label: '음식',
),
BottomNavigationBarItem(
icon: Icon(Icons.receipt_long_outlined),
label: '주문',
),
BottomNavigationBarItem(
icon: Icon(Icons.person_outlined),
label: '프로필',
),
],
), // BottomNavigationBar
child: // ...
)
BottomNavigationBar
자체는 위와 같이 구현합니다.
currentIndex
: 현재 선택된 인덱스onTap
: 눌렀을 때 동작, 콜백 함수는 네비게이션 바의 n번 째 아이템을 눌렀을 때, 해당 인덱스를 반환합니다items
: 네이게이션 바의 아이템, Widget 중BottomNavigationBarItem
위젯이 가장 많이 사용됩니다
TabView
Scaffold(
bottomNavigationBar: BottomNavigationBar(
// 위에서 구현한 부분과 동일
),
child: TabBarView(
physics: const NeverScrollableScrollPhysics(), // 스와이핑으로 화면 전환 방지
controller: _tabController, // BottomNavigationBar의 controller와 동일한 컨트롤러 사용
children: const [
RestaurantPage(),
ProductPage(),
OrderPage(),
ProfilePage(),
],
),
)
Scaffold
의 child에는, TabBarView
를 넣어주겠습니다.
TabBarView
는 TabBar
나 BottomNavigationBar
에서 선택한 index에 맞추어서 메인 화면에 띄울 페이지를 선택합니다.
TabBarView
에 대한 자세한 내용은 공식 문서나 공식 소개 비디오를 참고하세요.
physics: const NeverScrollableScrollPhysics()
: TabBarView에서 스와이핑을 통해서 화면이 전환되는 것을 방지합니다. 우리는BottomNavigationBar
의 아이템을 클릭했을 때 화면이 전환되는 것을 원하지, 스와이핑을 통해서 전환되는 것을 원치 않습니다.controller
: BottomNavigationBar와 동일한TabController
를 넣어줍니다.children
: 각 아이템마다 화면에 보여줄 페이지를 제공합니다.
TabController
가장 핵심이 되는 것은 TabController
입니다.
우선, TabController
선언 시 late
로 선언해야 합니다. 왜냐하면 TabController
는 initState
함수 안에서 초기화할 것이기 때문입니다.
그리고 TabController
를 이용하기 위해서는, 우선 _TabViewState
클래스에 SingleTickerProviderStateMixin
을 붙여줘야 합니다.
class TabView extends StatefulWidget {
const TabView({super.key});
@override
State<TabView> createState() => _TabViewState();
}
// SingleTickerProviderStateMixin 추가
class _TabViewState extends State<TabView> with SingleTickerProviderStateMixin {
int _index = 0;
late TabController _tabController; // TabController 추가
// ...
}
그리고 이를 initState
에서 초기화합니다. 그리고 TabController
에 추가한 리스너는 반드시 이 위젯이 dispose될 때 detach되어야 합니다. 그래서 dispose
함수에서 리스너를 지워줍니다.
class _RootTabState extends State<RootTab> with SingleTickerProviderStateMixin {
late TabController _tabController;
int _index = 0;
/// initState에서 tabController 초기화
@override
void initState() {
super.initState();
/// length 4는 BottomNavigationBar의 item 개수
/// `vsync: this`를 사용하기 위해서 SingleTickerProviderStateMixin를 사용함
_tabController = TabController(length: 4, vsync: this);
/// tabListener는 밑에서 구현할 예정
_tabController.addListener(tabListener);
}
@override
void dispose() {
/// tabListener 지우기
_tabController.removeListener(tabListener);
super.dispose();
}
void tabListener() {
// ...
}
}
tabListener
함수는 다음과 같이 정의합니다.
void tabListener() {
setState(() {
_index = _tabController.index;
});
}
여기서, 사용자의 클릭에 의해서 _tabController
의 index 속성이 바뀔 때, 현재 인덱스 정보를 나타내는 _index
를 변경해줍니다. _index
가 변경이 되고 있으니깐, setState
를 불러서 화면을 다시 그리게끔 요청합니다.
코드 리팩토링
몇 가지 불만사항이 있습니다.
우선, _tabController = TabController(length: 4, vsync: this);
이 부분에서, 길이를 4로 하드코딩하여 지정하고 있습니다. 그러나 만약에 BottomNavigationBar
의 item 개수가 바뀌게 되면 어떨까요?
한 가지 예시로, 최근 카카오톡도 UI에서 하단 네비게이션 바의 아이템 개수가 5개로 바뀌었습니다. 변경 사항이 있을 때마다 하드코딩된 숫자를 바꾸는 건 그리 좋은 방법은 아닙니다.
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.home),
label: '홈',
),
BottomNavigationBarItem(
icon: Icon(Icons.calendar_today),
label: '스케줄',
),
BottomNavigationBarItem(
icon: Icon(Icons.chat),
label: '채팅',
),
BottomNavigationBarItem(
icon: Icon(Icons.person),
label: '프로필',
),
],
이 리스트 부분을 따로 분리해줍시다.
/// NavItem은 아이콘, label을 담고 있는 모델 클래스로써
/// BottomNavigationBarItem에 필요한 정보를 제공합니다
class NavItem {
final IconData activeIcon;
final String label;
const NavItem({
required this.activeIcon,
required this.label,
});
}
/// 여기서는 그냥 전역적으로 선언해주었습니다
/// [_navItems]는 [_TabViewState]의 지역 변수로 관리해주어도 되고, provider에 넣어도 되지만
/// 여기선 그냥 제일 간단하게 전역에서 관리할 예정입니다
const _navItems = [
NavItem(
activeIcon: Icons.home,
label: '홈',
),
NavItem(
activeIcon: Icons.calendar_today,
label: '스케줄',
),
NavItem(
activeIcon: Icons.chat,
label: '채팅',
),
NavItem(
activeIcon: Icons.person,
label: '프로필',
),
];
그리고 items를 다음과 같이 리팩토링합니다.
items: _navItems.map((item) {
return BottomNavigationBarItem(
icon: Icon(item.activeIcon),
label: item.label,
);
}).toList(),
그리고 하드 코딩되어 있는 부분을 개선합니다.
@override
void initState() {
super.initState();
/// length를 `_navItems.length` 로 교체
_tabController = TabController(length: _navItems.length, vsync: this);
_tabController.addListener(tabListener);
}
UI 개선하기
한 가지 불만이 있습니다. BottomNavigationBar에서, 선택된 item만 강조되는 효과가 있습니다. 텍스트도 선택된 아이템만 나오고 있습니다.
이를 원하지 않는다면, 몇 가지 옵션을 넣어주면 됩니다.
BottomNavigationBar(
selectedItemColor: kPrimaryDarkColor, // 선택된 아이템의 색
unselectedItemColor: kPrimaryDarkColor, // 선택되지 않은 아이템의 색 (여기서는 둘이 같게 함)
type: BottomNavigationBarType.fixed, // 아이콘과 텍스트가 항상 함께 보임
onTap: // 나머지는 동일
)
type: BottomNavigationBarType.fixed
를 옵션으로 넣어주면, 텍스트가 모두 보이게 됩니다.
위에 그림에서 선택된 아이템의 사이즈가 바뀌는 것을 확인할 수 있는데, 이 역시 옵션으로 조절 가능합니다.
BottomNavigationBar(
selectedItemColor: kPrimaryDarkColor,
unselectedItemColor: kPrimaryDarkColor,
type: BottomNavigationBarType.fixed,
selectedFontSize: 10, // 선택된 아이템과 비선택된 아이템의 텍스트 크기를 같게
unselectedFontSize: 10,
// ...
)
단순히 폰트 사이즈 뿐만 아니라, 다양한 텍스트 옵션을 조절하고 싶다면, selectedFontSize
등이 아닌 selectedTextStyle
속성을 조절합니다.
BottomNavigationBar(
selectedItemColor: kPrimaryDarkColor,
unselectedItemColor: kPrimaryDarkColor,
selectedLabelStyle: const TextStyle(
fontWeight: FontWeight.bold, // 선택된 아이템의 글자 크기를 굵게
fontSize: 10,
),
unselectedLabelStyle: const TextStyle(
fontSize: 10, // 둘다 폰트 사이즈는 10으로 고정
),
type: BottomNavigationBarType.fixed,
// ...
)
선택된 ‘홈’ 아이템의 텍스트가 bold
로 적용된 것을 확인할 수 있습니다.
선택된 아이템의 아이콘을 바꾸고 싶다면?
그런데 위의 그림을 보면, 단순히 아이콘의 색이 바뀌는 게 아니라, 아예 아이콘 자체가 바뀌고 있습니다.
이건 어떻게 한 걸까요? 선택된 index와 해당 아이템의 인덱스를 비교하면 됩니다.
정말 다행히도, 이미 NavItem
모델과 item 리스트를 따로 분리해놓았기 때문에, 약간의 수정만 거치면 됩니다.
NavItem 모델을 다음과 같이 변경합니다.
class NavItem {
/// 현재 item의 인덱스
final int index;
final IconData activeIcon;
/// 비활성화된 item에서 어떤 아이콘을 표시할 지
final IconData inactiveIcon;
final String label;
const NavItem({
required this.index,
required this.activeIcon,
required this.inactiveIcon,
required this.label,
});
}
그리고 index
와 inactiveIcon
정보를 리스트에 추가합니다.
const _navItems = [
NavItem(
index: 0, // index 추가
activeIcon: Icons.home,
inactiveIcon: Icons.home_outlined, // icon 추가
label: '홈',
),
NavItem(
index: 1,
activeIcon: Icons.calendar_today,
inactiveIcon: Icons.calendar_today_outlined,
label: '스케줄',
),
NavItem(
index: 2,
activeIcon: Icons.chat,
inactiveIcon: Icons.chat_outlined,
label: '채팅',
),
NavItem(
index: 3,
activeIcon: Icons.person,
inactiveIcon: Icons.person_outline,
label: 'My',
),
];
그리고 BottomNavigationBarItem
부분을 다음과 같이 변경합니다.
BottomNavigationBar(
items: _navItems.map((item) {
return BottomNavigationBarItem(
icon: Icon(
// 현재 index와 item.index가 같은 지 비교해서 서로 다른 icon을 내보낸다
_index == item.index ? item.activeIcon : item.inactiveIcon,
),
label: item.label,
);
}).toList(),
)
이제 아이콘을 누를 때마다, 서로 다른 아이콘이 보여지는 효과를 구현할 수 있습니다.
아이콘 변경이 되지 않는 UI는 아래와 같습니다.
이때에는 색을 통해서 선택된 아이콘을 표시하게 됩니다.
BottomNavigationBar(
selectedItemColor: primaryColor, // 선택된 아이템 컬러와
unselectedItemColor: bodyTextColor, // 선택되지 않은 아이템 컬러를 다르게 하여 표시
selectedFontSize: 10,
unselectedFontSize: 10,
type: BottomNavigationBarType.fixed,
)
전체 코드
class TabView extends StatefulWidget {
const TabView({super.key});
@override
State<TabView> createState() => _TabViewState();
}
class _TabViewState extends State<TabView> with TickerProviderStateMixin {
late TabController _tabController;
int _index = 0;
@override
void initState() {
super.initState();
_tabController = TabController(length: _navItems.length, vsync: this);
_tabController.addListener(tabListener);
}
@override
void dispose() {
_tabController.removeListener(tabListener);
super.dispose();
}
void tabListener() {
setState(() {
_index = _tabController.index;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
bottomNavigationBar: BottomNavigationBar(
selectedItemColor: kPrimaryDarkColor,
unselectedItemColor: kPrimaryDarkColor,
selectedLabelStyle: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 10,
),
unselectedLabelStyle: const TextStyle(
fontSize: 10,
),
type: BottomNavigationBarType.fixed,
onTap: (int index) {
_tabController.animateTo(index);
},
currentIndex: _index,
items: _navItems.map((item) {
return BottomNavigationBarItem(
icon: Icon(
_index == item.index ? item.activeIcon : item.inactiveIcon,
),
label: item.label,
);
}).toList(),
),
body: TabBarView(
physics: const NeverScrollableScrollPhysics(),
controller: _tabController,
children: const [
HomePage(),
SchedulePage(),
Center(child: Text('채팅')),
Center(child: Text('My')),
],
),
);
}
}
class NavItem {
final int index;
final IconData activeIcon;
final IconData inactiveIcon;
final String label;
const NavItem({
required this.index,
required this.activeIcon,
required this.inactiveIcon,
required this.label,
});
}
const _navItems = [
NavItem(
index: 0,
activeIcon: Icons.home,
inactiveIcon: Icons.home_outlined,
label: '홈',
),
NavItem(
index: 1,
activeIcon: Icons.calendar_today,
inactiveIcon: Icons.calendar_today_outlined,
label: '스케줄',
),
NavItem(
index: 2,
activeIcon: Icons.chat,
inactiveIcon: Icons.chat_outlined,
label: '채팅',
),
NavItem(
index: 3,
activeIcon: Icons.person,
inactiveIcon: Icons.person_outline,
label: 'My',
),
];
예전에 사용했던 방법
저도 위의 방법을 몰랐을 때는, 아래와 같이 구현을 했었습니다.
class MainPage extends StatefulWidget {
const MainPage({super.key});
@override
State<MainPage> createState() => _MainPageState();
}
class _MainPageState extends State<MainPage> {
int _selectedIndex = 0;
static List<Widget> pages = <Widget>[
const MapPage(),
const PostsPage(),
const ProfilePage(),
];
void _onItemTapped(int index) {
if (mounted) {
setState(() {
_selectedIndex = index;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: pages.elementAt(_selectedIndex),
),
bottomNavigationBar: BottomNavigationBar(
items: const <BottomNavigationBarItem>[
BottomNavigationBarItem(
icon: Icon(Icons.home),
label: 'Home',
backgroundColor: Colors.blue,
),
BottomNavigationBarItem(
icon: Icon(Icons.list),
label: 'Posts',
backgroundColor: Colors.green,
),
BottomNavigationBarItem(
icon: Icon(Icons.person),
label: 'Profile',
backgroundColor: Colors.pink,
),
],
currentIndex: _selectedIndex,
onTap: _onItemTapped,
));
}
}
Flutter를 배운 지 한 달도 안 된 시기에 구현했던 BottomNavigationBar를 이용한 TabBarView인데, 정말 아무것도 모르는 상태에서 꾸역꾸역 만들었답니다.
class _MainPageState extends State<MainPage> {
/// 현재 선택된 인덱스
int _selectedIndex = 0;
/// 페이지 리스트들
/// 왜 굳이 static으로 선언했는지는 모르겠습니다
static List<Widget> pages = <Widget>[
const MapPage(),
const PostsPage(),
const ProfilePage(),
];
/// 페이지 인덱스를 변경시키는 `setState` 함수
void _onItemTapped(int index) {
if (mounted) {
setState(() {
_selectedIndex = index;
});
}
}
// ...
}
그때 당시에는 TickerProviderStateMixin
과 TabController
의 존재를 몰라서, _onItemTapped
에 setState
로만 인덱스 변경을 시도했습니다.
body: Center(
child: pages.elementAt(_selectedIndex),
),
body에는 pages[_selectedIndex]
를 Center
로 감싸서 화면을 보여주고 있습니다.
그래서 onTap
시에 해당 인덱스로 page 전환이 이루어지는 구조입니다.
생각보다 잘 만들었네요.
이처럼 탭바 페이지를 구현하는 방법은 매우 다양하고 취향입니다. 여기서는 SingleTickerProviderStateMixin을 사용하는 한 가지 방법을 소개했습니다.
참고 자료
- [코드팩토리] [중급] Flutter 진짜 실전! 상태관리, 캐시관리, Code Generation, GoRouter, 인증로직 등 중수가 되기 위한 필수 스킬들!, Code Factory
- TabBarView class, Flutter Docs
- 본 게시물은 B612의 내부 자료를 일부 포함하고 있습니다