Kotlin Multiplatform navigation helper focused on:
- state-first back stack management
- cross-module navigation by route key
- KSP-generated registries and destinations
中文文档: 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/detail
Recommended rules:
- Route keys must be globally unique.
- The annotation route must not contain query parameters or fragments.
- Complete path-segment placeholders are supported, such as
users/{filter}/{id}. - Path placeholder and query parameter names should match composable parameter names.
- Route keys should stay stable and should not be coupled to function names.
Route key normalization:
- Query parameters and fragments are ignored when matching page identity.
- Empty path segments are ignored, so trailing slashes do not change the key.
- Scheme and authority are normalized to lowercase.
- If the same query key appears multiple times at runtime, the last value wins.
- Path parameters are URL-decoded and override query parameters with the same name.
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:
Proceedcontinues 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:
- You cannot write
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. - The generated
equals/hashCodeignoreentryIdand match only on business parameters, so URL-structural APIs likegoBack(url)/remove(url)keep working. entryIdis a reserved parameter name on screen composables.- Disabled by default; ordinary screens are unaffected.
URL query restoration is intended for lightweight public parameters.
Supported:
String- primitive types
@Serializableobject types- nullable values
- default values
If you use @Serializable screen parameters, the declaring module should also apply the Kotlin
serialization plugin.
Not recommended for URL transport:
- complex objects
- large payloads
- private or sensitive business state
Runtime behavior:
- Missing required query parameters make route resolution fail.
- Invalid primitive parsing also makes route resolution fail.
- Invalid
@SerializableJSON payloads also make route resolution fail. - Nullable parameters and parameters with default values fall back naturally.
- If a value should not come from the route, prefer loading it from inside the screen.
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:
- use the result type itself as the default key
- pass a custom key when you need multiple results of the same type in one flow
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(...).
- Registries are application-level global configuration.
- Register them once during app startup with
NavCenter.setRegistries(...). - Duplicate route keys fail fast during registration.
NavDisplayHelper(...)is optional; users may directly useNavDisplay.
- Mark composable pages with
@Screen(route = ...). - Initialize global registries at app startup.
- Create a
NavBackStackStatefor the current host. - Render with
NavDisplayHelper(...)orNavDisplay(...). - Navigate anywhere by route key through
NavCenter.navigate(...).