
State-first back-stack navigation with compiler-generated registries and destinations, global route-key resolution for cross-module navigation, interceptors, serializable route parameters, page results and multi-instance support.
Kotlin Multiplatform navigation helper focused on:
中文文档: README_ZH.md
Maintainer release instructions: PUBLISHING.md
If you use a plain Android/Kotlin module instead of KMP, use the Android KSP configuration directly:
dependencies {
implementation("io.github.licc981:navigation3-helper:<version>")
ksp("io.github.licc981:nav3-ksp-compiler:<version>")
}Android-only module plugins:
plugins {
id("com.android.application") // or com.android.library
kotlin("android")
id("com.google.devtools.ksp")
}Android startup example:
class BaseApplication : Application() {
override fun onCreate() {
loadNavRegistry(XXXRegistry)
}
}
@Composable
fun App() {
NavDisplayHelper(startRoute = XXXRegistry.defaultStartScreen)
}Add the runtime and KSP compiler:
dependencies {
implementation("io.github.licc981:navigation3-helper:<version>")
add("kspCommonMainMetadata", "io.github.licc981:nav3-ksp-compiler:<version>")
}If your project has platform-specific KSP tasks, also add the compiler to those configurations:
dependencies {
add("kspAndroid", "io.github.licc981:nav3-ksp-compiler:<version>")
add("kspIosX64", "io.github.licc981:nav3-ksp-compiler:<version>")
add("kspIosArm64", "io.github.licc981:nav3-ksp-compiler:<version>")
add("kspIosSimulatorArm64", "io.github.licc981:nav3-ksp-compiler:<version>")
}Apply the required plugins in modules that declare @Screen pages:
plugins {
kotlin("multiplatform")
id("com.google.devtools.ksp")
}If any screen parameter uses an @Serializable type, also apply:
plugins {
kotlin("plugin.serialization")
}Generated code for commonMain should be added to the source set when needed:
kotlin {
sourceSets {
commonMain {
kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin")
}
}
}Minimal startup example:
fun initNavigation() {
NavCenter.setRegistries(setOf(ComposeAppRegistry))
}
@Composable
fun App() {
NavDisplayHelper(ComposeAppRegistry.defaultStartScreen)
}Use LocalNavBackStackState for local destination-based navigation inside the current host, and
use NavCenter for cross-module route-key navigation.
Use an empty route when the page does not need global route resolution:
@Screen
@Composable
fun ProfileScreen() { /* ... */ }Navigate through the generated destination:
val backStack = LocalNavBackStackState.current
backStack.navigate(ProfileScreenDestination)Declare a fixed route key:
@Screen(route = "app://user/detail")
@Composable
fun UserDetailScreen(id: Long) { /* ... */ }Navigate from any module through NavCenter:
NavCenter.navigate("app://user/detail?id=123")Set an application-wide fallback when a URL is not registered, or when a matched route cannot restore its destination because of missing or invalid parameters:
NavCenter.setRouteNotFoundHandler { url ->
NotFoundScreenDestination(url)
}Returning a NavScreen makes NavCenter.navigate(url) add that fallback destination to the current
host. Return null to keep the false result while using the callback only for logging. An
interceptor Block or a redirect loop does not invoke this callback, so explicit interception is
never bypassed. The fallback destination itself must still be included in the current host's
entryProvider. Use NavCenter.clearRouteNotFoundHandler() to remove the configuration.
@Serializable
data class UserInfo(
val userId: String,
val nickname: String
)
@Screen(route = "app://user/me")
@Composable
fun MeScreen(userInfo: UserInfo) { /* ... */ }Runtime navigation:
val userInfoParam = serializeRouteQueryValue(
UserInfo(userId = "1001", nickname = "Aleyn")
)
NavCenter.navigate("app://user/me?userInfo=$userInfoParam")Return a one-shot result from the child page:
// Child page
Button(onClick = {
backStack.setResult(ProfileResult(success = true))
backStack.goBack()
}) { /* ... */ }Handle it once when the parent page becomes active again:
// Parent page
backStack.consumeResultEffect<ProfileResult> { result ->
if (result?.success == true) {
// refresh UI
}
}NavDisplayHelper(...) is optional. You can wire the official component yourself:
val backStack = rememberHelperBackStack(
startRoute = ComposeAppRegistry.defaultStartScreen,
navRegistrySet = setOf(ComposeAppRegistry)
)
NavDisplay(
backStack = backStack.navBackStack,
onBack = { backStack.goBack() },
entryProvider = getEntryProvider(setOf(ComposeAppRegistry))
)@Screen(route = ..., needLogin = ...) declares an optional route key and login requirement.
If route is left empty, the screen still gets a generated destination and can participate in
ordinary local navigation, but it is not registered into NavCenter route resolution.
The library treats the route as an identity key and does not force a specific protocol. These are all valid styles:
https://www.app.cn/user/detailapp://user/detailuser/detailRecommended rules:
users/{filter}/{id}.Route key normalization:
Example:
@Screen(
route = "https://www.myapp.com/users/{filter}/{id}",
needLogin = true
)
@Composable
fun UserDetailScreen(
filter: String,
id: Long
) { /* ... */ }Navigate at runtime with:
NavCenter.navigate("https://www.myapp.com/users/active/123")This URL restores filter = "active" and id = 123L. NavCenter.navigate(String),
NavCenter.resolve(String), and interceptors are synchronous APIs and can be called directly from
click handlers. Data requiring asynchronous work should be prepared before navigation.
needLogin = true is written to both the generated destination and registry metadata. An
interceptor can use NavCenter.needLogin(url) to inspect the target route:
NavCenter.addInterceptor { url ->
when {
NavCenter.needLogin(url) && !session.isLoggedIn() ->
InterceptResult.Redirect("app://login")
else -> InterceptResult.Proceed
}
}Interceptor results:
Proceed continues through subsequent interceptors and navigation.Block(reason) stops navigation and does not call subsequent interceptors.Redirect(newRoute) restarts the interceptor chain with the new route; redirect loops are blocked.Ordinary local screen:
@Screen
@Composable
fun LocalOnlyScreen() { /* ... */ }When the same route with the same arguments is pushed twice in a row, navigation3 derives
NavEntry.contentKey from key.toString() and reuses the same content slot, so the second push
appears to do nothing. The multiInstance flag solves this:
@Screen(route = Routes.COURSE_DETAIL, multiInstance = true)
@Composable
fun CourseDetailScreen(courseId: Long) { /* ... */ }When enabled, the generated Destination gets an extra runtime-generated entryId primary
constructor parameter (defaulting to newScreenEntryId(), a combination of the process
monotonic clock, an in-process counter and randomness), so every push produces a distinct
contentKey and the same route + same arguments can be pushed multiple times.
Design notes:
entryId = UUID.randomUUID().toString() in the annotation: annotation
arguments must be compile-time constants, and an annotation value attached to a function
declaration is a single value that cannot distinguish runtime push instances.equals/hashCode ignore entryId and match only on business parameters,
so URL-structural APIs like goBack(url) / remove(url) keep working.entryId is a reserved parameter name on screen composables.URL query restoration is intended for lightweight public parameters.
Supported:
String@Serializable object typesIf you use @Serializable screen parameters, the declaring module should also apply the Kotlin
serialization plugin.
Not recommended for URL transport:
Runtime behavior:
@Serializable JSON payloads also make route resolution fail.Non-serializable parameters with defaults are treated as page-injected parameters and omitted from the destination. For example:
@Screen(route = "app://course/{courseId}")
@Composable
fun CourseDetailScreen(
courseId: String?,
viewModel: CourseDetailViewModel = viewModel()
) { /* ... */ }The generated CourseDetailScreenDestination only contains courseId. The generated screen call
omits viewModel, so the composable's default injection expression remains responsible for it.
For @Serializable route parameters, encode the JSON payload before appending it to the runtime
URL query string:
@Serializable
data class Filter(val tab: String, val page: Int)
val filter = serializeRouteQueryValue(Filter(tab = "post", page = 2))
NavCenter.navigate("app://user/detail?filter=$filter")For local page result passing, prefer the host-scoped result store on NavBackStackState.
There are two usage styles:
Example:
Button(
onClick = {
backStack.navigate(EditProfileScreen(resultKey = resultKey))
}
) { /* ... */ }
val result = backStack.consumeResult<ProfileResult>()
// or
val result = backStack.consumeResult<ProfileResult>(resultKey)Return from the child page:
backStack.setResult(ProfileResult(...))
backStack.goBack()With a custom key:
backStack.setResult(resultKey, ProfileResult(...))
backStack.goBack()Available APIs:
setResult(...)peekResult(...)consumeResult(...)consumeResultEffect(...)hasResult(...)clearResult(...)If the result should only be handled once when the page becomes active again, prefer
consumeResultEffect(...) or consumeResult(...) over peekResult(...).
NavCenter.setRegistries(...).NavDisplayHelper(...) is optional; users may directly use NavDisplay.@Screen(route = ...).NavBackStackState for the current host.NavDisplayHelper(...) or NavDisplay(...).NavCenter.navigate(...).Kotlin Multiplatform navigation helper focused on:
中文文档: README_ZH.md
Maintainer release instructions: PUBLISHING.md
If you use a plain Android/Kotlin module instead of KMP, use the Android KSP configuration directly:
dependencies {
implementation("io.github.licc981:navigation3-helper:<version>")
ksp("io.github.licc981:nav3-ksp-compiler:<version>")
}Android-only module plugins:
plugins {
id("com.android.application") // or com.android.library
kotlin("android")
id("com.google.devtools.ksp")
}Android startup example:
class BaseApplication : Application() {
override fun onCreate() {
loadNavRegistry(XXXRegistry)
}
}
@Composable
fun App() {
NavDisplayHelper(startRoute = XXXRegistry.defaultStartScreen)
}Add the runtime and KSP compiler:
dependencies {
implementation("io.github.licc981:navigation3-helper:<version>")
add("kspCommonMainMetadata", "io.github.licc981:nav3-ksp-compiler:<version>")
}If your project has platform-specific KSP tasks, also add the compiler to those configurations:
dependencies {
add("kspAndroid", "io.github.licc981:nav3-ksp-compiler:<version>")
add("kspIosX64", "io.github.licc981:nav3-ksp-compiler:<version>")
add("kspIosArm64", "io.github.licc981:nav3-ksp-compiler:<version>")
add("kspIosSimulatorArm64", "io.github.licc981:nav3-ksp-compiler:<version>")
}Apply the required plugins in modules that declare @Screen pages:
plugins {
kotlin("multiplatform")
id("com.google.devtools.ksp")
}If any screen parameter uses an @Serializable type, also apply:
plugins {
kotlin("plugin.serialization")
}Generated code for commonMain should be added to the source set when needed:
kotlin {
sourceSets {
commonMain {
kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin")
}
}
}Minimal startup example:
fun initNavigation() {
NavCenter.setRegistries(setOf(ComposeAppRegistry))
}
@Composable
fun App() {
NavDisplayHelper(ComposeAppRegistry.defaultStartScreen)
}Use LocalNavBackStackState for local destination-based navigation inside the current host, and
use NavCenter for cross-module route-key navigation.
Use an empty route when the page does not need global route resolution:
@Screen
@Composable
fun ProfileScreen() { /* ... */ }Navigate through the generated destination:
val backStack = LocalNavBackStackState.current
backStack.navigate(ProfileScreenDestination)Declare a fixed route key:
@Screen(route = "app://user/detail")
@Composable
fun UserDetailScreen(id: Long) { /* ... */ }Navigate from any module through NavCenter:
NavCenter.navigate("app://user/detail?id=123")Set an application-wide fallback when a URL is not registered, or when a matched route cannot restore its destination because of missing or invalid parameters:
NavCenter.setRouteNotFoundHandler { url ->
NotFoundScreenDestination(url)
}Returning a NavScreen makes NavCenter.navigate(url) add that fallback destination to the current
host. Return null to keep the false result while using the callback only for logging. An
interceptor Block or a redirect loop does not invoke this callback, so explicit interception is
never bypassed. The fallback destination itself must still be included in the current host's
entryProvider. Use NavCenter.clearRouteNotFoundHandler() to remove the configuration.
@Serializable
data class UserInfo(
val userId: String,
val nickname: String
)
@Screen(route = "app://user/me")
@Composable
fun MeScreen(userInfo: UserInfo) { /* ... */ }Runtime navigation:
val userInfoParam = serializeRouteQueryValue(
UserInfo(userId = "1001", nickname = "Aleyn")
)
NavCenter.navigate("app://user/me?userInfo=$userInfoParam")Return a one-shot result from the child page:
// Child page
Button(onClick = {
backStack.setResult(ProfileResult(success = true))
backStack.goBack()
}) { /* ... */ }Handle it once when the parent page becomes active again:
// Parent page
backStack.consumeResultEffect<ProfileResult> { result ->
if (result?.success == true) {
// refresh UI
}
}NavDisplayHelper(...) is optional. You can wire the official component yourself:
val backStack = rememberHelperBackStack(
startRoute = ComposeAppRegistry.defaultStartScreen,
navRegistrySet = setOf(ComposeAppRegistry)
)
NavDisplay(
backStack = backStack.navBackStack,
onBack = { backStack.goBack() },
entryProvider = getEntryProvider(setOf(ComposeAppRegistry))
)@Screen(route = ..., needLogin = ...) declares an optional route key and login requirement.
If route is left empty, the screen still gets a generated destination and can participate in
ordinary local navigation, but it is not registered into NavCenter route resolution.
The library treats the route as an identity key and does not force a specific protocol. These are all valid styles:
https://www.app.cn/user/detailapp://user/detailuser/detailRecommended rules:
users/{filter}/{id}.Route key normalization:
Example:
@Screen(
route = "https://www.myapp.com/users/{filter}/{id}",
needLogin = true
)
@Composable
fun UserDetailScreen(
filter: String,
id: Long
) { /* ... */ }Navigate at runtime with:
NavCenter.navigate("https://www.myapp.com/users/active/123")This URL restores filter = "active" and id = 123L. NavCenter.navigate(String),
NavCenter.resolve(String), and interceptors are synchronous APIs and can be called directly from
click handlers. Data requiring asynchronous work should be prepared before navigation.
needLogin = true is written to both the generated destination and registry metadata. An
interceptor can use NavCenter.needLogin(url) to inspect the target route:
NavCenter.addInterceptor { url ->
when {
NavCenter.needLogin(url) && !session.isLoggedIn() ->
InterceptResult.Redirect("app://login")
else -> InterceptResult.Proceed
}
}Interceptor results:
Proceed continues through subsequent interceptors and navigation.Block(reason) stops navigation and does not call subsequent interceptors.Redirect(newRoute) restarts the interceptor chain with the new route; redirect loops are blocked.Ordinary local screen:
@Screen
@Composable
fun LocalOnlyScreen() { /* ... */ }When the same route with the same arguments is pushed twice in a row, navigation3 derives
NavEntry.contentKey from key.toString() and reuses the same content slot, so the second push
appears to do nothing. The multiInstance flag solves this:
@Screen(route = Routes.COURSE_DETAIL, multiInstance = true)
@Composable
fun CourseDetailScreen(courseId: Long) { /* ... */ }When enabled, the generated Destination gets an extra runtime-generated entryId primary
constructor parameter (defaulting to newScreenEntryId(), a combination of the process
monotonic clock, an in-process counter and randomness), so every push produces a distinct
contentKey and the same route + same arguments can be pushed multiple times.
Design notes:
entryId = UUID.randomUUID().toString() in the annotation: annotation
arguments must be compile-time constants, and an annotation value attached to a function
declaration is a single value that cannot distinguish runtime push instances.equals/hashCode ignore entryId and match only on business parameters,
so URL-structural APIs like goBack(url) / remove(url) keep working.entryId is a reserved parameter name on screen composables.URL query restoration is intended for lightweight public parameters.
Supported:
String@Serializable object typesIf you use @Serializable screen parameters, the declaring module should also apply the Kotlin
serialization plugin.
Not recommended for URL transport:
Runtime behavior:
@Serializable JSON payloads also make route resolution fail.Non-serializable parameters with defaults are treated as page-injected parameters and omitted from the destination. For example:
@Screen(route = "app://course/{courseId}")
@Composable
fun CourseDetailScreen(
courseId: String?,
viewModel: CourseDetailViewModel = viewModel()
) { /* ... */ }The generated CourseDetailScreenDestination only contains courseId. The generated screen call
omits viewModel, so the composable's default injection expression remains responsible for it.
For @Serializable route parameters, encode the JSON payload before appending it to the runtime
URL query string:
@Serializable
data class Filter(val tab: String, val page: Int)
val filter = serializeRouteQueryValue(Filter(tab = "post", page = 2))
NavCenter.navigate("app://user/detail?filter=$filter")For local page result passing, prefer the host-scoped result store on NavBackStackState.
There are two usage styles:
Example:
Button(
onClick = {
backStack.navigate(EditProfileScreen(resultKey = resultKey))
}
) { /* ... */ }
val result = backStack.consumeResult<ProfileResult>()
// or
val result = backStack.consumeResult<ProfileResult>(resultKey)Return from the child page:
backStack.setResult(ProfileResult(...))
backStack.goBack()With a custom key:
backStack.setResult(resultKey, ProfileResult(...))
backStack.goBack()Available APIs:
setResult(...)peekResult(...)consumeResult(...)consumeResultEffect(...)hasResult(...)clearResult(...)If the result should only be handled once when the page becomes active again, prefer
consumeResultEffect(...) or consumeResult(...) over peekResult(...).
NavCenter.setRegistries(...).NavDisplayHelper(...) is optional; users may directly use NavDisplay.@Screen(route = ...).NavBackStackState for the current host.NavDisplayHelper(...) or NavDisplay(...).NavCenter.navigate(...).