[Android/Compose] Bottom Navigation 구현하기 by Jetpack Navigation

Kunnam·2025년 3월 22일

드림학기

목록 보기
4/7

바텀 네비게이션을 구현하려면 아래와 같은 작업을 하면 된다.

  • Route , Enum Class 구현
  • BottomNavController 클래스
  • Bottom NavHost 생성
  • 바텀 네비게이션 컴포넌트 구현

바텀 탭 Route, 바텀 탭 Enum Class 구현

바텀 탭 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, "쇼핑")
}

BottomNavController 클래스 생성

필요한 기능

  • 현재 선택된 탭 확인 (시각적으로 다른 Icon 를 표시할 때 사용함)
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와 함께 사용할 때 탭 전환 간의 상태 유지를 위해 주로 사용됩니다.

  • 바텀 네비게이션 UI 를 표시할 지 알려주는 기능

특정 화면에서는 바텀 네비게이션이 보이면 안 될 때 사용합니다.

@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
    }

}

바텀 NavHost 생성

@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 코드는 참고용이니 복사 붙여넣기 후 테스트해보시길 바랍니다.

MainActivity

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
                    )

                }
            }
        }
    }
}
profile
공부블로그

0개의 댓글