Skip to content
 
 

Repository files navigation

nav3-helper

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

Installation

Maven Central Version

Maintainer release instructions: PUBLISHING.md

Android-only module

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)
}

Kotlin Multiplatform

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)
}

Examples

Use LocalNavBackStackState for local destination-based navigation inside the current host, and use NavCenter for cross-module route-key navigation.

1. Local-only 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)

2. Cross-module route navigation

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.

3. @Serializable route parameter

@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")

4. Page result callback

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
    }
}

5. Use NavDisplay directly

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))
)

Route Rules

@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/detail
  • app://user/detail
  • user/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.

Login and interceptors

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() { /* ... */ }

Multi-instance screens (multiInstance)

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/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.
  • Disabled by default; ordinary screens are unaffected.

Current Parameter Support

URL query restoration is intended for lightweight public parameters.

Supported:

  • String
  • primitive types
  • @Serializable object 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 @Serializable JSON 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")

Page Results

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(...).

Registry Rules

  • 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 use NavDisplay.

Minimal Flow

  1. Mark composable pages with @Screen(route = ...).
  2. Initialize global registries at app startup.
  3. Create a NavBackStackState for the current host.
  4. Render with NavDisplayHelper(...) or NavDisplay(...).
  5. Navigate anywhere by route key through NavCenter.navigate(...).

About

Kotlin Multiplatform navigation3 helper

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages