Navigation 2.8.0-alpha08 부터 Compose Navigation은 Kotlin DSL을 사용하여 NavGraph를 정의할 때 Kotlin Serialization을 기반으로 한 Type Safe 시스템을 제공한다.
Type Safe란 무엇이고 왜 필요한지를 알아보며 Bottom Navigation을 설계해보고자 한다.
Type Safe는 프로그래밍 언어나 시스템이 데이터의 타입을 엄격하게 관리하여, 잘못된 타입의 데이터가 사용될 때 컴파일 시점에 오류를 발생시키는 것을 의미한다.
예를 들어, 정수형 변수에 문자열을 할당하려고 하면 컴파일러가 이를 감지하고 오류를 발생시킵니다.
Compose Navigation에서 문자열 기반의 탐색 방식은 여러 문제점을 일으킬 수 있다.
화면 간 이동을 문자열로 처리하므로, 오타나 잘못된 문자열 사용 시 런타임 오류가 발생할 수 있다.
// 잘못된 문자열 사용 예시
navController.navigate("proifle") // "profile" 오타 발생
문자열 기반 탐색은 데이터 타입을 명시적으로 검증하지 않으므로, 화면 간 데이터 전달 시 타입 불일치 오류가 발생할 수 있다. 역시 런타임 오류가 발생할 수 있다.
// 잘못된 데이터 타입 전달 예시
navController.navigate("profile/userId=abc") // userId는 정수형이어야 함
// 올바른 데이터 타입 전달 예시
navController.navigate("profile/userId=123")
문자열 기반 탐색은 코드의 가독성을 저하 하고 유지보수를 어렵게 만든다. 특히, 앱의 규모가 커질수록 문자열 기반 탐색은 더욱 복잡해지고 오류 발생 가능성을 높인다.
// 복잡한 문자열 기반 탐색 예시
navController.navigate("product/id=123&name=example&price=1000")
세 개의 화면(Home, Statistics, Setting)을 이동하는 Bottom Navigation을 설계할 것이다.
Compose Navigation과 Material3의 NavigationBar를 이용할 것이다.
먼저 Compose Navigation과 Material3 의존성을 추가해주자.
implementation("androidx.navigation:navigation-compose:$nav_version")
implementation("androidx.compose.material3:material3")
Type-Safe한 Compose Bottom Navigation을 설계하는 첫 번째 단계는 탐색 경로(Route)를 명확하게 정의하는 것이다. 이를 통해 앱의 탐색구조를 체계적으로 관리하고, 런타임 오류를 방지하며 코드의 가독성을 높일 수 있다. (가장 핵심이라 봐도 무방)
sealed interface NavRoute {
@Serializable
data object Home : NavRoute
@Serializable
data object Statistics : NavRoute
@Serializable
data object Setting : NavRoute
}
sealed interface는 한정된 집합의 타입을 표현하는데 적합하며 객체의 파라미터를 유연하게 설정할 수 있다. Navigation의 route는 고정되어 있고 상황에 따라 데이터를 받는 경우도 있으니, sealed interfae를 활용하여 route를 정의해주었다.
@Serializable어노테이션은 Kotlin Serialization 라이브러리를 사용하여 객체를 직렬화할 수 있도록 한다. 이를 통해 탐색 경로를 안전하게 전달할 수 있다.
만약, Navigation으로 이동할 화면이 생긴다면 NavRoute를 상속받는 객체를 추가해주면 된다.
이제 Type Safe하게 정의한 NavRoute를 기반으로 실제 화면 전환을 관리하는 NavHost를 설계할 것이다.
NavHost는 앱의 탐색 그래프를 구성하고, 사용자의 상호작용에 따라 화면을 전환하는 역할을 한다.
@Composable
fun ExampleNavHost(
modifier: Modifier = Modifier,
navController: NavHostController,
startDestination: NavRoute,
) {
NavHost(
navController = navController,
startDestination = startDestination,
modifier = modifier,
) {
homeNavGraph()
statisticsNavGraph()
settingNavGraph()
}
}
NavHost를 갖는 ExampleNavHost컴포저블은 다음과 같은 역할을 수행한다.
NavHost 생성NavHost 컴포저블을 사용하여 실제 화면 전환을 관리한다.navController와 startDestination을 NavHost에 전달하여 탐색 컨트롤러와 시작 화면을 설정한다.homeNavGraph(), statisticsNavGraph(), settingNavGraph() 함수를 호출하여 각 화면의 탐색 그래프를 구성한다.modifier를 통해 NavHost의 외형을 조정할 수 있다.아직 작성하지 않은 homeNavGraph(), statisticsNavGraph(), settingNavGraph()에 대해 설명을 해보겠다.
@Composable
public fun NavHost(
navController: NavHostController,
startDestination: Any,
modifier: Modifier = Modifier,
...
builder: NavGraphBuilder.() -> Unit
)
NavHost의 마지막 매개변수 builder: NavGraphBuilder.() -> Unit은 탐색 그래프를 정의하고 빌드하는 일을 담당한다. Navigation Kotlin DSL의 람다 문법을 사용하므로 함수 본문 안에서 후행 람다로 전달되어 괄호 밖으로 꺼낼 수 있다.
internal fun NavGraphBuilder.homeNavGraph() {
composable<NavRoute.Home> {
HomeScreen()
}
}
internal fun NavGraphBuilder.statisticsNavGraph() {
composable<NavRoute.Statistics> {
StatisticsScreen()
}
}
internal fun NavGraphBuilder.settingNavGraph() {
composable<NavRoute.Setting> {
SettingScreen()
}
}
위 코드는 각 화면의 탐색 그래프 구성 함수이고 아래와 같은 특징을 가진다.
builder의 타입인 NavGraphBuilder의 확장함수 형태이다.composable<NavRoute.Home>, composable<NavRoute.Statistics>, composable<NavRoute.Setting>과 같이 Type Safe한 탐색 경로를 지정한다.HomeScreen(), StatisticsScreen(), SettingScreen())을 연결한다.이제 Bottom Navigation의 하단 탭에 해당하는 NavTab을 설계하여 사용자에게 보여지는 탭 아이템과 탐색 경로를 연결해 볼 것이다. NavTab은 각 탭의 아이콘, 제목, 그리고 연결된 탐색 경로를 정의한다.
internal enum class NavTab(
@DrawableRes val iconResId: Int,
@StringRes val titleTextId: Int,
val route: NavRoute,
) {
HOME(
iconResId = R.drawable.ic_home,
titleTextId = R.string.home_screen_title,
route = NavRoute.Home,
),
STATISTICS(
iconResId = R.drawable.ic_statistics,
titleTextId = R.string.statistics_screen_title,
route = NavRoute.Statistics,
),
SETTING(
iconResId = R.drawable.ic_setting,
titleTextId = R.string.setting_screen_title,
route = NavRoute.Setting,
),
;
companion object {
@Composable
fun find(isRouteMatch: @Composable (NavRoute) -> Boolean): NavTab? {
return entries.find { isRouteMatch(it.route) }
}
@Composable
fun contains(predicate: @Composable (NavRoute) -> Boolean): Boolean {
return entries.map { it.route }.any { predicate(it) }
}
}
}
아이템 별로 동일한 데이터를 가질 것이므로, enum class를 사용하여 Bottom Navigation의 탭 아이템을 명확하게 정의하였다.
companion object는 우선 무시하자
주의사항: NavTab과 NavRoute의 구분
NavTab은 Bottom Navigation의 하단 탭 아이템만을 정의한다는 점을 명심해야 한다. 즉, 사용자가 하단 탐색 모음을 통해 직접 접근할 수 있는 화면만을 NavTab에 포함해야 한다.
예를 들어, 특정 버튼을 눌렀을 때 화면 B로 이동한다고 가정한다. 만약 화면 B가 하단 탭으로 이동하는 화면이 아니라면, 화면 B는 NavTab이 아닌 NavRoute에만 추가하면 된다.
핵심:
NavTab: 하단 탐색 모음을 통해 직접 접근 가능한 최상위 수준의 화면 정의NavRoute: 앱 내의 모든 화면 정의 (하단 탐색 모음을 통한 접근 여부와 무관)이러한 구분을 통해 앱의 탐색 구조를 명확하게 관리하고, 불필요한 복잡성을 줄일 수 있다.
이제 Navigation 관련 로직을 캡슐화한 ExampleNavController 객체를 설계하여, Bottom Navigation의 탐색을 보다 효율적으로 관리해 볼 것이다. ExampleNavController는 NavHostController를 감싸고, 탐색 관련 기능을 제공한다.
internal class ExampleNavController(
private val navController: NavHostController,
) {
val startDestination = NavRoute.Home
val isNavigationBarVisible: Boolean
@Composable
get() = NavTab.contains { navRoute ->
currentDestination?.hasRoute(navRoute::class) == true
}
val currentTab: NavTab
@Composable
get() = NavTab.find { navRoute ->
currentDestination?.hasRoute(navRoute::class) == true
} ?: NavTab.HOME
private val currentDestination: NavDestination?
@Composable
get() = navController.currentBackStackEntryAsState().value?.destination
fun navigate(tab: NavTab) {
val tabNavOptions = navOptions {
popUpTo(navController.graph.id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
when (tab) {
NavTab.HOME -> navController.navigateToHome(tabNavOptions)
NavTab.SETTING -> navController.navigateToSetting(tabNavOptions)
NavTab.STATISTICS -> navController.navigateToStatistics(tabNavOptions)
}
}
}
internal fun NavController.navigateToHome(navigateOptions: NavOptions) {
this.navigate(NavRoute.Home, navigateOptions)
}
internal fun NavController.navigateToSetting(navigateOptions: NavOptions) {
this.navigate(NavRoute.Setting, navigateOptions)
}
internal fun NavController.navigateToSetting(navigateOptions: NavOptions) {
this.navigate(NavRoute.Setting, navigateOptions)
}
ExampleNavController 클래스는 다음과 같은 역할을 수행한다.
NavHostController 캡슐화navController를 멤버 변수로 가지고, NavHostController의 기능을 활용하여 탐색 로직을 구현한다.startDestination을 NavRoute.Home으로 설정하여 앱의 시작 화면을 정의한다.isNavigationBarVisible 속성을 사용하여 현재 화면에 Bottom Navigation을 표시해야 하는지 여부를 결정한다. NavTab.contains 함수를 사용하여 현재 화면이 NavTab에 포함되는지 확인한다.currentTab 속성을 사용하여 현재 선택된 탭 정보를 제공합니다. NavTab.find 함수를 사용하여 현재 화면에 해당하는 NavTab을 찾는다.navigate(tab: NavTab) 함수를 사용하여 지정된 탭으로 이동합니다. navOptions를 사용하여 탐색 옵션을 설정하고, when 표현식을 사용하여 각 탭에 해당하는 탐색 함수를 호출합니다.navOptions를 설정하였다.이제 실제 UI로 보여지는 NavigationBar를 설계해 보겠다. 사용자와 직접 상호작용하는 부분이므로, 사용자 경험을 고려하여 디자인해야 한다.
코드가 길어서 나누어서 설명해보겠다.
NavigationBar
@Composable
internal fun ExampleNavigationBar(
modifier: Modifier = Modifier,
isVisible: Boolean,
tabs: List<NavTab> = NavTab.entries,
currentTab: NavTab,
onTabClick: (NavTab) -> Unit,
) {
AnimatedVisibility(
visible = isVisible,
enter = fadeIn(),
exit = fadeOut(),
) {
NavigationBar(
modifier = modifier
.fillMaxWidth()
.height(73.dp)
.dropShadow(
shape = RectangleShape,
color = Color.Black.copy(0.1f),
blur = 10.dp,
offsetX = 1.dp,
),
containerColor = White,
) {
tabs.forEach { tab ->
ExampleNavigationBarItem(
tab = tab,
selected = tab == currentTab,
onTabClick = { onTabClick(it) },
)
}
}
}
}
AnimatedVisibility 컴포저블을 사용하여 NavigationBar의 가시성을 제어한다.return하는 경우 어색하게 보이므로 애니메이션을 적용하여 자연스럽게 보이도록 했다.NavigationBar 컴포저블을 사용하여 기본적인 틀을 구성한다. modifier를 통해 크기, 그림자 효과 등을 설정하였다.fun Modifier.dropShadow(
shape: Shape = RectangleShape,
color: Color = Color.Black,
blur: Dp = 0.dp,
offsetY: Dp = 0.dp,
offsetX: Dp = 0.dp,
spread: Dp = 0.dp,
) = this.drawBehind {
val shadowSize = Size(size.width + spread.toPx(), size.height + spread.toPx())
val shadowOutline = shape.createOutline(shadowSize, layoutDirection, this)
val paint = Paint()
paint.color = color
if (blur.toPx() > 0) {
paint.asFrameworkPaint().apply {
maskFilter = BlurMaskFilter(blur.toPx(), BlurMaskFilter.Blur.NORMAL)
}
}
drawIntoCanvas { canvas ->
canvas.save()
canvas.translate(offsetX.toPx(), offsetY.toPx())
canvas.drawOutline(shadowOutline, paint)
canvas.restore()
}
}tabs 리스트를 순회하며 ExampleNavigationBarItem 컴포저블을 생성하고, 각 탭 아이템을 NavigationBar에 배치한다.NavigationItem
@Composable
private fun RowScope.ExampleNavigationBarItem(
tab: NavTab,
selected: Boolean,
onTabClick: (NavTab) -> Unit,
) {
NavigationBarItem(
modifier = Modifier.padding(horizontal = 8.dp),
icon = {
Icon(
painter = painterResource(tab.iconResId),
tint = navigationBarItemColor(selected),
contentDescription = null,
)
},
label = {
Text(
text = stringResource(tab.titleTextId),
color = navigationBarItemColor(selected),
)
},
selected = selected,
onClick = { onTabClick(tab) },
colors = NavigationBarItemDefaults.colors(indicatorColor = Color.Transparent),
interactionSource = NoRippleInteractionSource,
}
@Composable
private fun navigationBarItemColor(selected: Boolean): Color {
if (selected) {
return MaterialTheme.colorScheme.primary
}
return Gray400
}
Icon 및 Text 컴포저블을 사용하여 아이콘과 라벨을 추가한다. tab에 정의된 리소스 ID를 사용하여 아이콘과 텍스트를 가져온다.navigationBarItemColor 함수를 사용하여 선택된 탭과 선택되지 않은 탭의 색상을 다르게 표시한다.
onClick 매개변수를 통해 탭 클릭 시 수행할 동작을 정의한다. onTabClick 함수를 호출하여 탭 정보를 전달한다.
Ripple제거
리플효과 O | 리플효과 X |
|---|
지금 프로젝트에서는 Ripple효과가 없는 것이 더 깔끔하다고 판단하여서 제거하고 싶었다.
Ripple효과를 제거해주기 위해서는 interactionSource를 조절해야 한다. 내부 구현을 보면서 어떻게 하면 좋을지 보자.
NavigationBarItem의 내부 구현은 아래와 같다.
@Composable
fun RowScope.NavigationBarItem(
selected: Boolean,
onClick: () -> Unit,
icon: @Composable () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
label: @Composable (() -> Unit)? = null,
alwaysShowLabel: Boolean = true,
colors: NavigationBarItemColors = NavigationBarItemDefaults.colors(),
interactionSource: MutableInteractionSource? = null
) {
@Suppress("NAME_SHADOWING")
val interactionSource = interactionSource ?: remember { MutableInteractionSource() }
...
}
fun MutableInteractionSource(): MutableInteractionSource = MutableInteractionSourceImpl()
@Stable
private class MutableInteractionSourceImpl : MutableInteractionSource {
// TODO: consider replay for new indication instances during events?
override val interactions = MutableSharedFlow<Interaction>(
extraBufferCapacity = 16,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
override suspend fun emit(interaction: Interaction) {
interactions.emit(interaction)
}
override fun tryEmit(interaction: Interaction): Boolean {
return interactions.tryEmit(interaction)
}
}
interactionSource는 null이라는 optional값을 가지고 있다. 그리고 null일 경우 NavigationBarItem에서 기본으로 제공하는 MutableInteractionSource()를 할당해주는 것을 볼 수 있다.
MutableInteractionSourceImpl객체가 Ripple효과를 내는 주범(?)이다. 그렇다면 해당 객체 대신 우리가 생성한 MutableInteractionSource를 상속받는 객체를 넣어주면 Ripple효과를 없앨 수 있다.
internal object NoRippleInteractionSource : MutableInteractionSource {
override val interactions: Flow<Interaction> = emptyFlow()
override suspend fun emit(interaction: Interaction) = Unit
override fun tryEmit(interaction: Interaction): Boolean = true
}
코드는 정말 단순한데, MutableInteractionSource를 상속받고 override한 메서드들이 아무 역할을 하지 않도록 코드를 작성해주면 된다.
우리가 설계한 Bottom Navigation을 적용시키면 아래와 같이 작성할 수 있다.
internal fun MainScreen(
exampleNavController: ExampleNavController,
navController: NavHostController,
) {
Scaffold(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
.navigationBarsPadding(),
bottomBar = {
ExampleNavigationBar(
isVisible = exampleNavController.isNavigationBarVisible,
currentTab = exampleNavController.currentTab,
tabs = NavTab.entries,
onTabClick = {
exampleNavController.navigate(it)
},
)
},
) { innerPadding ->
ExampleNavHost(
modifier = Modifier.padding(innerPadding),
navController = navController,
startDestination = exampleNavController.startDestination,
)
}
statusBarsPadding()와 navigationBarsPadding()은 MainActivity에서 enableEdgeToEdge()속성을 사용하기에 추가한 속성이다.
DroidKnightsApp
https://medium.com/androiddevelopers/navigation-compose-meet-type-safety-e081fb3cf2f8
https://developer.android.com/develop/ui/compose/navigation?hl=ko