[Flutter] BottomNavigationBar 구현하기
플러터로 앱을 개발하여 MVP를 출시한 이후에 피드백을 받다 보니 하단 네비게이션 바에 이런저런 문제가 있음을 알게 되었습니다.
분명 공식 문서랑 괜찮아 보이는 곳들을 참고했었는데.. 화면이 많아지다 보니 전에는 보이지 않던 문제들이 발생했고, 더 괜찮은 문서를 찾던 중 좋은 가이드가 있어서 공유하게 되었습니다.
꽤 괜찮은 문서
https://codewithandrea.com/articles/multiple-navigators-bottom-navigation-bar/
이런 분들에게 유용할 수 있어요 👍
1. 각각의 탭마다 독립적인 네비게이션 스택이 필요하다
일반적인 앱들은 앱 사용 중에 여러 탭을 왔다 갔다 할 때, 이전에 보던 탭의 히스토리를 보존해주고 있습니다.
대표적으로 멜론 앱의 경우, 검색 탭에서 노래를 검색하던 중 다른 곳으로 갔다 와도 검색 결과를 그대로 보여줍니다.
2. Android 에만 존재하는 "뒤로 가기 버튼" 이 이상하게 동작한다
ios의 경우 백 버튼이 없어서 사용자는 appBar에 있는 뒤로 가기 화살표를 눌러 우리가 의도한 대로 움직이지만,
Android는 백 버튼이 생각보다 이상하게 동작해 당황할 수 있습니다.
특히 네비게이션 바의 경우 일반적인 구조가 아니기 때문에 조심해야 합니다!
3. 현재 보고 있는 탭을 계속 누르면 Navigator push 중첩이 일어난다
이 경우, 현재 탭의 스택에 있는 요소들을 모두 pop 해줘야 합니다.
결과물
구현 코드
1. main()
우선 위젯 트리의 루트는 MaterialApp으로 감싸주고, 메인 위젯인 App()을 호출합니다.
import 'package:flutter/material.dart';
import 'package:flutter_bottomnavigationbar_demo/app.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: App(),
);
}
}
2. App()
네비게이션 부분의 루트 위젯 역할을 하며, 탭 간 이동을 컨트롤하고 있는 변수들을 포함하고 있는 위젯입니다.
import 'package:flutter/material.dart';
import 'package:flutter_bottomnavigationbar_demo/bottom_navigation.dart';
import 'package:flutter_bottomnavigationbar_demo/tab_item.dart';
import 'package:flutter_bottomnavigationbar_demo/tab_navigator.dart';
class App extends StatefulWidget {
@override
State<App> createState() => _AppState();
}
class _AppState extends State<App> {
var _currentTab = TabItem.brunch;
final _navigatorKeys = {
TabItem.brunch: GlobalKey<NavigatorState>(),
TabItem.lunch: GlobalKey<NavigatorState>(),
TabItem.dinner: GlobalKey<NavigatorState>(),
};
void _selectTab(TabItem tabItem) {
if (tabItem == _currentTab) {
/// 네비게이션 탭을 누르면, 해당 네비의 첫 스크린으로 이동!
_navigatorKeys[tabItem]!.currentState!.popUntil((route) => route.isFirst);
} else {
setState(() => _currentTab = tabItem);
}
}
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () async {
final isFirstRouteInCurrentTab =
!await _navigatorKeys[_currentTab]!.currentState!.maybePop();
if (isFirstRouteInCurrentTab) {
// 메인 화면이 아닌 경우
if (_currentTab != TabItem.brunch) {
// 메인 화면으로 이동
_selectTab(TabItem.brunch);
// 앱 종료 방지
return false;
}
}
/// 네비게이션 바의 첫번째 스크린인 경우, 앱 종료
return isFirstRouteInCurrentTab;
},
child: Scaffold(
body: Stack(
children: <Widget>[
_buildOffstageNavigator(TabItem.brunch),
_buildOffstageNavigator(TabItem.lunch),
_buildOffstageNavigator(TabItem.dinner),
],
),
bottomNavigationBar: BottomNavigation(
currentTab: _currentTab,
onSelectTab: _selectTab,
),
),
);
}
Widget _buildOffstageNavigator(TabItem tabItem) {
/// (offstage == false) -> 트리에서 완전히 제거된다.
return Offstage(
offstage: _currentTab != tabItem,
child: TabNavigator(
navigatorKey: _navigatorKeys[tabItem],
tabItem: tabItem,
));
}
}
코드가 길어 나눠서 자세히 설명합니다!
var _currentTab = TabItem.brunch;
현재 사용자가 보고 있는 탭을 가리키는 변수입니다. TabItem.brunch(메인 화면)를 default 값으로 설정하였습니다.
final _navigatorKeys = {
TabItem.brunch: GlobalKey<NavigatorState>(),
TabItem.lunch: GlobalKey<NavigatorState>(),
TabItem.dinner: GlobalKey<NavigatorState>(),
};
각 탭들은 개개의 GlobalKey를 가지는데, 이를 통해 각 탭들은 유니크한 키 값을 가질 수 있고, 각 탭의 첫 번째 route(화면)이 무엇인지도 알 수 있게 됩니다. 공식 문서 참고
TabItem은 enum 타입으로 {brunch, lunch, dinner}를 가지고 있습니다. 아래서 더 자세히 설명합니다.
void _selectTab(TabItem tabItem) {
if (tabItem == _currentTab) {
/// 네비게이션 탭을 누르면, 해당 네비의 첫 스크린으로 이동!
_navigatorKeys[tabItem]!.currentState!.popUntil((route) => route.isFirst);
} else {
setState(() => _currentTab = tabItem);
}
}
_selectTab 은 탭 간 이동과 현재 탭을 누를 때 동작을 구현한 함수입니다. 사용자가 선택한 tabItem을 인자로 받아, 현재 사용자가 보고 있는 탭과 비교합니다.
- 같다면? popUntil 메소드를 사용해 위젯이 탭의 첫 화면일 때까지 비워줍니다.
- 다르다면? 스택은 그대로 두고, 탭 인덱스만 바꿔주면 됩니다.
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () async {
final isFirstRouteInCurrentTab =
!await _navigatorKeys[_currentTab]!.currentState!.maybePop();
if (isFirstRouteInCurrentTab) {
// 메인 화면이 아닌 경우
if (_currentTab != TabItem.brunch) {
// 메인 화면으로 이동
_selectTab(TabItem.brunch);
// 앱 종료 방지
return false;
}
}
/// 네비게이션 바의 첫번째 스크린인 경우, 앱 종료
return isFirstRouteInCurrentTab;
},
App 위젯의 빌드 부분입니다.
WillPopScope 위젯은 안드로이드의 백 버튼에 대한 결과를 onWillPop 속성을 이용해 조작할 수 있습니다.
만약 onWillPop 속성이 false라면, 백 버튼을 누를 시 앱이 종료되지 않습니다.
만약 이런 작업을 해주지 않으면 앱 사용 중 무심코 백 버튼을 눌러 앱이 바로 꺼져버리는 불상사가 일어날 수 있습니다 😭
또한 탭의 첫 화면이더라도, 홈 화면이 따로 존재한다면 백 버튼을 눌러 이동시킬 수 있습니다. 앱이 꺼지기 전의 최후 보루를 설정하는 것이죠!
child: Scaffold(
body: Stack(
children: <Widget>[
_buildOffstageNavigator(TabItem.brunch),
_buildOffstageNavigator(TabItem.lunch),
_buildOffstageNavigator(TabItem.dinner),
],
),
bottomNavigationBar: BottomNavigation(
currentTab: _currentTab,
onSelectTab: _selectTab,
),
),
Scaffold 화면 부분입니다.
전체적인 구성은 Stack으로 이루어져 있기 때문에 사실 모든 탭 화면은 겹쳐져있다고 볼 수 있습니다.
하지만 아래 설명할 Offstage 위젯을 이용하면 on/off 설정을 할 수 있어 하나만 띄우게 되는 것입니다.
BottomNavigation 위젯은 커스텀 위젯으로 아래서 더 자세히 설명합니다.
Widget _buildOffstageNavigator(TabItem tabItem) {
/// (offstage == false) -> 트리에서 완전히 제거된다.
return Offstage(
offstage: _currentTab != tabItem,
child: TabNavigator(
navigatorKey: _navigatorKeys[tabItem],
tabItem: tabItem,
));
}
Stack을 구성하는 _buildOffstageNavigator 위젯입니다.
TabItem을 인자로 받아, 현재 보고 있는 탭이 아닌 탭들은 모두 offstage: false 처리가 됩니다.
이때, false처리가 된 탭들은 모두 off 상태가 되면서 위젯 트리 바깥으로 나오게 됩니다. (물리적인 공간도 삭제)
이런 on/off 방식을 구현하는 방법은 여러 가지인데, 대부분의 경우 offstage를 사용하는 것이 좋다고 합니다!
TabNavigator는 임의로 만든 위젯으로, 각 탭의 route 구성을 정의하고 있고 GlobalKey와 tabItem을 인자로 받습니다. 아래서 더 자세히 설명합니다.
3. tab_item.dart
import 'package:flutter/material.dart';
import 'package:flutter_bottomnavigationbar_demo/brunch.dart';
import 'package:flutter_bottomnavigationbar_demo/dinner.dart';
import 'package:flutter_bottomnavigationbar_demo/lunch.dart';
enum TabItem { brunch, lunch, dinner }
const Map<TabItem, int> tabIdx = {
TabItem.brunch: 0,
TabItem.lunch: 1,
TabItem.dinner: 2,
};
Map<TabItem, Widget> tabScreen = {
TabItem.brunch: const BrunchScreen(),
TabItem.lunch: const LunchScreen(),
TabItem.dinner: const DinnerScreen(),
};
앞서 사용한 TabItem enum 배열, 각 탭에 해당하는 인덱스와 스크린 위젯을 매핑한 정보들을 담고 있습니다.
예시 앱에는 별 다른 정보를 갖고 있지 않아서 스크린을 하나로 묶어도 되지만, 개인적으로 이후 앱의 확장성을 위해 탭마다 다른 스크린을 설정해두는 게 좋다고 생각합니다.
4. BottomNavigation
import 'package:flutter/material.dart';
import 'package:flutter_bottomnavigationbar_demo/navbar_items.dart';
import 'package:flutter_bottomnavigationbar_demo/tab_item.dart';
class BottomNavigation extends StatelessWidget {
BottomNavigation({required this.currentTab, required this.onSelectTab});
final TabItem currentTab;
final ValueChanged<TabItem> onSelectTab;
@override
Widget build(BuildContext context) {
return BottomNavigationBar(
type: BottomNavigationBarType.fixed,
items: [
_buildItem(TabItem.brunch),
_buildItem(TabItem.lunch),
_buildItem(TabItem.dinner),
],
onTap: (index) => onSelectTab(
TabItem.values[index],
),
currentIndex: currentTab.index,
selectedItemColor: Colors.amber,
);
}
BottomNavigationBarItem _buildItem(TabItem tabItem) {
return navbarItems[tabIdx[tabItem]!];
}
}
현재 탭 정보와 탭이 변경할 때 어떤 식으로 동작할지에 대한 함수를 인자로 받습니다.
앱 동작 중에 탭에 있는 변수들이 바뀌지는 않을 것이므로 Stateless 위젯으로 설정하였고 BottomNavigationBar 기본 위젯을 리턴하고 있습니다.
이때 타입은 fixed, shifting 등이 있는데 shifting은 너무 난잡하니.. 개인적으로 fixed로 고정시켜 보여주는 것이 좋아 보입니다.
navbarItems은 임의로 만든 BottomNavigationBarItem 배열로 탭 내부 아이콘이나 라벨(글씨)을 정의할 수 있습니다.
import 'package:flutter/material.dart';
List<BottomNavigationBarItem> navbarItems = [
BottomNavigationBarItem(
icon: Icon(Icons.brunch_dining),
label: '아침',
),
BottomNavigationBarItem(
icon: Icon(Icons.lunch_dining),
label: '점심',
),
BottomNavigationBarItem(
icon: Icon(Icons.dinner_dining),
label: '저녁',
),
];
5. TabNavigator
import 'package:flutter/material.dart';
import 'package:flutter_bottomnavigationbar_demo/tab_item.dart';
class TabNavigatorRoutes {
static const String root = '/';
}
class TabNavigator extends StatelessWidget {
TabNavigator({required this.navigatorKey, required this.tabItem});
final GlobalKey<NavigatorState>? navigatorKey;
final TabItem tabItem;
Map<String, WidgetBuilder> _routeBuilders(BuildContext context) {
return {
TabNavigatorRoutes.root: (context) => tabScreen[tabItem]!,
};
}
@override
Widget build(BuildContext context) {
final routeBuilders = _routeBuilders(context);
return Navigator(
key: navigatorKey,
initialRoute: TabNavigatorRoutes.root,
onGenerateRoute: (routeSettings) {
return MaterialPageRoute(
builder: (context) => routeBuilders[routeSettings.name!]!(context),
);
},
);
}
}
TabNavigator는 모든 탭마다 가지고 있는 Offstage 위젯의 child 값입니다.
자식 위젯 간 이동을 정의할 수 있는 Navigator 위젯을 리턴하며, 탭 각각의 첫 화면(initialRoute), 화면 간 이동(MaterialPageRoute)을 구현하고 있습니다.
전체 코드
https://github.com/suhwan-cheon/flutter_bottomNavigationBar_demo