바텀 네비게이션을 구현하려면 아래와 같은 작업을 하면 된다.
바텀 탭 Route 클래스 구현
@Serializable
sealed class BottomNavRoute {
@Serializable
data object BottomHome : BottomNavRoute()
@Serializable
data object BottomAssets : BottomNavRoute()
@Serializable
data object BottomRecords : BottomNavRoute()
@Serializable
data object BottomHealth : BottomNavRoute()
@Serializable
data object BottomShopping : BottomNavRoute()
}
바텀 탭 Enum Class 구현
바텀 탭이 가져야 할 정보들을 Enum Class 로 선언합니다.
UI 에 표시될 텍스트, 선택, 비선택 아이콘, description 파라미터가 존재하고, route 정보도 포함하고 있습니다.
@Serializable
enum class BottomTab(
val label: String,
val route: BottomNavRoute,
val selectedIcon: Int,
val unselectedIcon: Int,
val description : String = ""
) {
HOME("홈", BottomNavRoute.BottomHome, R.drawable.ic_home, R.drawable.ic_home, "홈 화면"),
ASSETS("자산", BottomNavRoute.BottomAssets, R.drawable.ic_asset, R.drawable.ic_asset, "자산 관리"),
RECORDS("가계부", BottomNavRoute.BottomRecords, R.drawable.ic_calendar, R.drawable.ic_calendar, "가계부"),
HEALTH("건강", BottomNavRoute.BottomHealth, R.drawable.ic_health, R.drawable.ic_health, "건강"),
SHOPPING("쇼핑", BottomNavRoute.BottomShopping, R.drawable.ic_bag, R.drawable.ic_bag, "쇼핑")
}
필요한 기능
private val currentDestination: NavDestination?
@Composable get() = navController
.currentBackStackEntryAsState().value?.destination
val currentTab: BottomTab?
@Composable get() = BottomTab.entries.find { tab ->
currentDestination?.route == tab.route::class.qualifiedName
}
각 바텀 탭에서 tab == currentTab 이면 다른 selectedIcon 을 적용하고, 아니면 unSelectedIcon 을 적용합니다.
fun navigate(tab: BottomTab) {
val navOptions = navOptions {
popUpTo(BottomTab.HOME.route) {
inclusive = false
}
launchSingleTop = true
restoreState = true
}
when (tab) {
BottomTab.HOME -> navController.navigateToHome(navOptions)
BottomTab.ASSETS -> navController.navigateToAssets(navOptions)
BottomTab.RECORDS -> navController.navigateToRecords(navOptions)
BottomTab.HEALTH -> navController.navigateToHealth(navOptions)
BottomTab.SHOPPING -> navController.navigateToShopping(navOptions)
}
}
popUpTo(BottomTab.HOME.route) { inclusive = false }
뒤로가기 시 BottomTab.Home.route , 즉 홈까지의 스택을 제거합니다.
스택에 (Top)현재 화면 - (A) - (B) - (C) - Home - (D) - … 이런식으로 쌓여있을 때, 뒤로가기를 하면 현재화면 , A , B , C 가 스택에서 제거됩니다.
inclusive = false 속성을 통해 파라미터로 들어온 Home 은 스택에서 제거되지 않습니다.
inclusive 속성의 기본값은 false 지만 명시적으로 선언해주었습니다.
launchSingleTop = true
현재 탑(Top) 목적지가 이동하려는 목적지와 같다면 새 인스턴스를 만들지 않고 기존 인스턴스를 재사용합니다.
같은 탭을 여러 번 누를 때, 화면이 계속 중첩되지 않도록 방지합니다.
restoreState = true
해당 목적지로 이동할 때 이전에 저장된 상태를 복원합니다.
탭 간 이동 시 스크롤 위치, 입력 내용 등 BottomNavigation이나 NavHost와 함께 사용할 때 탭 전환 간의 상태 유지를 위해 주로 사용됩니다.
특정 화면에서는 바텀 네비게이션이 보이면 안 될 때 사용합니다.
@Composable
fun shouldShowBottomBar() : Boolean {
// 기능 정의
val showBottomBar = BottomTab.entries.any {
currentDestination?.hasRoute(it.route::class) ?: false
}
return showBottomBar
}
이 경우에는 현재 경로가 BottomTab 경로에 존재한다면 바텀 네비게이션을 보여줍니다. (그 외의 화면에서는 바텀 네비를 숨깁니다.)
class BottomNavController(
val navController: NavHostController
) {
private val currentDestination: NavDestination?
@Composable get() = navController
.currentBackStackEntryAsState().value?.destination
val startDestination = BottomTab.HOME.route
val currentTab: BottomTab?
@Composable get() = BottomTab.entries.find { tab ->
currentDestination?.route == tab.route::class.qualifiedName
}
fun navigate(tab: BottomTab) {
val navOptions = navOptions {
popUpTo(BottomTab.HOME.route) {
inclusive = false
}
launchSingleTop = true
restoreState = true
}
when (tab) {
BottomTab.HOME -> navController.navigateToHome(navOptions)
BottomTab.ASSETS -> navController.navigateToAssets(navOptions)
BottomTab.RECORDS -> navController.navigateToRecords(navOptions)
BottomTab.HEALTH -> navController.navigateToHealth(navOptions)
BottomTab.SHOPPING -> navController.navigateToShopping(navOptions)
}
}
private fun NavController.navigateToHome(navOptions: NavOptions) {
navController.navigate(BottomTab.HOME.route, navOptions)
}
fun NavController.navigateToAssets(navOptions: NavOptions) {
navController.navigate(BottomTab.ASSETS.route, navOptions)
}
fun NavController.navigateToRecords(navOptions: NavOptions) {
navController.navigate(BottomTab.RECORDS.route, navOptions)
}
fun NavController.navigateToHealth(navOptions: NavOptions) {
navController.navigate(BottomTab.HEALTH.route, navOptions)
}
fun NavController.navigateToShopping(navOptions: NavOptions) {
navController.navigate(BottomTab.SHOPPING.route, navOptions)
}
@Composable
fun shouldShowBottomBar() = BottomTab.entries.any {
currentDestination?.hasRoute(it.route::class) ?: false
}
}
@Composable
fun BottomNavHost(
modifier: Modifier = Modifier,
navigator: BottomNavController,
padding: PaddingValues
) {
NavHost(
navController = navigator.navController,
startDestination = navigator.startDestination,
) {
composable<BottomNavRoute.BottomHome> {
BottomHomeRoute(padding = padding)
}
composable<BottomNavRoute.BottomAssets> {
BottomAssetsRoute(padding = padding)
}
composable<BottomNavRoute.BottomRecords> {
BottomRecordsRoute(padding = padding)
}
composable<BottomNavRoute.BottomHealth> {
BottomHealthRoute(padding = padding)
}
composable<BottomNavRoute.BottomShopping> {
BottomShoppingRoute(padding = padding)
}
}
}
편의상 이동 이벤트는 구현하지 않았습니다.
실제 사용한다면 메인 탭 마다 Nested Nav Graph 가 형성 될 것입니다.
fun MainNavHost(...) {
NavHost(...) {
homeGraph(...)
assetsGraph(...)
recordsGraph(...)
healthGraph(...)
shoppingGraph(...)
}
상위 요소로부터 BottomTab 리스트에 대한 정보를 전달받고, 만든 BottomNavController 클래스로부터 currentTab, onTabSelected 이벤트를 전달받습니다.
@Composable
fun BottomBar(
modifier: Modifier = Modifier,
visible: Boolean,
tabs: List<BottomTab>,
currentTab: BottomTab?,
onTabSelected: (BottomTab) -> Unit,
) {
AnimatedVisibility(
visible = visible,
enter = fadeIn() + slideIn { IntOffset(0, it.height) },
exit = fadeOut() + slideOut { IntOffset(0, it.height) }
) {
Row(
modifier = modifier
.fillMaxWidth()
.height(80.dp)
.drawBehind {
val borderThickness = 1.dp.toPx()
drawLine(
color = Color.DarkGray,
start = Offset(0f, 0f),
end = Offset(size.width, 0f),
strokeWidth = borderThickness
)
},
) {
tabs.forEach { tab ->
BottomBarItem(
tab = tab,
selected = tab == currentTab,
onClick = { onTabSelected(tab) },
)
}
}
}
}
@Composable
private fun RowScope.BottomBarItem(
modifier: Modifier = Modifier,
tab: BottomTab,
selected: Boolean,
onClick: () -> Unit,
) {
val itemSelectColor = if (selected) Color.Black else Color.DarkGray
val itemSelectIcon = if (selected) tab.selectedIcon else tab.unselectedIcon
Column(
modifier = modifier
.padding(vertical = 10.dp)
.fillMaxHeight()
.align(Alignment.CenterVertically)
.weight(1f)
.selectable(
selected = selected,
role = Role.Tab,
onClick = onClick,
indication = null,
interactionSource = remember { MutableInteractionSource() }
),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterVertically)
) {
Icon(
imageVector = ImageVector.vectorResource(itemSelectIcon),
contentDescription = tab.description,
tint = Color.Unspecified
)
Text(
text = tab.label,
color = itemSelectColor
)
}
}
@Preview
@Composable
private fun MainBottomBarPreview() {
ComposeNavigationTheme {
BottomBar(
visible = true,
tabs = BottomTab.entries,
currentTab = BottomTab.HOME,
onTabSelected = { },
)
}
}
이 UI 코드는 참고용이니 복사 붙여넣기 후 테스트해보시길 바랍니다.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ComposeNavigationTheme {
val navController = rememberNavController()
val navigator: BottomNavController = remember(navController) { BottomNavigator(navController) }
Scaffold(modifier = Modifier.fillMaxSize(),
bottomBar = {
BottomBar(
modifier = Modifier
.background(Color.White)
.navigationBarsPadding(),
visible = navigator.shouldShowBottomBar(),
tabs = BottomTab.entries,
currentTab = navigator.currentTab,
onTabSelected = { navigator.navigate(it) }
)
}) { innerPadding ->
BottomNavHost(
navigator = navigator,
padding = innerPadding
)
}
}
}
}
}