Skip to content

Navigation 3

Koin은 의존성 주입을 포함한 타입 안전(type-safe) 멀티플랫폼 내비게이션을 위해 AndroidX Navigation 3와의 통합을 지원합니다.

Navigation 3는 Compose를 위해 특별히 설계된 Jetpack의 새로운 내비게이션 라이브러리입니다:

  • 전체 백 스택 제어 - 리스트에서 항목을 추가하거나 제거하여 내비게이션을 수행합니다.
  • 타입 안전한 라우트 - 라우트는 @Serializable이 적용된 Kotlin 클래스로 정의됩니다.
  • 적응형 레이아웃(Adaptive layouts) - 여러 목적지를 동시에 표시할 수 있습니다(리스트-상세 구조 등).
  • 자동 애니메이션 - 기본적으로 트랜지션 지원이 내장되어 있습니다.

설정

멀티플랫폼 프로젝트

kotlin
// shared/build.gradle.kts
commonMain.dependencies {
    implementation("io.insert-koin:koin-compose-navigation3:$koin_version")
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:$serialization_version")
}

Android 전용 프로젝트

kotlin
dependencies {
    implementation("io.insert-koin:koin-compose-navigation3:$koin_version")
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:$serialization_version")
}

serialization 플러그인을 적용합니다:

kotlin
plugins {
    kotlin("plugin.serialization")
}

플랫폼 지원

플랫폼상태
Android전체 지원
iOS전체 지원
Desktop전체 지원
Web전체 지원

핵심 개념

Kotlin 클래스로 정의하는 라우트

@Serializable을 사용하여 타입 안전한 라우트를 정의합니다:

kotlin
@Serializable
data object HomeRoute

@Serializable
data object ProfileRoute

@Serializable
data class DetailRoute(val itemId: String)

@Serializable
data class SettingsRoute(val section: String? = null)

백 스택 (Back Stack)

Navigation 3는 간단한 리스트 기반의 백 스택을 사용합니다:

kotlin
// 기본 백 스택
val backStack = remember { mutableStateListOf<Any>(HomeRoute) }

// 지속성 백 스택 (구성 변경 시에도 유지됨)
val backStack = rememberNavBackStack(HomeRoute)

// 앞으로 가기 (Navigate forward)
backStack.add(DetailRoute("123"))

// 뒤로 가기 (Navigate back)
backStack.removeLastOrNull()

NavDisplay는 애니메이션과 함께 백 스택을 렌더링합니다:

kotlin
NavDisplay(
    backStack = backStack,
    onBack = { backStack.removeLastOrNull() },
    entryProvider = { route -> /* NavEntry */ }
)

Koin 통합

내비게이션 엔트리 선언

모듈에서 navigation<T> DSL을 사용합니다:

kotlin
val appModule = module {
    // 의존성
    single<ApiClient>()
    viewModel<HomeViewModel>()
    viewModel<DetailViewModel>()

    // Koin 주입을 포함한 내비게이션 엔트리
    navigation<HomeRoute> { route ->
        HomeScreen(viewModel = koinViewModel())
    }

    navigation<DetailRoute> { route ->
        DetailScreen(
            itemId = route.itemId,
            viewModel = koinViewModel { parametersOf(route.itemId) }
        )
    }

    navigation<ProfileRoute> { route ->
        ProfileScreen(viewModel = koinViewModel())
    }
}

koinEntryProvider 사용하기

Koin에서 모든 내비게이션 엔트리를 가져옵니다:

kotlin
@Composable
fun App() {
    val backStack = rememberNavBackStack(HomeRoute)
    val entryProvider = koinEntryProvider<Any>()

    NavDisplay(
        backStack = backStack,
        onBack = { backStack.removeLastOrNull() },
        entryProvider = entryProvider
    )
}

전체 예제

kotlin
// 라우트
@Serializable data object ConversationList
@Serializable data class ConversationDetail(val id: Int)
@Serializable data object Profile

// 더 깔끔한 내비게이션을 위한 Navigator 클래스
class Navigator(startDestination: Any) {
    val backStack = mutableStateListOf(startDestination)

    fun goTo(destination: Any) {
        backStack.add(destination)
    }

    fun goBack() {
        backStack.removeLastOrNull()
    }
}

// Koin 모듈
val appModule = module {
    includes(conversationModule, profileModule)

    activityRetainedScope {
        scoped { Navigator(startDestination = ConversationList) }
    }
}

val conversationModule = module {
    activityRetainedScope {
        navigation<ConversationList> {
            val navigator = get<Navigator>()
            ConversationListScreen(
                onConversationClicked = { detail ->
                    navigator.goTo(detail)
                }
            )
        }

        navigation<ConversationDetail> { route ->
            val navigator = get<Navigator>()
            ConversationDetailScreen(
                conversationId = route.id,
                onProfileClicked = { navigator.goTo(Profile) }
            )
        }
    }
}

val profileModule = module {
    activityRetainedScope {
        navigation<Profile> {
            ProfileScreen()
        }
    }
}

// Activity
class MainActivity : ComponentActivity(), AndroidScopeComponent {
    override val scope: Scope by activityRetainedScope()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            val navigator: Navigator = get()

            Scaffold { padding ->
                NavDisplay(
                    backStack = navigator.backStack,
                    modifier = Modifier.padding(padding),
                    onBack = { navigator.goBack() },
                    entryProvider = getEntryProvider()
                )
            }
        }
    }
}

스코프 내비게이션 (Scoped Navigation)

Koin 스코프 내에서 내비게이션 엔트리를 선언합니다:

kotlin
val appModule = module {
    // Activity-retained 스코프 (구성 변경 시에도 유지됨)
    activityRetainedScope {
        scoped { UserSession() }
        viewModel<ProfileViewModel>()

        navigation<ProfileRoute> { route ->
            ProfileScreen(viewModel = koinViewModel())
        }
    }

    // 커스텀 스코프
    scope<CheckoutFlow> {
        scoped { CheckoutState() }
        viewModel<CheckoutViewModel>()

        navigation<CartRoute> { route ->
            CartScreen(viewModel = koinViewModel())
        }

        navigation<PaymentRoute> { route ->
            PaymentScreen(viewModel = koinViewModel())
        }
    }
}

ViewModel 통합

내비게이션 인자 포함하기

라우트 데이터를 ViewModel에 전달합니다:

kotlin
@Serializable
data class DetailRoute(val itemId: String, val fromSearch: Boolean = false)

class DetailViewModel(
    val route: DetailRoute,
    private val repository: Repository
) : ViewModel() {
    val item = repository.getItem(route.itemId)
}

val appModule = module {
    viewModelOf(::DetailViewModel)

    navigation<DetailRoute> { route ->
        DetailScreen(
            viewModel = koinViewModel { parametersOf(route) }
        )
    }
}

엔트리 데코레이터 사용하기

ViewModel 상태 유지를 위해 데코레이터를 사용합니다:

kotlin
NavDisplay(
    backStack = backStack,
    onBack = { backStack.removeLastOrNull() },
    entryDecorators = listOf(
        rememberSaveableStateHolderNavEntryDecorator(),
        rememberViewModelStoreNavEntryDecorator()
    ),
    entryProvider = entryProvider {
        entry<DetailRoute> { route ->
            val viewModel = koinViewModel<DetailViewModel> {
                parametersOf(route)
            }
            DetailScreen(viewModel)
        }
    }
)

애니메이션

기본 트랜지션 (Default Transitions)

kotlin
NavDisplay(
    backStack = backStack,
    onBack = { backStack.removeLastOrNull() },
    entryProvider = entryProvider,
    // 정방향 내비게이션 애니메이션
    transitionSpec = {
        slideInHorizontally(initialOffsetX = { it }) togetherWith
        slideOutHorizontally(targetOffsetX = { -it })
    },
    // 뒤로 가기 애니메이션
    popTransitionSpec = {
        slideInHorizontally(initialOffsetX = { -it }) togetherWith
        slideOutHorizontally(targetOffsetX = { it })
    }
)

라우트별 애니메이션

kotlin
navigation<ModalRoute>(
    metadata = NavDisplay.transitionSpec {
        slideInVertically(initialOffsetY = { it }) togetherWith
        ExitTransition.KeepUntilTransitionsFinished
    } + NavDisplay.popTransitionSpec {
        EnterTransition.None togetherWith
        slideOutVertically(targetOffsetY = { it })
    }
) { route ->
    ModalScreen()
}

적응형 레이아웃 (Adaptive Layouts)

리스트-상세 패턴 (List-Detail Pattern)

적응형 레이아웃을 위해 씬 전략(scene strategies)을 사용합니다:

kotlin
@Composable
fun App() {
    val backStack = rememberNavBackStack(ConversationList)
    val listDetailStrategy = rememberListDetailSceneStrategy<Any>()

    NavDisplay(
        backStack = backStack,
        onBack = { backStack.removeLastOrNull() },
        sceneStrategy = listDetailStrategy,
        entryProvider = entryProvider {
            entry<ConversationList>(
                metadata = ListDetailSceneStrategy.listPane()
            ) {
                ConversationListScreen()
            }

            entry<ConversationDetail>(
                metadata = ListDetailSceneStrategy.detailPane()
            ) { route ->
                ConversationDetailScreen(route.id)
            }
        }
    )
}

Koin 모듈과 함께 사용하기

kotlin
val appModule = module {
    navigation<ConversationList>(
        metadata = ListDetailSceneStrategy.listPane()
    ) {
        ConversationListScreen(
            onItemClick = { get<Navigator>().goTo(it) }
        )
    }

    navigation<ConversationDetail>(
        metadata = ListDetailSceneStrategy.detailPane()
    ) { route ->
        ConversationDetailScreen(route.id)
    }
}

Android 확장 기능

지연 엔트리 프로바이더 (Lazy Entry Provider)

kotlin
class MainActivity : ComponentActivity() {
    // 지연 초기화
    private val entryProvider by entryProvider<Any>()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            val backStack = rememberNavBackStack(HomeRoute)

            NavDisplay(
                backStack = backStack,
                onBack = { backStack.removeLastOrNull() },
                entryProvider = entryProvider
            )
        }
    }
}

즉시 엔트리 프로바이더 (Eager Entry Provider)

kotlin
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val entryProvider = getEntryProvider<Any>()

        setContent {
            NavDisplay(
                backStack = backStack,
                entryProvider = entryProvider,
                onBack = { backStack.removeLastOrNull() }
            )
        }
    }
}

API 레퍼런스

DSL 함수

함수설명
Module.navigation<T> { }모듈 수준에서 내비게이션 엔트리 선언
ScopeDSL.navigation<T> { }특정 스코프 내에서 내비게이션 엔트리 선언

Composable 함수

함수설명
koinEntryProvider<T>()Koin에서 집계된 엔트리 프로바이더를 가져옴

Android 확장 기능

함수설명
entryProvider<T>()지연 엔트리 프로바이더 위임(delegate)
getEntryProvider<T>()즉시 엔트리 프로바이더

이전 (Navigation 2.x)

kotlin
NavHost(navController, startDestination = "home") {
    composable("home") {
        HomeScreen(viewModel = koinViewModel())
    }
    composable("detail/{id}") { backStackEntry ->
        val id = backStackEntry.arguments?.getString("id")
        DetailScreen(id = id, viewModel = koinViewModel())
    }
}

이후 (Navigation 3)

kotlin
// 타입 안전한 라우트
@Serializable data object HomeRoute
@Serializable data class DetailRoute(val id: String)

// 모듈 선언
val appModule = module {
    navigation<HomeRoute> { HomeScreen(viewModel = koinViewModel()) }
    navigation<DetailRoute> { route ->
        DetailScreen(id = route.id, viewModel = koinViewModel())
    }
}

// 사용 예시
val backStack = rememberNavBackStack(HomeRoute)
NavDisplay(
    backStack = backStack,
    onBack = { backStack.removeLastOrNull() },
    entryProvider = koinEntryProvider()
)

참고 자료