Annotate a screen with @AutoRegister and it is wired. NavEase generates the registry, a serializer for every key, and the saved-state configuration your back stack restores from. Typed keys in every screen. No reflection, no string routes, no manual registry — and no red underlines while you write.
// 1. Your destinations. No @Serializable, no NavKey. sealed class AppScreens : NavEaseRoot { data object Home : AppScreens() data class Detail(val id: String) : AppScreens() } // 2. A screen. navKey arrives already typed. @AutoRegister class DetailScreen : ActivityScreen<AppScreens.Detail>() { @Composable override fun Content(navKey: AppScreens.Detail, nav: NavEaseController) { Text(navKey.id) // no casting, no args() helper } } // 3. The host. Serves every screen under that root. @Composable fun App() { NavEaseHost<AppScreens>(start = AppScreens.Home) } // iOS, Desktop, Web, Android — the same App(). No per-platform wiring.
Runs natively on every platform
One way to declare a graph, so there is one thing to learn and one thing to debug.
Annotate an ActivityScreen<K> subclass. KSP finds it, writes its serializer, and registers it. There is no factory to update and no list to keep in sync.
id("io.github.alims-repo.navease"). The plugin applies KSP and the serialization compiler plugin, adds the runtime, registers the generated source directory, and orders the tasks.
Content() receives navKey: K — the exact subclass this screen handles. Read navKey.id directly. No casting, no bundle, no nullable lookups.
A second sealed root gets a second host. Entries are filtered by root, so two graphs never see each other's screens — and two roots may safely declare keys of the same name.
Off by default, so screens that don't use them need no experimental opt-in. Turn it on at the host that owns the transition and read the two scopes from runtime.composition.
The SavedStateConfiguration is assembled from the generated serializers, so a back stack — keys with arguments included — survives process death on every platform.
Each module generates into its own package and its own bootstrap, so several modules use NavEase without colliding. A root per feature module is the natural shape.
singleTop, popUpTo, popToIndex, replace with finish = true, and back-with-result — all typed, all scoped to their own host.
Push, Fade, Rise, Zoom, Depth, Instant. Set a default at the host, override per navigation. Recorded per push, so the reverse plays on the way back — predictive back included.
Five steps. Nothing after step 4 is required to ship — step 5 is the rest of the API, for when you need it.
In the module that will hold your screens:
plugins {
kotlin("multiplatform")
id("org.jetbrains.compose")
id("org.jetbrains.kotlin.plugin.compose")
id("io.github.alims-repo.navease") version "0.2.0"
}
That is the whole build change. The plugin applies KSP and the Kotlin serialization compiler plugin — skipping either one you already declare — adds navease-runtime and navease-ksp, registers build/generated/ksp/metadata/commonMain/kotlin as a source directory, and makes every compilation depend on kspCommonMainKotlinMetadata. Prefer to wire it by hand? The README has the manual setup.
A sealed class extending NavEaseRoot. One subclass per screen; a data class when it carries arguments.
import io.github.alimsrepo.navease.runtime.NavEaseRoot sealed class AppScreens : NavEaseRoot { data object Splash : AppScreens() data object Home : AppScreens() data class Profile(val userId: String, val isEditable: Boolean = false) : AppScreens() data class Detail(val itemId: String, val title: String) : AppScreens() }
No @Serializable, and no : NavKey — NavEaseRoot covers both. NavEase writes a serializer for each subclass from its constructor parameters. Argument types still need to be serializable: Kotlin primitives and String work as they are, anything else must be @Serializable.
Extend ActivityScreen<K>, annotate with @AutoRegister, override Content().
import io.github.alimsrepo.navease.runtime.annotations.AutoRegister import io.github.alimsrepo.navease.runtime.navigation.NavEaseController import io.github.alimsrepo.navease.runtime.screen.ActivityScreen @AutoRegister class HomeScreen : ActivityScreen<AppScreens.Home>() { @Composable override fun Content(navKey: AppScreens.Home, navEaseController: NavEaseController) { Button(onClick = { navEaseController.navigate(AppScreens.Profile(userId = "alim")) }) { Text("Open profile") } } } @AutoRegister class ProfileScreen : ActivityScreen<AppScreens.Profile>() { @Composable override fun Content(navKey: AppScreens.Profile, navEaseController: NavEaseController) { Text("User ${navKey.userId}") // typed — no casting if (navKey.isEditable) EditForm() } }
@AutoRegister takes no arguments — the start destination belongs to the host, so a nested host can pick its own. Two rules, both enforced at build time: extend ActivityScreen directly (an intermediate base class of your own hides K from KSP), and be constructible with no arguments (generated code calls HomeScreen(); take dependencies inside Content()).
One host, one type parameter, one start destination.
import io.github.alimsrepo.navease.runtime.host.NavEaseHost @Composable fun App() { MaterialTheme { NavEaseHost<AppScreens>( start = AppScreens.Splash, onExitRequest = { finish() }, // back pressed on the root screen ) } }
Build once so KSP runs, and that is a working graph. Adding a screen later means writing the class and rebuilding — nothing else to update. The same App() is the entry point on Android, iOS, Desktop and Web; there is no per-platform initialisation to remember.
None of this is required to ship. Reach for it when you need it.
/** Push a screen onto the back stack. */ fun navigate( navKey: NavKey, finish: Boolean = false, // push and drop the screen underneath — replace singleTop: Boolean = false, // skip if that screen class is already on top navTransition: NavTransition? = null, ) /** Pop one screen. At the root, calls the host's onExitRequest instead. */ fun back() /** Pop until [key]'s screen. Matched by runtime class — argument values ignored. */ fun popUpTo(key: NavKey, inclusive: Boolean = false) /** Pop everything above [index] (0 = root). */ fun popToIndex(index: Int) /** The current back stack, oldest entry first. */ fun getHistory(): List<NavKey>
// A default for the whole host… NavEaseHost<AppScreens>(start = AppScreens.Splash, navTransition = NavTransition.Depth) // …overridden for one navigation. Recorded per push, so the reverse plays // on the way back — predictive back included. nav.navigate(AppScreens.Detail(id, title), navTransition = NavTransition.Rise)
// Called whenever the top of the back stack changes, including for the start // destination on first composition. For concerns that belong to the graph rather // than to any one screen — screen-view analytics being the obvious one. NavEaseHost<AppScreens>( start = AppScreens.Splash, onDestinationChanged = { navKey -> analytics.logScreenView(navKey::class.simpleName.orEmpty()) }, ) // NavEaseController is the receiver, not a second parameter — so a hook that // only observes ignores it, and one that needs to navigate just uses it. NavEaseHost<HomeNav>( start = HomeNav.Root, onDestinationChanged = { navKey -> hideBottomBar(navKey !is HomeNav.Root) }, )
import io.github.alimsrepo.navease.runtime.navigation.backWithResult import io.github.alimsrepo.navease.runtime.navigation.resultOf data class ProfileResult(val saved: Boolean) // On the screen returning a value: navEaseController.backWithResult(ProfileResult(saved = true)) // On the screen waiting for it: val result by navEaseController.resultOf<ProfileResult>() LaunchedEffect(result) { if (result?.saved == true) showSnackbar("Profile saved") }
Results live in a snapshot-state map scoped to that host's controller, so writing one recomposes the reader. resultOf<T>() consumes the entry as it reads it — it will not fire twice — and nothing is shared between independent hosts. Keyed by the type's simple name, so it must be a named class.
// Turn it on at the host that owns the transition. A shared element only morphs // within one host, so enable it on the host containing both screens. NavEaseHost<AppScreens>(start = AppScreens.Splash, enableSharedTransitions = true) // Then read both scopes in the two screens, matching the key exactly. import io.github.alimsrepo.navease.runtime.composition.LocalNavEaseAnimatedContentScope import io.github.alimsrepo.navease.runtime.composition.LocalNavEaseSharedTransitionScope @OptIn(ExperimentalSharedTransitionApi::class) @Composable fun Avatar(userId: String) { val sharedScope = LocalNavEaseSharedTransitionScope.current val animScope = LocalNavEaseAnimatedContentScope.current // Guard on null so the same composable still renders when the flag is off. val modifier = if (sharedScope != null) { with(sharedScope) { Modifier.sharedBounds( sharedContentState = rememberSharedContentState(key = "avatar_$userId"), animatedVisibilityScope = animScope, ) } } else Modifier Box(modifier.size(88.dp)) }
// A nested graph is another sealed root with its own screens and its own host. sealed class WizardStep : NavEaseRoot { data object PickRole : WizardStep() data class Confirm(val role: String) : WizardStep() } @AutoRegister class PickRoleScreen : ActivityScreen<WizardStep.PickRole>() @AutoRegister class ConfirmScreen : ActivityScreen<WizardStep.Confirm>() // Inside a screen of the outer graph: NavEaseHost<WizardStep>( start = WizardStep.PickRole, onExitRequest = { outerController.back() }, // back on the nested root leaves the feature )
Each host owns an independent back stack, controller and result store, and gets its own screen instances, so nothing leaks between them. Two roots may safely declare keys with the same name — AppScreens.Detail and WizardStep.Detail stay distinct throughout the generated code.
Two files per module, in build/generated/ksp/metadata/commonMain/kotlin/. Names are fully qualified and entries are sorted, so the output is byte-identical between builds.
// A KSerializer per key — this is why your keys need no @Serializable… private object NavEaseSer_com_example_AppScreens_Detail : KSerializer<AppScreens.Detail> { /* … */ } // …and one registration per screen. Screens are registered as factories, so each // host builds its own instances and two hosts never share a screen object. private object NavEaseAutoInit { init { NavEaseAutoRegistry.addEntry( AppScreens.Home::class, AppScreens::class, NavEaseSer_com_example_AppScreens_Home, ) { HomeScreen() } // … one line per @AutoRegister screen } } public fun navEaseBootstrap() { NavEaseAutoInit }
// One overload per sealed root. This is what initialises the registry. @Composable public inline fun <reified T : AppScreens> NavEaseHost( start: AppScreens, /* … the full host parameter list … */ ) { com.example.generated.app.navEaseBootstrap() NavEaseHostForRoot(rootClass = AppScreens::class, start = start, /* … */) }
Why the overload lives in the runtime's package. Your call site imports io.github.alimsrepo.navease.runtime.host.NavEaseHost, and a single-name import brings in every overload from that package. The generated one wins resolution because its start parameter is the more specific type — so NavEaseHost<AppScreens>(...) binds to it and bootstraps first. That is what makes the registry work identically on every platform: it depends on neither JVM reflection nor a Kotlin/Native eager-init anchor. The file name carries the module's generated package, so several modules never collide.
Every option has a working default, so the block is optional.
| Property | Type | Default | Description |
|---|---|---|---|
version |
Property<String> |
the plugin's own version | Pins navease-runtime and navease-ksp. The default keeps all three in step. |
addRuntimeDependency |
Property<Boolean> |
true |
Adds navease-runtime to commonMain. Set false to declare it yourself. |
generatedPackage |
Property<String> |
io.github.alimsrepo.navease.generated.<module> |
Where KSP writes. The module name is part of the default because two modules generating into one package would produce duplicate classes. |
kspProcessorDependency |
Property<Any> |
Maven Central coordinate | Override with project(":navease-ksp") when working inside the NavEase repository. |
runtimeDependency |
Property<Any> |
Maven Central coordinate | Override with project(":navease-runtime") for the same reason. |
navease { version = "0.2.0" generatedPackage = "com.example.app.navigation" }
Apply the plugin to every module that declares @AutoRegister screens. Each gets its own generated package and its own bootstrap, so nothing collides.
:app AppScreens — the shell :feature-cart CartScreens — nested host, its own root :feature-account AccountScreens — nested host, its own root
One rule. All the screens for a given sealed root must live in one module. That root's host overload is generated by the module owning those screens, and two modules generating an overload for the same root would produce an ambiguous call. The module that hosts a nested graph needs a dependency on the module declaring it, as it would for any other type.
The host did not bind to the generated overload. Either the module has not been built since the screens were added, or the NavEase plugin is not applied to the module that declares them.
Run ./gradlew :yourModule:kspCommonMainKotlinMetadata and check that build/generated/ksp/metadata/commonMain/kotlin contains AutoRegisterScreens.kt. Then re-sync the IDE.
The registry is populated, but nothing is registered under that root — the message lists the roots that are. NavEase matches a screen to the outermost sealed class of its key, so the usual cause is a key that belongs to a different root than expected.
KSP reads the key type K from the class's direct supertypes, so an intermediate base class of your own — a TrackedScreen<K> that centralises analytics, say — hides it.
Put shared behaviour in a composable you call from Content(), or use onDestinationChanged for anything graph-wide.
Generated code calls HomeScreen(), so a constructor parameter it cannot supply is a build error. Obtain dependencies inside Content() — from a composition local, or your DI framework's composable accessor.
A screen instance is created per host, so it may hold state for the life of that host.
Two modules were given the same generatedPackage. Remove the explicit value and let the plugin derive one per module, or give each module a distinct one.
A saved back stack was written before that parameter existed on the key. NavEase writes every parameter, so a restore can only miss one when the key's parameters changed between the save and the restore.
Removing a parameter is safe — unknown elements are skipped — but adding a required one invalidates back stacks saved by earlier builds.
No. NavEaseRoot is enough — NavEase writes the serializers. Argument types still need to be serializable: Kotlin primitives and String work as they are, anything else must be @Serializable. Sealed hierarchies are fine; the generated serializer delegates to the type's own.
Generated serializers are not the compiler plugin's $$serializer classes, so a rule matching those will not cover them. Keep the generated package:
-keep class com.example.app.navigation.** { *; }
Yes, including predictive back on Android. Back is only consumed while there is something to pop — at the root it falls through to the system, so the app closes normally unless you handle onExitRequest.
KMP library · all platforms
NavEaseRoot — the marker your sealed key class extends@AutoRegister — the one annotationActivityScreen<K> — typed base class; override Content(navKey: K, …)NavEaseHost<Root>(start) — the host, plus the display engineNavEaseController — navigate · back · popUpTo · popToIndex · backWithResultNavTransition — Push · Fade · Rise · Zoom · Depth · InstantLocalNavEaseController, LocalNavEaseSharedTransitionScope, LocalNavEaseAnimatedContentScopeNavEaseAutoRegistry — filled in by generated code; you never call itJVM symbol processor
@AutoRegister class to its key and its outermost sealed rootKSerializer per key — no @Serializable on your keysnavEaseBootstrap() and a NavEaseHost overload per rootActivityScreen supertype · NavEaseRoot key · concrete, no-arg constructorID io.github.alims-repo.navease
navease-ksp to kspCommonMainMetadata and navease-runtime to commonMaincommonMainkspCommonMainKotlinMetadataNavEase is open-source and free. Star the repo and ship faster.