NavEase
Navigation for Kotlin Multiplatform + Compose Multiplatform.
Annotate a screen, and it is wired. No reflection, no string routes, no manual registry, and no
red underlines while you write.
Status: published to Maven Central — latest version 0.2.0
Table of contents
How it works
Three pieces, and nothing else:
At build time KSP finds the annotated classes and writes the registry, a KSerializer for every
key, and the SavedStateConfiguration the back stack restores from.
What you don't write: no when (route) factory, no serializers module, no @Serializable on
your key class, no route strings, no manual add(...) list.
Platform support
No per-platform initialisation. The same App() works everywhere.
Setup
With the Gradle plugin
plugins {
kotlin("multiplatform")
id("com.android.kotlin.multiplatform.library")
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 your build
already declares;
- adds
navease-ksp to and to ;
Optional configuration:
navease {
version = "0.2.0"
addRuntimeDependency = false
generatedPackage = "com.example.app.navigation"
}
Without the plugin
plugins {
kotlin("multiplatform")
id("com.google.devtools.ksp")
id()
}
kotlin {
sourceSets {
commonMain {
kotlin.srcDir()
dependencies {
implementation()
}
}
}
}
dependencies {
add(, )
}
ksp {
arg(, )
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask<*>>().configureEach {
(name != ) dependsOn()
}
tasks.configureEach {
(name != && name.startsWith()) {
dependsOn()
}
}
Quick start
1. Declare your destinations
import io.github.alimsrepo.navease.runtime.NavEaseRoot
sealed class AppScreens : NavEaseRoot {
data object Splash : AppScreens()
data Home : AppScreens()
( userId: String, isEditable: = ) : AppScreens()
( itemId: String, title: String) : AppScreens()
}
No @Serializable, and no : NavKey. NavEaseRoot covers both — KSP writes a serializer for
each subclass from its constructor parameters.
2. Write the screens
@AutoRegister takes no arguments. The start destination is declared at the host, so a nested
host can pick its own.
3. Host it
@Composable
fun App() {
MaterialTheme {
NavEaseHost<AppScreens>(
start = AppScreens.Splash,
onExitRequest = { finish() },
)
}
}
That is the whole setup. Adding a screen later means writing the class and rebuilding.
NavEaseController
Received as the second parameter of Content(), and available from any composable below the host
through LocalNavEaseController.current.
From deep in a tree:
@Composable
fun DeepWidget() {
val nav = LocalNavEaseController.current ?: return
Button(onClick = { nav.back() }) { Text("Back") }
}
Transitions
NavEaseHost<AppScreens>(start = AppScreens.Splash, navTransition = NavTransition.Depth)
The host value is the default; any single navigation can override it:
nav.navigate(AppScreens.Detail(id, title), navTransition = NavTransition.Rise)
The transition is recorded per push, so the matching reverse animation plays on the way back,
including for the predictive-back gesture.
Observing destination changes
Every host takes an optional onDestinationChanged, called whenever the top of the back stack
changes — including for the start destination on first composition.
It is for concerns that belong to the graph rather than to any one screen. Screen-view analytics
is the obvious one: done per screen it is a line every screen has to remember, and nothing catches
a new screen that forgets it.
NavEaseHost<AppScreens>(
start = AppScreens.Splash,
onDestinationChanged = { navKey ->
analytics.logScreenView(navKey::class.simpleName.orEmpty())
},
)
NavEaseController is the receiver, not a second parameter. The host owns its controller, so
code outside it has no other way to reach one — a hook that only observes ignores the receiver,
and a hook that needs to navigate uses it:
NavEaseHost<AppScreens>(
start = AppScreens.Splash,
onDestinationChanged = { navKey ->
if (navKey !is AppScreens.Home && deepLink.isPending) {
navigate(AppScreens.Home)
}
},
)
On a nested host it answers "which inner screen am I on", which is what tells an outer shell
whether to show its bottom bar:
NavEaseHost<HomeNav>(
start = HomeNav.Root,
onDestinationChanged = { navKey -> hideBottomBar(navKey !is HomeNav.Root) },
)
The callback runs outside composition, so call plain functions from it, not composables.
Back with result
Send a value back to the screen underneath.
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.
Results are keyed by the result type's simple name, so it must be a named class, not an anonymous
or local one.
Shared element transitions
Off by default. When enableSharedTransitions = false, no SharedTransitionLayout is created
and LocalNavEaseSharedTransitionScope is null — zero overhead, and screens that don't use
shared elements need no experimental opt-in.
Turn it on at the host that owns the transition:
NavEaseHost<AppScreens>(
start = AppScreens.Splash,
enableSharedTransitions = true,
)
Then read both scopes in the two screens that share the element, matching the key exactly:
import androidx.compose.animation.ExperimentalSharedTransitionApi
import io.github.alimsrepo.navease.runtime.composition.LocalNavEaseAnimatedContentScope
io.github.alimsrepo.navease.runtime.composition.LocalNavEaseSharedTransitionScope
{
sharedScope = LocalNavEaseSharedTransitionScope.current
animScope = LocalNavEaseAnimatedContentScope.current
modifier = (sharedScope != ) {
with(sharedScope) {
Modifier.sharedBounds(
sharedContentState = rememberSharedContentState(key = ),
animatedVisibilityScope = animScope,
)
}
} Modifier
Box(modifier.size(dp)) { }
}
Rules:
- The key is any
Any — a string like , or a data class. It must match
on both screens, or nothing morphs.
Nested navigation
A nested graph is another sealed root with its own screens and its own host. Entries are filtered
by root, so the two graphs never see each other's screens.
sealed class WizardStep : NavEaseRoot {
data object PickRole : WizardStep()
data class ( role: String) : WizardStep()
}
: <>() { }
: <>() { }
NavEaseHost<WizardStep>(
start = WizardStep.PickRole,
onExitRequest = { outerController.back() },
)
Each host owns an independent back stack, controller and result store, and gets its own screen
instances, so nothing leaks between them. Put as many side by side as you need — bottom tabs, a
wizard inside a screen, a detail pane.
Two roots may safely declare keys with the same name; AppScreens.Detail and WizardStep.Detail
are distinct throughout the generated code.
Multi-module projects
Apply the plugin to every module that declares @AutoRegister screens. Each module gets its own
generated package (io.github.alimsrepo.navease.generated.<module name>) and its own bootstrap,
so nothing collides.
One rule: all the screens for a given sealed root must live in one module. The root's host
overload is generated by the module that owns those screens, and two modules generating an
overload for the same root would produce an ambiguous call. A root per feature module is the
natural shape:
:app AppScreens — the shell
:feature-cart CartScreens — nested host, its own root
:feature-account AccountScreens — nested host, its own root
The module that hosts a nested graph needs a dependency on the module that declares it, as it
would for any other type.
What KSP generates
After a build, in build/generated/ksp/metadata/commonMain/kotlin/:
| File | Contents |
|---|
<generatedPackage>/AutoRegisterScreens.kt | A per key, and registering every screen |
Registration looks like this:
private object NavEaseAutoInit {
init {
NavEaseAutoRegistry.addEntry(
AppScreens.Home::class,
AppScreens::class,
NavEaseSer_com_example_AppScreens_Home,
) { HomeScreen() }
}
}
Screens are registered as factories, so each host builds its own instances and two hosts of
one root never share a screen object.
The generated overload is what initialises the registry. NavEaseHost<AppScreens>(...)
resolves to it in preference to the runtime's generic overload, because its start parameter is
the more specific type, and it calls navEaseBootstrap() before creating the host. 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 overloads are generated into the runtime's host package so that your ordinary
import io.github.alimsrepo.navease.runtime.host.NavEaseHost picks them up. The file name carries
the module's generated package, so several modules never collide.
Generated names are fully qualified and entries are sorted, so the output is byte-identical
between builds.
Constraints
Screens must extend ActivityScreen directly. 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 — fails the build. Put shared behaviour in a composable you call from
Content(), or use onDestinationChanged for anything
graph-wide.
Screens must be constructible with no arguments. Generated code calls HomeScreen(). Obtain
dependencies inside Content() — from a composition local, or your DI framework's composable
accessor — rather than through the constructor.
One screen per key. Two @AutoRegister screens handling the same key is a build error, and
the message names both.
Key arguments must 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.
Changing a key's parameters invalidates a saved back stack. NavEase writes every parameter, so
restoring state saved before a parameter was added fails with a message naming that parameter.
Removing one is safe: unknown elements are skipped.
Rebuild after adding a screen. @AutoRegister is processed at build time, so a new screen
needs ./gradlew :shared:kspCommonMainKotlinMetadata — or any build — before the host can route
to it.
R8 / ProGuard. 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.** { *; }
Troubleshooting
"the screen registry is empty"
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.
"no @AutoRegister screens found for root 'X'"
The registry is populated, but nothing is registered under that root. The message lists the roots
that are registered. The usual cause is a key nested under an intermediate sealed layer in a
different root than expected — NavEase matches on the outermost sealed class of the key.
"must extend ActivityScreen<K> directly"
An intermediate base class hides K from KSP. See Constraints.
Duplicate class NavEaseHostOverloads… in a multi-module build
Two modules were given the same generatedPackage. Remove the explicit generatedPackage and let
the plugin derive one per module, or give each module a distinct value.
Module structure
Everything under io.github.alimsrepo.navease.internal is a fork of AndroidX Navigation 3. It is
public for technical reasons but is not a supported surface, and it may change in any release.
Nothing you write should import from it.
Sample app
shared/ is a small multi-screen demo built exactly the way this README describes — six
destinations, typed arguments, shared element transitions and a splash that replaces itself.
./gradlew :androidApp:installDebug
./gradlew :desktopApp:run
FAQ
Do I have to register screens anywhere?
No. Annotate with @AutoRegister and rebuild. There is no factory to update and no list to keep
in sync.
Does my key class need @Serializable?
No. NavEaseRoot is enough — KSP writes the serializers. Argument types still need to be
serializable.
What happens on process death?
The back stack is restored. NavEase assembles the SavedStateConfiguration from the generated
serializers, so a key carrying arguments — including a sealed type — round-trips intact.
Can I have several graphs at once?
Yes. See Nested navigation. Each host is fully independent.
Can a screen take constructor dependencies?
No — generated code calls HomeScreen(). Read them inside Content() instead. A screen is
created per host, so it may hold state for the life of that host.
How do I show a confirmation before exiting?
Show it from onExitRequest and only finish on confirm:
NavEaseHost<AppScreens>(start = AppScreens.Splash, onExitRequest = { showExitDialog = true })
Is the back gesture handled?
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.
Do shared elements work outside Android?
Yes. SharedTransitionLayout is Compose Multiplatform, so iOS, Desktop and Web behave the same.
Do I need KSP?
Yes. The Gradle plugin applies and wires it for you.
License
Copyright 2026 NavEase Contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https:
Sources under navease-runtime/.../navease/internal/ are derived from
AndroidX Navigation 3, Copyright The Android Open Source
Project, also under Apache 2.0. See NOTICE.