제가 iOS(찍먹), Android 개발만 계속 하다가 Flutter의 세계에 발을 들였던 게 올해 1월로, 어느덧 10여 개월이 흘렀는데 말이죠.
그동안 플러터로 진행한 프로젝트도 2개나(그 중 하나는 무려 Andriod로 출시까지 했다가 Flutter로 넘어갔습니다) 되다보니 작성할 콘텐츠 거리가 꽤나 쌓였습니다. 앞으로는 플러터 포스트도 하나씩 부지런히 풀어가도록 하겠습니다😊
그동안 진행한 프로젝트 마다 바텀네비게이션으로 화면을 이동했기에 바텀네비게이션을 구현할 일이 많았는데요, 생각해보면 진행한 프로젝트에서 90%는 제가 바텀네비를 도맡아 만들었던 것 같습니다. 이 정도면 바텀네비 전문 개발자가 아닌지?
그치만 전 개인적으로 바텀네비를 구현하는 일을 좋아합니다. 한 번 만들어놓으면 바뀔 일이 잘 없긴 하지만, 어찌보면 프로젝트의 시작이 되는 일이니까요. 제가 바텀네비를 작업하고 나면 각 탭 별 화면 담당자를 나눠 작업이 착착 진행되는, 그 지점이 참 좋습니다.
앞서 사담이 좀 길었지만.., 이번에는 조금 특이했던 게 아이콘 밑에 label도 함께 표시해 주어야 한다는 점이었습니다. 학교 성적 조회 어플을 만들다보니 공시, 학기, 전체 성적이라는 3가지 각기 다른 성적을 아이콘만으로 구별하기는 좀 애매하더라구요 ^~^
바텀네비게이션이 늘상 그렇듯, 구현해주어야 하는 요구사항은 크게 2가지가 될 것입니다.
- 선택한 아이템은 바텀네비에서 흰색으로 표시해주어야 한다.
- 아이템이 선택되면, 위에 표시되는 페이지를 변경해주어야 한다.
assets > images > menu
를 두어 바텀네비 아이콘이 활성화/비활성화 되었을 때의 에셋을 추가해 준다.
Flutter에서는 assets 안에 패키지를 자유롭게 만들 수 있다는 게 큰 장점인 것 같다. (menu 폴더 안에 이미지를 추가할 수 있다는 것에 감명받은 Android 개발자🥹)
그렇지만 이미지 사용을 위해서는 선행되어야 하는 작업이 있다.
pubspec.yaml
에 flutter_svg
(svg 이미지를 추가했기 때문에)와 assets의 경로
를 추가해 준다.
dependencies:
flutter:
sdk: flutter
flutter_svg: ^2.0.9
# The following section is specific to Flutter packages.
flutter:
assets:
- assets/images/
- assets/images/menu/
바텀네비 아이콘은 menu로 한 번 더 들어가서 저장했기 때문에 assets/images/menu/
까지 꼭 추가해 주어야 한다.
바텀네비에 사용될 이미지들은 관리를 손쉽게 하기 위해 svg_icons
에 경로를 저장해 주었다.
class SvgIcons {
/// Menu
static SvgPicture menuPublic =
SvgPicture.asset("assets/images/menu/public_grade.svg");
static SvgPicture menuPublicActive =
SvgPicture.asset("assets/images/menu/public_grade_active.svg");
static SvgPicture menuSemester =
SvgPicture.asset("assets/images/menu/semester_grade.svg");
static SvgPicture menuSemesterActive =
SvgPicture.asset("assets/images/menu/semester_grade_active.svg");
static SvgPicture menuTotal =
SvgPicture.asset("assets/images/menu/total_grade.svg");
static SvgPicture menuTotalActive =
SvgPicture.asset("assets/images/menu/total_grade_active.svg");
static SvgPicture menuSetting =
SvgPicture.asset("assets/images/menu/setting.svg");
static SvgPicture menuSettingActive =
SvgPicture.asset("assets/images/menu/setting_active.svg");
}
BottomNavigationBar 안에서 아이템의 label를 표시해줄지, 배경색은 뭘로 할지, 선택되었을 때와 선택되지 않았을 떄의 label 폰트 크기는 뭘로 할지 등 다양한 옵션을 부여할 수 있다.
BottomNavigationBar(
type: BottomNavigationBarType.fixed,
currentIndex: _tabController.index,
showSelectedLabels: true,
showUnselectedLabels: true,
backgroundColor: ColorStyles.bottomNavBackground,
selectedItemColor: Colors.white,
unselectedItemColor: ColorStyles.itemBackground,
selectedFontSize: 12,
unselectedFontSize: 12,
items: <BottomNavigationBarItem>[
BottomNavigationBarItem(
icon: SvgIcons.menuPublic,
activeIcon: SvgIcons.menuPublicActive,
label: "공시 성적"),
BottomNavigationBarItem(
icon: SvgIcons.menuSemester,
activeIcon: SvgIcons.menuSemesterActive,
label: "학기 성적"),
BottomNavigationBarItem(
icon: SvgIcons.menuTotal,
activeIcon: SvgIcons.menuTotalActive,
label: "전체 성적"),
BottomNavigationBarItem(
icon: SvgIcons.menuSetting,
activeIcon: SvgIcons.menuSettingActive,
label: "마이페이지"),
])
위의 BottomNavigation 코드를 화면에 표시해주기 위해 Scaffold 안에 넣게 되면
class MainScreen extends StatefulWidget {
const MainScreen({super.key});
static String routeName = "/";
State<MainScreen> createState() => _MainScreenState();
}
class _MainScreenState extends State<MainScreen> {
Widget build(BuildContext context) {
return Scaffold(
bottomNavigationBar: BottomNavigationBar(
// 위의 BottomNavigationBar 코드
);
}
}
위와 같은 형태가 된다.
main.dart
코드만 수정하고 바로 돌려보자.
void main() {
runApp(const GachonGradeApp());
}
/// 라우팅 설정 (pushNamed를 통해 쉽게 화면 라우팅을 할 수 있다)
final route = {
MainScreen.routeName: (context) => const MainScreen(),
};
class GachonGradeApp extends StatelessWidget {
const GachonGradeApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'GachonGrade',
theme: ThemeData(
),
initialRoute: MainScreen.routeName,
routes: route,
);
}
}
첫 화면으로 MainScreen이 나오도록 한다.
실행시켜보면 위와 같은 화면이 나온다. 아직 아이템 클릭 이벤트는 없다.
클릭했을 때 선택한 아이템을 변경하고, 화면을 전환하는 코드를 추가해야 한다.
class PublicGradeTab extends StatelessWidget {
const PublicGradeTab({super.key});
Widget build(BuildContext context) {
return const Scaffold(
backgroundColor: ColorStyles.defaultScreenBackground,
body: Center(
child: Text(
"공시 성적 화면",
style: TextStyle(color: Colors.white),
)),
);
}
}
현재 바텀네비 아이템 개수는 총 4개이다. 바텀네비 하나당 화면 하나를 매칭해 총 4개의 화면이 필요하다.
위의 예시는 공시 성적의 PublicGradeTab
이지만, 이런 식으로 화면마다 구분이 될 수 있게끔 SemesterGradeTab
, TotalGradeTab
, SettingTab
를 각각 추가해 준다.
화면 전환을 관리해 줄 TabController와, 화면 안에 들어갈 TabItem을 정의한다.
TabItem에는 바로 직전 만들어준 화면들을 List<Widget> 형태로 넣어준다.
TabController에서는 애니메이션을 지원하는데, TabController 초기화 및 필수 조건인 vsync
사용을 위해서는 class 뒤에 with SingleTickerProviderStateMixin
를 붙여줘야 한다. vsync
는 애니메이션 최적화 및 언제 재생할지 시간을 세어주는 등의 기능을 위해 필요하다. SingleTickerProviderStateMixin는 현재 위젯 트리가 활성화된 동안만 Tick하는 단일 Ticker를 제공하는 것이다.
class _MainScreenState extends State<MainScreen> with SingleTickerProviderStateMixin {
late TabController _tabController;
final List<Widget> _tabItem = [
const PublicGradeTab(),
const SemesterGradeTab(),
const TotalGradeTab(),
const SettingTab(),
];
void initState() {
super.initState();
_tabController = TabController(length: _tabItem.length, vsync: this);
}
// 네비게이션바 클릭 이벤트
void _onNavigationBarTab(int index) {
setState(() {
_tabController.animateTo(index);
});
}
Widget build(BuildContext context) {
// ...
}
}
class MainView extends StatefulWidget {
const MainView({super.key});
State<MainView> createState() => _MainViewState();
}
class _MainViewState extends State<MainView> with SingleTickerProviderStateMixin {
Widget build(BuildContext context) {
return Scaffold(
body: TabBarView(
physics: const NeverScrollableScrollPhysics(),
controller: _tabController,
children: _tabItem,
),
bottomNavigationBar: Theme(
data: ThemeData(
// 아이템 클릭 효과 제거
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
),
child: BottomNavigationBar(
// ...
onTap: _onNavigationBarTab),
)
);
}
}
Scaffold의 body에 TabBarView를 두고, 앞서 만든 _tabController
과 _tabItem
를 넣어준다.
BottomNavigatoin 코드는 위에서 작성했던 것과 동일한데, onTap으로 마찬가지로 _onNavigationBarTab
을 넣어준다. 클릭 시 다른 화면으로 이동시켜주는 역할이다.
바텀네비 아이템 클릭 효과 제거를 위해서 BottomNavigationBar를 Theme로 감싸는 코드도 추가했다.
class MainScreen extends StatefulWidget {
const MainScreen({super.key});
static String routeName = "/";
State<MainScreen> createState() => _MainScreenState();
}
class _MainScreenState extends State<MainScreen> with SingleTickerProviderStateMixin {
late TabController _tabController;
final List<Widget> _tabItem = [
const PublicGradeTab(),
const SemesterGradeTab(),
const TotalGradeTab(),
const SettingTab(),
];
void initState() {
super.initState();
_tabController = TabController(length: _tabItem.length, vsync: this);
}
void _onNavigationBarTab(int index) {
setState(() {
_tabController.animateTo(index);
});
}
Widget build(BuildContext context) {
return Scaffold(
body: TabBarView(
physics: const NeverScrollableScrollPhysics(),
controller: _tabController,
children: _tabItem,
),
bottomNavigationBar: Theme(
data: ThemeData(
// 아이템 클릭 효과 제거
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
),
child: BottomNavigationBar(
type: BottomNavigationBarType.fixed,
currentIndex: _tabController.index,
showSelectedLabels: true,
showUnselectedLabels: true,
backgroundColor: ColorStyles.bottomNavBackground,
selectedItemColor: Colors.white,
unselectedItemColor: ColorStyles.itemBackground,
selectedFontSize: 12,
unselectedFontSize: 12,
items: <BottomNavigationBarItem>[
BottomNavigationBarItem(
icon: SvgIcons.menuPublic,
activeIcon: SvgIcons.menuPublicActive,
label: "공시 성적"),
BottomNavigationBarItem(
icon: SvgIcons.menuSemester,
activeIcon: SvgIcons.menuSemesterActive,
label: "학기 성적"),
BottomNavigationBarItem(
icon: SvgIcons.menuTotal,
activeIcon: SvgIcons.menuTotalActive,
label: "전체 성적"),
BottomNavigationBarItem(
icon: SvgIcons.menuSetting,
activeIcon: SvgIcons.menuSettingActive,
label: "설정"),
],
onTap: _onNavigationBarTab),
)
);
}
}