2025.05.21 새벽에 진행한 Google I/O 2025에서 새로 Compose용으로 만든 Navigation3가 새롭게 공개가 되었다. 기존 Navigation은 애초에 AndroidX나 컴포즈가 만들어지기 전에 공개된 라이브러리인 만큼 신규 컴포즈 패턴과 같이 작업을 할 경우 많은 단점이 존재했는데 이를 이번 신규 Navigation3에서 해결하려고 해서 관심이 생겨 알아보았다.
기존 Navigation 2.0 버전이고 신규 Navigation이 Navigation3이기 때문에 줄여서 Nav2, 신규는 Nav3라고 부르겠습니다
주의! 1.0.0-alpha1 버전으로 작성되어서 지금 보고 계시는 버전과는 많이 다를 수 있습니다
세팅
Navigation3 관련 의존성을 가져오기만 하면 끝입니다
아직 material, lifecycle은 못 가져오는 것 같아서 지원을 안 하는 것 같습니다
[versions]
nav3Core = "1.0.0-alpha01"
nav3Material = "1.0.0-SNAPSHOT" // 아직 지원을 안하는 것 같습니다
nav3Lifecycle = "1.0.0-alpha01" // 아직 지원을 안하는 것 같습니다
kotlinSerialization = "2.1.21"
kotlinxSerializationCore = "1.8.1"
[libraries]
# Core Navigation 3 libraries
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" }
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" }
# Optional add-on libraries
androidx-material3-navigation3 = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation3", version.ref = "nav3Material" }
androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "nav3Lifecycle" }
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinxSerializationCore" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationCore" }
[plugins]
# Optional plugins
jetbrains-kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinSerialization"}
Nav2가 가지고 있는 문제점
- 가장 큰 단점은 화면들의 BackStack 관리를 라이브러리가 해주고 있다는 점이다. 이 BackStack 상태는 간접적으로만 관찰이 가능 하기 때문에 제대로 관리하려면 나만의 BackStack도 가지고 있어야 해서 SSOT가 깨지기 쉽다.
- Nav2는 BackStack 최상위 한 개의 화면을 보여주도록 설계를 했기 때문에 테블랫 같이 큰 화면에서 여러 개의 화면을 동시에 보여주기 어렵다.
그 외에도 여러 개가 있지만 공식에서 직접 언급한 가장 큰 문제점들을 코드상으로 기존에는 어떻게 했고 신규 Nav3에서는 어떻게 변경이 되었는지 알아보겠습니다
BackStack 관리가 어렵다
위에서 얘기를 한 거처럼 Nav2는 BackStack을 라이브러리 내부에서 관리를 하기 때문에 사용 측에서 관리하기 매우 어려웠다 심지어 2.8.0 버전에 Serialization을 통한 Type Safety 기능이 추가된 이후에는 더욱 어려워졌다. 밑에 코드 예시를 통해 설명을 해보겠습니다.
sealed interface Destination {
@Serializable
data object ScreenOne : Destination
@Serializable
data object ScreenTwo : Destination
@Serializable
data object ScreenThree : Destination
}
val navController = rememberNavController()
val navDestination by viewModel.destination.collectAsStateWithLifecycle()
NavHost(
navController = navController,
startDestination = Destination.ScreenOne,
modifier = Modifier.padding(innerPadding)
) {
composable<Destination.ScreenOne> {
ScreenOne(
onNavigateNext = {
viewModel.navigateTo(Destination.ScreenTwo)
},
)
}
composable<Destination.ScreenTwo> {
ScreenTwo(
onNavigateNext = {
viewModel.navigateTo(Destination.ScreenThree)
},
onNavigateBack = {
navController.popBackStack()
},
)
}
composable<Destination.ScreenThree> {
ScreenThree(
onNavigateBack = {
navController.popBackStack()
},
goToHome = {
navController.popBackStack(
route = Destination.ScreenOne,
inclusive = false
)
}
)
}
}
LaunchedEffect(navDestination.destination) {
navController.navigate(navDestination.destination)
}
TypeSafety를 이용한 Navigation으로 정말 간단한 화면을 보여주고 있다. 그리고 callback에 직접 navController를 호출하는 것을 피하고 화면 이동을 더 용이하게 관리하기 위해 viewModel을 통해 관리하고 있다.
코드상 큰 문제가 없어 보이지만 화면 회전, 테마 변경 같은 Configuration Change가 일어날 경우 큰 문제가 발생하고 있다
state를 표현하기 위해 Counter를 추가했다.
GIF를 보면 Configuration Change가 일어나니 Counter가 초기화되고 뒤로 가기 할시 기존 Counter가 찍혀 있는 화면으로 넘어가는 것을 볼 수 있다.
이건 Configuration Change가 일어날 때마다 Composable이 다시 생성되면서 Destination StateFlow한테 마지막 Destination을 받아오고 navigate가 동작해서 생긴 문제다 이를 해결하려면 BackStack을 확인해 현재 화면으로 다시 이동하는지 확인해야 한다.
LaunchedEffect(navDestination.destination) {
if (navController.currentDestination?.hasRoute(navDestination.destination::class) == true) return@LaunchedEffect
navController.navigate(navDestination.destination)
}
하지만 BackStack을 라이브러리에서 관리를 하기 때문에 NavDestination이라고 하는 라이브러리에 정의되어 있는 클래스로 변경되어 있어서 현재 같은 화면으로 이동하려는지 확인하기가 어렵다.
심지어 단순하게 BackStack을 확인하기도 복잡하다
val backStack = navController.currentBackStack.value
backStack.forEach {
Log.d("BackStack", "$it")
}
// 결과중 한개
// NavBackStackEntry(f9214d04-3569-4ef6-9d76-c1ec295f9f19) destination=Destination(0x322a8767) route=com.codingcraft.android.navigation3.destination.Destination.ScreenOne
우리는 Destination이라는 매우 단순한 object를 썼지만 NavBackStackEntry이라는 복잡한 클래스로 변경되었다.
이게 어떤 화면인지 파악하려면 Destination에 route를 하드코딩하고 contains로 비교하거나 위에 있는 hasRoute를 사용해야 한다 심지어 currentBackStack을 직접 사용하시면 라이브러리 내부에서만 쓰라고 경고까지 나온다.
이뿐만 아니라 간단해 보이는 동작인 BackStack 맨 앞 화면 pop이나 앞에서 몇 번째 화면까지 Pop도 매우 복잡하다. 이 동작을 위해서는 사용자가 몇 번째 index에 뭔 화면이 있는지 알아야 하거나 currentBackStack을 돌면서 어떤 index에 뭐가 있는지 파악하고 pop을 해야 한다.
Nav3가 해결한 방식
Nav3의 경우 BackStack의 관리를 사용자한테 맡긴다. 대신 라이브러리는 BackStack의 변화를 감지해서 화면을 보여주는 역할을 한다
사용자가 BackStack을 관리하기 때문에 어떤 형태든 지원이 가능하다, 같은 애플리케이션을 Nav3로 구현하면 아래와 같다.
@Serializable
data object ScreenOne : NavKey
@Serializable
data object ScreenTwo : NavKey
@Serializable
data object ScreenThree : NavKey
val backStack = rememberNavBackStack(ScreenOne)
val navDestination by viewModel.destination.collectAsStateWithLifecycle()
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider = entryProvider {
entry(ScreenOne) {
ScreenOne(
onNavigateNext = {
viewModel.navigateTo(ScreenTwo)
}
)
}
entry(ScreenTwo) {
ScreenTwo(
onNavigateNext = {
viewModel.navigateTo(ScreenThree)
},
onNavigateBack = {
backStack.removeLastOrNull()
}
)
}
entry(ScreenThree) {
ScreenThree(
onNavigateBack = {
backStack.removeLastOrNull()
},
goToHome = {
backStack.removeRange(1, backStack.size)
}
)
}
}
)
LaunchedEffect(navDestination) {
if (navDestination.destination != backStack.lastOrNull()) {
backStack.add(navDestination.destination)
}
}
사용자가 backStack을 list 형태로 관리하기 때문에 매우 간단하게 마지막 삭제, 중간 삽입, 맨 앞 삭제, 1~3번 index 삭제 같은 리스트한테 할 수 있는 모든 동작을 동일하게 할 수 있기 때문에 기존에는 매우 복잡한 동작을 간단하게 수행할 수 있다.
라이브러리가 관리하기 쉽기 위해 NavBackStackEntry 같이 복잡한 형태의 클래스으로 변경을 안 하기 때문에 훨씬 비교하기도 수월하다.
예시에서는 Configuration Change에 대응하기 위해 라이브러리에서 제공하는 Navkey와 rememberNavBackStack을 사용했지만 Nav2 예시처럼 직접 구현한 sealed interface를 사용할 수도 있다
val backStack = remember { mutableStateListOf<Destination>(Destination.ScreenOne) }
아직 rememberSavable 지원을 직접 구현해야 하지만 이 backstack을 NavDisplay에 넘겨주기만 하면 된다.
이처럼 간단한 해결 방법으로 몇 가지 장점을 가져왔다
- BackStack을 사용자가 관리하게 된다. BackStack 관리가 훨씬 간단해졌다, 라이브러리는 어떤 동작이 일어나는지 모르고 단지 BackStack 리스트의 변화만 알게 된다
- Nav2에서는 Navigation 상태나 컴포넌트가 internal인 경우가 많아서 접근이 안 됐다. 하지만 Nav3는 최대한 많은 컴포넌트와 상태를 사용자가 확인할 수 있게 열어두었다 (BackStack도 그중 한 개)
여러 개 화면을 한꺼번에 보여주기 어렵다
navController 한 개를 통해 여러 개의 화면을 보여주는 건 Nav2에서는 불가능하다. 설계를 할 때 디바이스의 다양성을 고려하지 않아서 BackStack 가장 최상단에 있는 화면 한 개만 보여주기만 고려했기 때문이다. 그래서 아래의 코드 같은 건 불가능하다
val navController = rememberNavController()
Row {
NavHost(navController, startDestination = Destination.ScreenOne, Modifier.weight(1f)) {
composable<Destination.ScreenOne> { ScreenOne() }
composable<Destination.ScreenTwo> { ScreenTwo() }
}
NavHost(navController, startDestination = Destination.ScreenTwo, Modifier.weight(1f)) {
composable<Destination.ScreenOne> { ScreenOne() }
composable<Destination.ScreenTwo> { ScreenTwo() }
}
}
navController를 변경할 경우 같이 변경이 일어나기 때문에 화면에 여러개를 보여주려면 아예 navcontroller부터 시작해 화면을 분리해야 한다.
Nav3에서 해결한 방법
Scene이라고 하는 매우 자유롭게 커스텀이 가능한 컴포넌트를 도입하여 어떤 화면 사이즈든 적용 가능하게 설계를 했습니다.
이를 통해 아래 gif처럼 화면을 여러 개를 뛰울 수 있고 각각 다른 동작을 수행할 수 있다.
애니메이션 추가 애니메이션 제거
이를 위해서는 Scene 하고 SceneStrategy를 구현해야 됩니다.
한 화면에 들어갈 entries
구성 방식인 content (Composable이기 때문에 어떤 레이아웃이든 직접 구현이 가능합니다)
화면 계산 방식인 SceneStrategy를 구현하면 됩니다.
class TwoPaneScene<T : Any>(
override val key: Any,
override val previousEntries: List<NavEntry<T>>,
val firstEntry: NavEntry<T>,
val secondEntry: NavEntry<T>
) : Scene<T> {
override val entries: List<NavEntry<T>> = listOf(firstEntry, secondEntry)
override val content: @Composable (() -> Unit) = {
Row(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.weight(0.5f)) {
firstEntry.content.invoke(firstEntry.key)
}
Column(modifier = Modifier.weight(0.5f)) {
secondEntry.content.invoke(secondEntry.key)
}
}
}
companion object {
internal const val TWO_PANE_KEY = "TwoPane"
fun twoPane() = mapOf(TWO_PANE_KEY to true)
}
}
class TwoPaneSceneStrategy<T : Any> : SceneStrategy<T> {
@Composable
override fun calculateScene(
entries: List<NavEntry<T>>,
onBack: (Int) -> Unit
): Scene<T>? {
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
if (!windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)) {
return null
}
val lastTwoEntries = entries.takeLast(2)
return if (lastTwoEntries.size == 2
&& lastTwoEntries.all { it.metadata.containsKey(TwoPaneScene.TWO_PANE_KEY) }
) {
val firstEntry = lastTwoEntries.first()
val secondEntry = lastTwoEntries.last()
val sceneKey = Pair(firstEntry.key, secondEntry.key)
TwoPaneScene(
key = sceneKey,
previousEntries = entries.dropLast(1),
firstEntry = firstEntry,
secondEntry = secondEntry
)
} else {
null
}
}
}
이를 아래처럼 사용하면 여러 화면을 띄어둘 수 있습니다 (애니메이션 제거 버전)
val backStack = rememberNavBackStack(Home)
val twoPaneStrategy = remember { TwoPaneSceneStrategy<Any>() }
NavDisplay(
backStack = backStack,
onBack = { keysToRemove -> repeat(keysToRemove) { backStack.removeLastOrNull() } },
entryDecorators = listOf(
rememberSceneSetupNavEntryDecorator(),
rememberSavedStateNavEntryDecorator()
),
sceneStrategy = twoPaneStrategy,
entryProvider = entryProvider {
entry<Home>(
metadata = TwoPaneScene.twoPane()
) {
ContentRed("Welcome to Nav3") {
Button(onClick = { backStack.addProductRoute(1) }) {
Text("View the first product")
}
}
}
entry<Product>(
metadata = TwoPaneScene.twoPane()
) { product ->
ContentBase(
"Product ${product.id} ",
Modifier.background(colors[product.id % colors.size])
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Button(onClick = {
backStack.addProductRoute(product.id + 1)
}) {
Text("View the next product")
}
Button(onClick = {
backStack.add(Profile)
}) {
Text("View profile")
}
}
}
}
entry<Profile> {
ContentGreen("Profile (single pane only)")
}
}
)
마무리
Nav2를 통해 작업하면서 쓸데없이 복잡하다고 느낀 점들이 많아서 이번 Google I/O에서 발표한 Nav3를 통해 해결이 가능할 것 같아 바로 관심이 생기면서 간단하게 알아볼 겸 어떤 문제를 해결했는지 블로그로 적어봤습니다.
위에 두 개뿐만 아니라 더 자유로운 애니메이션 처리, 멀티 모듈에 더욱 용이한 구조 등등이 있지만 이번에 알아보려면 글이 길어지기 때문에 다음 기회에 적어보겠습니다.
P.S 알파 버전이라서 그런지 아직 문제가 많습니다
Target_sdk 36을 요구하고 Android Gradle 버전을 8.9.1 버전 이상을 요구하고 있어서 아직 도입은 힘들겠다는 생각이 들고
개인 플젝에서 도입을 시도해 봤지만 LocalNavigationEventDispatcherOwner이 internal로 수정이 불가능한데 제공을 안 했다고 해당 로그를 던지고 앱이 터지더군요
java.lang.IllegalStateException: No NavigationEventDispatcher was provided via LocalNavigationEventDispatcherOwner
공개된 지 하루밖에 안 됐고 alpha 버전이라서 그런 거 같아서 나중에 정식 출시되면 기대가 되는 라이브러리인 것 같습니다.
참고 자료
- https://android-developers.googleblog.com/2025/05/announcing-jetpack-navigation-3-for-compose.html
- https://developer.android.com/guide/navigation/navigation-3?_gl=1*2slbi1*_up*MQ..*_ga*MTA2Mzc3NDcxMy4xNzQ3ODQzMDk1*_ga_6HH9YJMN9M*czE3NDc4NDMwOTUkbzEkZzAkdDE3NDc4NDMwOTUkajAkbDAkaDE3OTI3MzM3MDAkZHNUbUNNTWJkWDhQT0ZzOXZzVDJhYVJuYWdNR0xFanZ1dWc.
Announcing Jetpack Navigation 3
Explore Jetpack Navigation 3, a new Compose-first navigation library with increased flexibility so Android developers to build robust experiences.
android-developers.googleblog.com
'android > compose' 카테고리의 다른 글
[Android/Compose] 최소 터치 타겟 사이즈에 대해서 알아보기 (0) | 2024.01.14 |
---|---|
(Android/Compose) Paging 라이브러리 없이 Paging 적용해보기 (1) | 2023.12.08 |
(android/compose) Server driven UI + Rich Text로 배포 없이 간편하게 UI를 변화시키다 (1) | 2023.11.30 |