NavEase is a Kotlin Multiplatform navigation library with three clean integration paths: Plugin + @AutoRegister for zero-config screen discovery, a zero-rebuild DSL Graph for instant IDE resolution, and Explicit ActivityScreen<K> for typed OOP registration. No reflection. No string routes. No boilerplate.
// 1. Annotate your screens — type-safe navKey, no casting @AutoRegister class HomeScreen : ActivityScreen<AppScreens.Home>() { @Composable override fun Content(navKey: AppScreens.Home, nav: NavController) { Button(onClick = { nav.navigate(AppScreens.Detail(id = "42")) }) { Text("Open Detail") } } } // 2. Use the host with your Root type — everything else is automatic @Composable fun App() { NavEaseHost<AppScreens>( // ← Root type required ✅ start = AppScreens.Home, onExitRequest = { /* ... */ } ) } // iOS entry — completely clean, zero extra wiring needed fun MainViewController() = ComposeUIViewController { App() }
Runs natively on every platform
NavEase gives you compile-time safety, zero runtime overhead, and a dead-simple API that doesn't get in your way.
@AutoRegister — one annotation, NavEaseHost<Root>(start), auto-wires on all platforms. DSL Graph — plain Kotlin, no annotations, instant IDE resolution. Explicit ActivityScreen<K> — typed OOP registration via NavEaseHost<Root> { add(Screen()) }. All three share the same runtime engine.
One line: id("io.github.alims-repo.navease"). The Gradle plugin auto-applies KSP, adds navease-runtime, registers the source directory, and wires task ordering. Zero manual boilerplate.
Annotate any ActivityScreen<K> subclass with @AutoRegister. KSP discovers your screens and wires them automatically when you use NavEaseHost<Root>(start). No manual screen listing, no navEaseBootstrap() call required.
navEaseGraph { screen<K> { … } } — define screens in plain Kotlin. No annotation processing, no rebuild required after adding a screen. The IDE resolves everything immediately.
Extend ActivityScreen<K> and receive a fully-typed navKey: K in Content(). No casting, no helper functions — direct field access on your sealed key class.
Enable with one parameter: NavEaseHost(enableSharedTransitions = true). Use LocalNavEaseSharedTransitionScope in any screen, zero extra setup.
SavedStateConfiguration is built and wired automatically from registered screens. Your back stack survives process death out of the box on every platform.
Built on JetBrains Navigation3 — the new Compose-first, serializable NavKey API. Works on every KMP target: Android, iOS, Desktop, Web.
singleTop, popUpTo, popToIndex, replace (finish = true) — all the navigation patterns you need, typed and safe.
LocalNavEaseController.current provides the nav controller to any deeply nested composable — no prop drilling.
All three paths share the same runtime engine, the same NavEaseHost, and the same typed navigation API. The difference is how much you want KSP to automate versus how much plain-Kotlin control you want.
Zero manual screen listing. Annotate your ActivityScreen subclasses and KSP discovers everything automatically.
navKey: K in every screen, no castingNavEaseHost<Root>(start) entry pointNo KSP. No annotations. No rebuild. Define screens in a Kotlin DSL and navigate with full IDE support immediately.
key is fully typedClass-based OOP registration. Extend ActivityScreen<K> and register each screen explicitly inside NavEaseHost<Root>.
navKey: K in Content() — no castingThe fastest way to production. One plugin line, one annotation per screen, and a type-safe NavEaseHost<Root>(start). The registry auto-wires on every platform — no navEaseBootstrap() call needed. iOS entry point stays completely clean.
In your shared/build.gradle.kts:
plugins {
kotlin("multiplatform")
id("io.github.alims-repo.navease") version "0.1.3"
}
The plugin automatically applies KSP, adds navease-runtime, registers the generated source directory, and wires all compilation tasks to depend on KSP.
Create a sealed class in commonMain that extends NavEaseRoot (no @Serializable needed):
sealed class AppScreens : NavEaseRoot { data object Home : AppScreens() data class Detail(val id: String) : AppScreens() }
Extend ActivityScreen<K> and add @AutoRegister.
@AutoRegister class HomeScreen : ActivityScreen<AppScreens.Home>() { @Composable override fun Content(navKey: AppScreens.Home, navController: NavController) { Button(onClick = { navController.navigate(AppScreens.Detail(id = "42")) }) { Text("Open Detail") } } } @AutoRegister class DetailScreen : ActivityScreen<AppScreens.Detail>() { data class Result(val confirmed: Boolean) @Composable override fun Content(navKey: AppScreens.Detail, navController: NavController) { Text("ID: ${navKey.id}") // ← typed, no casting ✅ Button(onClick = { navController.backWithResult(Result(confirmed = true)) }) { Text("Done") } } }
KSP validates at compile time: no duplicate NavKeys, and all screens must share the same sealed root. You specify the start destination when calling NavEaseHost.
The registry auto-wires on every platform — no navEaseBootstrap() call needed anywhere. Just drop NavEaseHost into App.kt:
@Composable fun App() { MaterialTheme { NavEaseHost<AppScreens>( // ← type param + start destination ✅ start = AppScreens.Home, onExitRequest = { /* show exit dialog or finish() */ }, enableSharedTransitions = true, navTransition = NavTransition.Push, ) } }
Registry auto-initialization: The generated NavEaseHost overloads handle everything for you on every platform (Android/JVM, iOS/Native, JS/Wasm). Your iOS MainViewController stays completely clean — just ComposeUIViewController { App() }.
In the parent screen, collect the typed result via the runtime API:
val result by navController.resultOf<DetailScreen.Result>() result?.let { Text("Confirmed: ${it.confirmed}") }
No KSP. No annotations. Define everything in Kotlin and navigate immediately with full IDE support.
You only need the runtime dependency — no KSP plugin required:
plugins {
kotlin("multiplatform")
// The NavEase plugin is optional for this path.
// You can also add the runtime manually:
}
kotlin {
sourceSets {
commonMain {
dependencies {
implementation("io.github.alims-repo:navease-runtime:0.1.3")
}
}
}
}
Create a plain @Serializable sealed class — no annotations on the class itself:
@Serializable sealed class AppScreen : NavKey { @Serializable data object Home : AppScreen() @Serializable data class Detail(val id: String) : AppScreen() }
Use navEaseGraph { } — each screen lambda receives a typed key:
val appGraph = navEaseGraph(start = AppScreen.Home) { screen<AppScreen.Home> { HomeScreen() } screen<AppScreen.Detail> { key -> DetailScreen(id = key.id) } // ^^^ typed key — no casting ✅ }
The screen<K> { } lambda receives the fully-typed key. IDE resolves key.id instantly — no rebuild ever needed after adding a screen.
Pass the graph to NavEaseHost — single call, zero boilerplate:
@Composable fun App() { MaterialTheme { NavEaseHost( // ← DSL overload ✅ graph = appGraph, onExitRequest = {}, navTransition = NavTransition.Push, ) } }
Use the runtime API directly — no generated code needed:
// Navigate val nav = LocalNavEaseController.current nav?.navigate(AppScreen.Detail(id = "abc")) // Return result data class DetailResult(val ok: Boolean) nav.backWithResult(DetailResult(ok = true)) // Observe result in parent screen val result by nav.resultOf<DetailResult>()
No KSP. No annotations. No rebuild. Extend ActivityScreen<K> and register each screen explicitly inside a typed NavEaseHost<Root> block. Your IDE resolves everything immediately — plain Kotlin, full OOP control.
Only navease-runtime is needed — no KSP plugin, no annotation processor:
kotlin {
sourceSets {
commonMain {
dependencies {
implementation("io.github.alims-repo:navease-runtime:0.1.3")
}
}
}
}
Same plain @Serializable sealed class used by all paths:
@Serializable sealed class AppScreens : NavKey { @Serializable data object Home : AppScreens() @Serializable data class Detail(val id: String) : AppScreens() }
Each screen class receives a fully-typed navKey: K in Content() — no casting, no generated extensions:
class HomeScreen : ActivityScreen<AppScreens.Home>() { @Composable override fun Content(navKey: AppScreens.Home, navController: NavController) { Button(onClick = { navController.navigate(AppScreens.Detail(id = "42")) }) { Text("Open Detail") } } } class DetailScreen : ActivityScreen<AppScreens.Detail>() { data class Result(val confirmed: Boolean) @Composable override fun Content(navKey: AppScreens.Detail, navController: NavController) { Text("ID: ${navKey.id}") // ← typed, no casting ✅ Button(onClick = { navController.backWithResult(Result(confirmed = true)) }) { Text("Done") } } }
Use the typed NavEaseHost<Root> overload and call add(Screen()) for each screen. SavedStateConfiguration is built automatically from the registered screens:
@Composable fun App() { MaterialTheme { NavEaseHost<AppScreens>( // ← typed root, explicit registration ✅ start = AppScreens.Home, onExitRequest = {}, enableSharedTransitions = true, navTransition = NavTransition.Push, ) { add(HomeScreen()) add(DetailScreen()) } } }
Adding a new screen is as simple as creating the class and calling add() here — no rebuild, no annotation, no plugin required.
Nest result classes inside the child screen for clear ownership, then observe them in the parent:
val result by navController.resultOf<DetailScreen.Result>() result?.let { Text("Confirmed: ${it.confirmed}") }
Every option available in the navease { } block.
| Property | Type | Default | Description |
|---|---|---|---|
version |
String |
"" (plugin bundled) |
Pin a specific NavEase version for both runtime and KSP processor. |
addRuntimeDependency |
Boolean |
true |
Automatically adds navease-runtime to commonMain. Set false to manage it yourself. |
kspProcessorDependency |
Any? |
null (Maven Central) |
Override with project(":navease-ksp") for local monorepo development. |
runtimeDependency |
Any? |
null (Maven Central) |
Override with project(":navease-runtime") for local monorepo development. |
generatedPackage |
String |
"" |
Custom package for KSP-generated files. Forwards to navease.generatedPackage KSP arg. |
navease { version = "0.1.3" kspProcessorDependency = project(":navease-ksp") runtimeDependency = project(":navease-runtime") generatedPackage = "com.example.myapp.navigation" }
This means KSP hasn't generated the initializer yet. Run ./gradlew :shared:kspCommonMainKotlinMetadata once to generate AutoRegisterScreens.kt, then sync the IDE. The registry bootstraps automatically when you use NavEaseHost<Root> — no navEaseBootstrap() call is required in your App.kt or platform entry points.
Each NavKey type may only be handled by one screen — using the same AppScreens.Detail in two different ActivityScreen classes will produce an error. Fix: ensure only one screen maps to each NavKey. Also ensure all screens share the same sealed root class.
Yes, but they must use separate NavEaseHost instances (e.g., one for a nested tab subgraph using the DSL, and one for the root using @AutoRegister). Do not mix the two approaches inside the same back stack.
On iOS/Native the registry is populated automatically by the generated NavEaseHost overloads, which call navEaseBootstrap() for you on first composition. No manual call is needed in your entry points.
If you still see this error, the most likely cause is that KSP has not run yet. Run:
./gradlew :shared:kspCommonMainKotlinMetadata
Then rebuild the iOS framework. Your MainViewController.kt stays completely clean — just ComposeUIViewController { App() }. No imports, no extra calls.
1. Keep your @Serializable sealed NavKey class. 2. Remove @AutoRegister annotations and extend plain classes. 3. Build a navEaseGraph { screen<K> { } } block. 4. Replace the NavEaseHost<Root>(start) call with NavEaseHost(graph = appGraph). 5. Remove the NavEase plugin if you no longer need KSP — only navease-runtime is required for the DSL path.
Yes. NavEase targets the stable Navigation3 serializable NavKey API. Bump the versions in your own libs.versions.toml; the NavEase runtime depends on Navigation3 but does not pin a specific version aggressively.
NavEase generates readable, auditable Kotlin. No magic — just code that you could have written yourself, but now you don't have to.
// Generated from @AutoRegister annotations package io.github.alimsrepo.navease.generated private object NavEaseAutoInit { init { NavEaseAutoRegistry.addEntry( SplashScreen(), AppScreens.Splash::class, AppScreens::class, NavEase_Splash_Ser // start ✅ ) NavEaseAutoRegistry.addEntry( HomeScreen(), AppScreens.Home::class, AppScreens::class, NavEase_Home_Ser ) // … all other @AutoRegister screens … } } private val _navEaseAutoInit: Any = NavEaseAutoInit // Called automatically by the generated NavEaseHost overloads fun navEaseBootstrap() { _navEaseAutoInit // triggers NavEaseAutoInit on Native/JS }
// commonMain — App.kt // The registry bootstraps automatically on every platform. Just use NavEaseHost. @Composable fun App() { NavEaseHost<AppScreens>(start = AppScreens.Splash) // ← that's it ✅ } // iosMain — MainViewController.kt // Completely clean — NavEaseHost handles the registry automatically. fun MainViewController() = ComposeUIViewController { App() } // Navigation — same on every platform val nav = LocalNavEaseController.current nav?.navigate(AppScreens.Detail(id = "abc")) // Typed result — no casting val result by nav.resultOf<DetailScreen.Result>()
KMP commonMain library
NavEaseHost<Root>(start) — auto-discovers @AutoRegister screens; registry auto-wires on all platformsNavEaseHost(graph) — DSL graph variant, no KSP neededNavEaseHost<Root>(start) { add(Screen()) } — explicit typed ActivityScreen registrationActivityScreen<K> — typed abstract base; override Content(navKey: K, …)navEaseGraph { screen<K> { } } — zero-rebuild DSL builderNavEaseAutoRegistry — global screen registry populated by KSP; auto-initialized on all platformsNavEaseRoot — NavKey alias that removes @Serializable requirements for @AutoRegister keysNavController — navigate / back / popUpTo / singleTop / backWithResultLocalNavEaseController + LocalNavEaseSharedTransitionScopeJVM KSP annotation processor
NavEaseProcessor — reads @AutoRegister annotationsNavEaseAutoInit object with direct addEntry() callsNavEaseHost overloads that call navEaseBootstrap() automaticallyClass.forName class-loading inside NavEaseHostio.github.alimsrepo.navease.generatedGradle plugin · ID io.github.alims-repo.navease
id("com.google.devtools.ksp") needednavease-ksp to kspCommonMainMetadata automaticallybuild/generated/ksp/metadata/commonMain/kotlin as a source dirkspCommonMainKotlinMetadatanavease-runtime to commonMaingeneratedPackage KSP arg · configurable via navease { } extensionNavEase is open-source and free. Star the repo and ship faster.