[Android][Compose] Navigation3

윤찬·2025년 12월 2일

Android

목록 보기
36/37
post-thumbnail

서론

Compose를 사용하면서 Navigation을 많이 사용해봤을 것이다.
최근에 Navigation3가 1.0.0버전으로 production-ready 상태가 되어 실제 프로덕트에서 사용이 가능하다고 얘기가 들어서 이번 기회에 Naviagion3를 간단 적용 방법을 알아보려고 한다.

실제 Android 공식 유튜브에 Navigation3 관련된 영상이 있으니 한 번 보는 것도 추천한다. 필자 또한 영상을 참고하고 소스코드를 통해 다양한 방법들을 확인해보고 직접 적용해서 동작하는 것을 확인해보려고 한다.

또한 공식 문서가 있으니 이 또한 참고하여 개발을 진행했다.

적용한 라이브러리

[versions]
nav3Core = "1.0.0"
lifecycleViewmodelNav3 = "2.10.0"
kotlinSerialization = "2.1.21"
kotlinxSerializationCore = "1.8.1"
material3AdaptiveNav3 = "1.3.0-alpha04"

[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-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" }
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinxSerializationCore" }

androidx-material3-adaptive-navigation3 = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation3", version.ref = "material3AdaptiveNav3" }

[plugins]

# Optional plugins
jetbrains-kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinSerialization"}
plugins {
    // Optional, provides the @Serialize annotation for autogeneration of Serializers.
    alias(libs.plugins.jetbrains.kotlin.serialization)
}

//...

dependencies {
    implementation(libs.androidx.navigation3.ui)
    implementation(libs.androidx.navigation3.runtime)
    implementation(libs.androidx.lifecycle.viewmodel.navigation3)
    implementation(libs.androidx.material3.adaptive.navigation3)
    implementation(libs.kotlinx.serialization.core)
}

본론

공식 문서의 설명으로는 아래와 같이 면시되어 있다.

Navigation 3는 Compose와 함께 작동하도록 설계된 새로운 탐색 라이브러리입니다. Navigation3를 사용하면 백 스택을 완전히 제어할 수 있으며, 대상 간 이동은 목록에서 항목을 추가하고 삭제하는 것만큼 간단합니다. 다음과 같은 기능을 제공하여 유연한 앱 탐색 시스템을 만듭니다.

Navigation2보다 개선된 점으로는 아래와 같다.

  • Compose와의 통합을 간소화
  • 뒤로 스택을 완전히 제어할 수 있다.
  • 백 스택에서 두 개 이상의 대상을 동시에 읽을 수 있는 레이아웃을 만들어 창 크기 및 기타 입력의 변경사항에도 적용할 수 있도록 한다.

기본 Navigation3 적용 방법

Navigation3는 다양한 방식으로 경로를 저장할 수 있다.
그 중 가장 간단한 방식은 바로 SnapShotStableList라는 객체를 만들어서 직접 백스택을 관리하는 방법이다.

아래 코드가 기본적인 Navigation3 구현 방법 중 하나이다.

data object ChatList

data class ChatDetail(val id: String)

class MainActivity : ComponentActivity() {
    @OptIn(ExperimentalMaterial3AdaptiveApi::class)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            Navigation3Theme {
                val backStack = remember { mutableStateListOf<Any>(ChatList) }
                NavDisplay(
                    backStack = backStack,
                    onBack = { backStack.removeLastOrNull() },
                ) { key ->
                    when (key) {
                        is ChatList -> {
                            NavEntry(key) {
                                ChatListContent(
                                    onChatClicked = { id ->
                                        backStack.add(ChatDetail(id))
                                    }
                                )
                            }
                        }

                        is ChatDetail -> {
                            NavEntry(key) {
                                ChatDetailContent(
                                    chatId = key.id
                                )
                            }
                        }

                        else -> error("Unknown key: $key")
                    }
                }
            }
        }
    }
}

@Composable
fun ChatListContent(
    modifier: Modifier = Modifier,
    onChatClicked: (String) -> Unit
) {
    LazyColumn(
        modifier = modifier
            .fillMaxSize(),
        verticalArrangement = Arrangement.spacedBy(8.dp),
        contentPadding = PaddingValues(16.dp)
    ) {
        items(100) { index ->
            Box(
                modifier = Modifier
                    .fillMaxWidth()
                    .height(60.dp)
                    .background(color = Color.Gray)
                    .clickable {
                        onChatClicked(index.toString())
                    },
                contentAlignment = Alignment.Center
            ) {
                Text(text = "${index + 1}")
            }
        }

    }
}

@Composable
fun ChatDetailContent(modifier: Modifier = Modifier, chatId: String) {
    Box(
        modifier = modifier
            .fillMaxSize()
            .background(color = Color.LightGray),
        contentAlignment = Alignment.Center
    ) {
        Text(text = "Chat Detail: $chatId")
    }
}

위와 같이 backstack을 remember { mutableStateListOf<Any>((초기 위치)) }로 만들어서 백스택 리스트를 직접 만들고, NavDisplay를 이용하여 관리하는 방법이다.
기본 코드를 보면 일반 Navigation2와 형식이 비슷한 것 같다. NavDisplay == NavHost 느낌??

실제로 실행하면 정상적으로 동작이 되는 것을 볼 수 있다.

여기서 Navigation2와 약간 다른 부분은 뒤로가기 했을 때 애니메이션이 살짝 다르다는 것을 느낄 수 있을 것이다.

가장 기본적인 방식이지만 여기에 문제점이 있다.

  • 구현하지 않는 화면을 사용한다면 오류가 발생(when문의 else문)
  • 화면이 많아지게 된다면 when문의 코드가 엄청 커질 수도 있다.

이 문제를 해결하는 방법이 entryProvider DSL을 이용해 해결할 수 있다.

EntryProvider DSL 사용

변경된 코드를 보면 아래와 같다.

//...
                NavDisplay(
                    backStack = backStack,
                    onBack = { backStack.removeLastOrNull() },
                    sceneStrategy = strategy,
                    entryProvider = entryProvider {
                        chatListEntry(backStack)
                        chatDetailEntry(backStack)
//                        profileEntry()
                    },
                ) 


@OptIn(ExperimentalMaterial3AdaptiveApi::class)
private fun EntryProviderScope<Any>.chatDetailEntry(backStack: SnapshotStateList<Any>) {
    entry<ChatDetail> { key ->
        ChatDetailContent(
            chatId = key.id,
        )
    }
}

@OptIn(ExperimentalMaterial3AdaptiveApi::class)
private fun EntryProviderScope<Any>.chatListEntry(backStack: SnapshotStateList<Any>) {
    entry<ChatList> {
        ChatListContent(
            onChatClicked = { id ->
                backStack.add(ChatDetail(id))
            }
        )
    }
}

//...

뭔가 보면 Navigation2의 composable<ROUTE> { ... } 가 생각이 날 것이다. 거의 비슷한 방식으로 화면을 구현할 수 있다. 이때 backStack의 정보를 전달화 화면 이동이나 백스택 동작 또한 가능하다.

실행해보면 첫 번째와 동일하게 동작하는 것을 볼 수 있다.

화면 전환 애니메이션 적용해보기

NavHost에도 화면 전환 애니메이션을 넣을 수 있듯이 NavDisplay에도 화면 전환 기능이 존재한다.

아래와 같은 코드를 통해 각각의 애니메이션의 속성을 정의할 수 있다.

//...

                NavDisplay(
                    backStack = backStack,
                    onBack = { backStack.removeLastOrNull() },
                    sceneStrategy = strategy,
                    entryProvider = entryProvider {
                        chatListEntry(backStack)
                        chatDetailEntry(backStack)
//                        profileEntry()
                    },
                    //애니메이션 속성 추가
                    transitionSpec = {
                        slideInHorizontally { it } togetherWith  slideOutHorizontally { -it }
                    },
                    popTransitionSpec = {
                        slideInHorizontally { -it } togetherWith  slideOutHorizontally { it }
                    },
                    predictivePopTransitionSpec = {
                        slideInHorizontally { -it } togetherWith  slideOutHorizontally { it }
                    }
                )


//...

근데 이전 라이브러리에는 없는 predictivePopTransitionSpec 속성이 추가된 것을 볼 수 있다.
실제 내부코드에 들어가서 설명 내용을 보면 아래와 같이 적혀 있다.

이를 간단하게 번역하면

예측형 NavEntry로 팝업할 때 기본 ContentTransform을 사용합니다.

아마 예상한 동작은 뒤로가기를 떼기 전에 동작하는 애니메이션 같은 느낌이다. 아까 위에 보듯이 화면이 작아지면서 뒤로가는 것을 보았을 것이다.

실제 defaultPredictivePopTransitionSpec을 보면 아래와 같다.

scaleOut이 보이는 것을 보면 아까 영상에서 봤던 것이 동작하는 것을 예측할 수 있다. 0.7f인 것은 이걸 보고 처음 알았다.

이제 각 역할에 대해 어느정도 알았을 것이다. 실제 코드가 실행이 되면 아래와 같이 동작하는 것을 아마 다들 예상하셨을 것이다.

아까와 달리 왼쪽에서 오른쪽, 나갈때는 반대로 애니메이션이 동작하는 것을 볼 수 있다.

그러면 각각의 화면에서 애니메이션을 적용하려면 어떻게 해야할까?

실제 위에 있는 코드는 모든 화면에서 공통적으로 적용되는 애니메이션이다. 하지만 가끔 특정 화면에서는 다른 애니메이션을 보여주고 싶을 때가 있을 것이다.

이때는 각 화면을 설정하는 부분인 entry의 metadata의 속성을 이용해서 적용할 수 있다.

먼저 entry의 metadata가 들어가는 값을 보면 Map<String, Any> 값이다.


실제 문서 metadata의 설명 또한 display의 정보 제공이다.

그러면 대체 어떻게 애니메이션을 이 metadata에 넣을 수 있을까? -> NavDisplay를 이용해 적용이 가능하다.

보면 Map의 객체를 반환하면서 각 spec을 제공하는 함수가 보일 것이다. 이를 이용해 Profile Screen을 추가하여 해당 화면만 아래에서 위로 올라오는 애니메이션을 추가해보았다.

                NavDisplay(
                    backStack = backStack,
                    onBack = { backStack.removeLastOrNull() },
                    sceneStrategy = strategy,
                    entryProvider = entryProvider {
                        chatListEntry(backStack)
                        chatDetailEntry(backStack)
                        
                        //profile 화면 추가
                        profileEntry()
                    },
                    //애니메이션 구현 내용...
                )



//디테일 클릭 시 ProfileScreen 화면으로 이동하는 고차 함수 추가
@Composable
fun ChatDetailContent(modifier: Modifier = Modifier, chatId: String, onProfilePhotoClicked: (String) -> Unit) {
    Box(
        modifier = modifier
            .fillMaxSize()
            .background(color = Color.LightGray)
            .clickable {
                onProfilePhotoClicked(chatId)
            },
        contentAlignment = Alignment.Center
    ) {
        Text(text = "Chat Detail: $chatId")
    }
}


//추가
private fun EntryProviderScope<Any>.profileEntry() {
    entry<Profile>(
        metadata = NavDisplay.transitionSpec {
            slideInVertically(animationSpec = tween(1000)) { it } togetherWith ExitTransition.KeepUntilTransitionsFinished
        } + NavDisplay.popTransitionSpec {
            EnterTransition.None togetherWith slideOutVertically(animationSpec = tween(1000)) { it }
        } + NavDisplay.predictivePopTransitionSpec {
            EnterTransition.None togetherWith slideOutVertically(animationSpec = tween(1000)) { it }
        }
    ) { key ->
        ProfileContent(
            profileId = key.profileId
        )
    }
}

@Composable
fun ProfileContent(modifier: Modifier = Modifier, profileId : String) {
    Box(
        modifier = modifier
            .fillMaxSize()
            .background(color = Color.Blue),
        contentAlignment = Alignment.Center
    ) {
        Text(text = "ProfileContent Content: $profileId")
    }
}

실행 결과를 보면 프로필 화면 이동할 때 다른 애니메이션이 적용된 것을 볼 수 있다.

사실 지금까지 위에 있는 내용은 Navigation2에서도 충분히 적용할 수 있는 부분이다. 왜 Navigation3로 변경해야할 이유가 딱히 느껴지지 않는 부분일 수 있을 것이다.

하지만 Navigation3에는 Navigation2와 다른 기능이 존재한다.

바로 화면의 넓이가 넓어졌을 때 여러 화면을 보여줄 수 있다는 점이다.(예로 폴드가 접혀있다가 펼졌을 때 화면을 변경하듯이.)

영상에서는 이런 특징을 Adaptive layout using Screen이라고 한다.

그려면 이러한 특징을 볼 수 있는 폴드를 이용해서 아래와 같이 코드를 구현하면 어떻게 동작하는지 확인해보자.
구현 방법은 바로 strategy를 이용하는 것이다.


				val backStack = remember { mutableStateListOf<Any>(ChatList) }
                
                //전략 추가
                val strategy = rememberListDetailSceneStrategy<Any>()
                NavDisplay(
                    backStack = backStack,
                    onBack = { backStack.removeLastOrNull() },
                    sceneStrategy = strategy,
                    entryProvider = entryProvider {
                        chatListEntry(backStack)
                        chatDetailEntry(backStack)
                        profileEntry()
                    },
                    transitionSpec = {
                        slideInHorizontally { it } togetherWith  slideOutHorizontally { -it }
                    },
                    popTransitionSpec = {
                        slideInHorizontally { -it } togetherWith  slideOutHorizontally { it }
                    },
                    predictivePopTransitionSpec = {
                        slideInHorizontally { -it } togetherWith  slideOutHorizontally { it }
                    }
                )

@OptIn(ExperimentalMaterial3AdaptiveApi::class)
private fun EntryProviderScope<Any>.chatDetailEntry(backStack: SnapshotStateList<Any>) {
    entry<ChatDetail>(
    	//metadata에 이 화면은 detailPain이라고 설정하기
        metadata = ListDetailSceneStrategy.detailPane()
    ) { key ->
        ChatDetailContent(
            chatId = key.id,
            onProfilePhotoClicked = { profileId ->
                backStack.add(Profile(profileId))
            }
        )
    }
}

@OptIn(ExperimentalMaterial3AdaptiveApi::class)
private fun EntryProviderScope<Any>.chatListEntry(backStack: SnapshotStateList<Any>) {
    entry<ChatList>(
    	//이 화면이 listPane이라고 설정하기
        metadata = ListDetailSceneStrategy.listPane()
    ) {
        ChatListContent(
            onChatClicked = { id ->
                backStack.add(ChatDetail(id))
            }
        )
    }
}

이걸 추가했을 때 fold에서는 어떻게 화면을 구성하는지 보자.


이걸 보고 진짜 신기하다고 생각했다. 다른 화면인데 화면을 펼치면 자동으로 분리가 된다는 것이 놀라웠다.

rememberListDetailSceneStrategy을 이용한 방식도 있지만 이미지와 같이 rememberSupportingPaneScreenStratege도 있는 것 보면 다른 방식으로도 화면을 구성할 수 있는 것 같다. 심지어 이 방식 말고도 사용자의 커스텀으로도 화면을 분리해서 만들 수 있는 것 같다.(아직 nav3 깃허브 레파지토리를 보고 판단하는 거지만..)

이걸 이용하면 이제 각 화면을 분리하면서도 서로 동작을 하는 화면을 구현할 수 있을 것 같다는 생각이 들었다. 뭔가 더 깊게 파보면서 여러 화면으로 분할도 되는지 확인하고 싶지만 일단 기본 예제부터 천천히 학습한 후 조금씩 적용해보려고 한다.

결론

Navigation3를 이용해 기본적인 화면 이동, 애니메이션 적용, 화면 분할 등 기본적인 속성을 알아보았다.

아마 다음에는 다른 분들도 궁금했던 의존성 주입을 어떻게 진행을 해야하는지에 대한 설명을 추후에 작성해보려고 한다. 마침 nav3 깃허브에 koin과 hilt를 이용해 navigation을 적용하는 방법이 적혀있는데 충분히 공부하고 실제로 구현해보면서 후기를 남기도록 하겠습니다.

profile
좋은 개발자가 되기까지

0개의 댓글