NavEase
NavEase is a KMP navigation library for Kotlin Multiplatform + Compose Multiplatform that offers three levels of integration — pick the one that fits your workflow.
No reflection. No string routes. No red underlines while writing.
✅ Status: published to Maven Central — latest version: 0.1.3
Table of Contents
Features
Three Approaches
All three share the same NavController, shared-element support, and NavDisplay engine.
Platform Support
Setup
TL;DR — use the Gradle plugin to skip all the boilerplate below.
Recommended: Gradle plugin (zero boilerplate)
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.1.3"
}
The navease plugin automatically:
- applies the KSP and Kotlin Serialization compiler plugins
- adds
navease-ksp to kspCommonMainMetadata
- registers
build/generated/ksp/metadata/commonMain/kotlin as a srcDir
Optional configuration via the navease { } extension:
navease {
version = "0.1.3"
addRuntimeDependency = true
generatedPackage = "com.myapp.nav"
}
For Approach 2 (navEaseGraph) and Approach 3 (ActivityScreen<K>) only navease-runtime is required — no KSP needed. If you don't use annotations, omit the KSP plugin and the ksp dependency.
Manual setup (if you prefer full control)
1. Apply plugins in your shared KMP module
plugins {
kotlin("multiplatform")
id("com.android.kotlin.multiplatform.library")
id("org.jetbrains.compose")
id("org.jetbrains.kotlin.plugin.compose")
id("com.google.devtools.ksp")
}
2. Add dependencies
kotlin {
sourceSets {
commonMain {
kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin")
dependencies {
implementation("io.github.alims-repo:navease-runtime:0.1.3")
}
}
}
}
dependencies {
add("kspCommonMainMetadata", "io.github.alims-repo:navease-ksp:0.1.3")
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask<*>>().configureEach {
if (name != ) {
dependsOn()
}
}
3. (Optional) Configure KSP options
ksp {
arg("navease.generatedPackage", "com.myapp.navigation.generated")
}
Approach 1 — KSP Annotations
The fully automated approach: annotate your screen classes and let KSP generate everything.
Step 1 — Annotate your screens
Annotation rules:
@NavEaseScreen(route = "…") — unique name per screen; becomes the sealed subclass name
Step 2 — Trigger code generation
./gradlew :shared:kspCommonMainKotlinMetadata
Step 3 — Launch NavEaseHost
@Composable
fun App() {
MaterialTheme {
NavEaseHost(enableSharedTransitions = true)
}
}
class MainActivity : ComponentActivity() {
override fun {
.onCreate(savedInstanceState)
setContent {
MaterialTheme {
NavEaseHost(onExitRequest = { finish() })
}
}
}
}
Approach 2 — navEaseGraph DSL
The zero-rebuild alternative. Define a plain Kotlin sealed class for your routes and wire screens in a DSL block — no annotations, no code generation, no rebuild required after adding a screen.
Step 1 — Define NavKeys
import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable
@Serializable
sealed class AppScreen : NavKey {
@Serializable data object Home : AppScreen()
@Serializable data object About : AppScreen()
( id: String) : AppScreen()
}
Step 2 — Build the graph
import io.github.alimsrepo.navease.runtime.presentation.navEaseGraph
val appGraph = navEaseGraph(start = AppScreen.Home) {
screen<AppScreen.Home> { HomeScreen() }
screen<AppScreen.About> { AboutScreen() }
screen<AppScreen.Detail> { key -> DetailScreen(id = key.id) }
}
screen<K> receives the typed K key so you can forward route arguments directly.
Step 3 — Host the graph
@Composable
fun App() {
MaterialTheme {
NavEaseHost(
graph = appGraph,
enableSharedTransitions = true,
)
}
}
Step 4 — Navigate
val nav = LocalNavEaseController.current ?: return
nav.navigate(AppScreen.Detail(id = "abc"))
nav.navigate(AppScreen.Home, finish = true)
nav.navigate(AppScreen.About, singleTop = true)
Back-with-Result (navEaseGraph)
data class DetailResult(val confirmed: Boolean)
nav.backWithResult(DetailResult(confirmed = true))
val result by nav.resultOf<DetailResult>()
result?.let { Text("Confirmed: ${it.confirmed}") }
Approach 3 — ActivityScreen<K>
The class-based, typed approach. Each screen extends ActivityScreen<K> where K is the specific NavKey subclass it handles — Content() receives a fully-typed navKey, no casting or generated extensions needed.
Step 1 — Define NavKeys
Same sealed class as Approach 2:
@Serializable
sealed class AppScreens : NavKey {
@Serializable data object Home : AppScreens()
@Serializable data object About : AppScreens()
@Serializable data class Detail(val id: String) : AppScreens()
}
Optional @AutoRegister path (no @Serializable needed):
If you annotate screens with @AutoRegister, NavEase generates serializers internally.
You can skip @Serializable and extend NavEaseRoot instead of NavKey:
import io.github.alimsrepo.navease.runtime.NavEaseRoot
sealed class AppScreens : NavEaseRoot {
data object Home : AppScreens()
data object About : AppScreens()
data class Detail(val id: String) : AppScreens()
}
Step 2 — Define screens
Step 3 — Host with NavEaseHost<Root>
@Composable
fun App() {
MaterialTheme {
NavEaseHost<AppScreens>(
start = AppScreens.Home,
enableSharedTransitions = true,
) {
add(HomeScreen())
add(AboutScreen())
add(DetailScreen())
}
}
}
add(Screen()) registers each screen — the reified type captures K automatically from the class's type parameter.
Back-with-Result (ActivityScreen<K>)
Nest the result class inside the child screen for clear ownership:
NavController API
NavController is the same across all three approaches. It is received as a parameter in Content() and is also available via LocalNavEaseController.current from any nested composable.
Per-navigate transitions
navController.navigate(
AppScreens.Detail(id = "abc"),
navTransition = NavTransition.Fade,
)
Available transitions: Push (iOS-style slide) · Fade · Rise (slide up) · Zoom · Depth · Instant
Back-with-Result
NavEase provides a type-safe back-with-result pattern across all three approaches. Results are scoped to the NavController instance — no global state, no cross-contamination.
Low-level API (all approaches)
navController.backWithResult(MyResult(value = 42))
val result by navController.resultOf<MyResult>()
resultOf<T>() returns a State<T?> backed by a SnapshotStateMap inside NavController. Recomposition happens automatically the frame the child calls backWithResult(). The value is consumed in the same frame via SideEffect — no one-frame null window.
Approach 1 — KSP-generated typed extensions
KSP wraps the low-level API with named functions so you never touch backWithResult() / resultOf() directly:
navController.backWithLibraryDetailResult(starred = true)
val result by navController.libraryDetailResult()
result?.let { if (it.starred) Text("⭐ Starred!") }
Approach 2 — navEaseGraph DSL
data class PickerResult(val imageUri: String)
navController.backWithResult(PickerResult(imageUri = "content://…"))
val pickerResult by navController.resultOf<PickerResult>()
Approach 3 — ActivityScreen<K>
class ImagePickerScreen : ActivityScreen<AppScreens.ImagePicker>() {
data class Result(val imageUri: String)
navController.backWithResult(Result(imageUri = "content://…"))
}
val pickerResult by navController.resultOf<ImagePickerScreen.Result>()
Shared Element Transitions
NavEase has optional shared element transition support. It is off by default — zero overhead when not used.
Enable shared transitions
NavEaseHost(enableSharedTransitions = true)
NavEaseHost(graph, enableSharedTransitions = true)
NavEaseHost<Root>(start, enableSharedTransitions = true) { … }
When true, NavEase wraps NavDisplay in a SharedTransitionLayout and provides the scope via LocalNavEaseSharedTransitionScope.
Use shared elements in screens
Rules for shared element keys:
- Keys are plain
Any values — use a string like "avatar_$username" or a data class
- The key must match exactly between the source and destination screen
- Use
sharedBounds for containers that change size/shape; use sharedElement for same-size content
KSP-Generated Code
Approach 1 only. After running ./gradlew :shared:kspCommonMainKotlinMetadata, NavEase writes five files:
AppScreens.kt
@Stable
@Serializable
sealed : {
Splash : AppScreens()
Home : AppScreens()
( libId: String, libName: String) : AppScreens()
{
startDestination: AppScreens () = Splash
savedStateConfig = SavedStateConfiguration { … }
}
}
NavEaseExtensions.kt
fun NavController.navigateToHome(finish: Boolean = false, navTransition: NavTransition? = null) { … }
fun NavController.navigateToLibraryDetail { … }
: LibraryDetailScreen.Args { … }
NavEaseResults.kt
data class LibraryDetailResult(val starred: Boolean)
fun NavController.backWithLibraryDetailResult(starred: Boolean) { … }
@Composable fun NavController.libraryDetailResult(): State<LibraryDetailResult?> = …
NavEaseHost.kt
@Composable
fun NavEaseHost(
onExitRequest: () -> Unit = {},
enableSharedTransitions: Boolean = false,
navTransition: NavTransition = NavTransition.Push,
) {
NavEaseNavGraph(
initialScreen = AppScreens.startDestination,
savedStateConfig = AppScreens.savedStateConfig,
screenFactory = ScreenFactory::createScreen,
onExitRequest = onExitRequest,
enableSharedTransitions = enableSharedTransitions,
navTransition = navTransition,
)
}
Module Structure
Sample App
The shared module contains a 10-screen demo app — the alims-repo library catalogue — showcasing Approach 1 (KSP annotations) with shared element transitions, typed arguments, typed results, and per-navigate transition overrides.
Navigation flow
SplashScreen ──(auto, finish=true)──▶ HomeScreen ──(shared bounds)──▶ LibraryDetailScreen
│
┌──────────────────────────────────┤
▼ │
NavEaseDemoScreen │
│ │
▼ │
TransitionPreviewScreen demo screens…
(SecureVaultDemo, FlowTabDemo,
PrayerTimesDemo, CrashGuardDemo,
PdfDemo)
Run on Android:
./gradlew :androidApp:installDebug
Run on Desktop:
./gradlew :desktopApp:run
FAQ
Q: Which approach should I choose?
| If you want… | Use |
|---|
| Maximum automation, typed extensions, no manual route management | Approach 1 — KSP Annotations |
| No annotation processing, instant IDE feedback, functional style | Approach 2 — navEaseGraph DSL |
| Class-based OOP, typed navKey in Content(), no code generation |
All three co-exist — you can mix them or progressively migrate.
Q: Do I need to manually register screens anywhere?
- Approach 1: No. Annotate with
@NavEaseScreen and rebuild.
- Approach 2: Register with
screen<K> { … } in the navEaseGraph block.
- Approach 3: Register with
add(ScreenInstance()) in the block.
Q: What happens when I add a new screen?
- Approach 1: Add
@NavEaseScreen, run ./gradlew :shared:kspCommonMainKotlinMetadata.
- Approach 2/3: Add a subclass to your sealed class, register it — no build step required.
Q: Can I have nested navigation (e.g. bottom tabs)?
Yes. Each NavEaseNavGraph / NavEaseHost call creates an independent NavController and back stack. Place multiple hosts side-by-side for parallel nav graphs.
Q: How do I handle Android back-press at the root?
NavEaseHost(onExitRequest = { finish() })
For a confirmation dialog, show it inside onExitRequest and only call finish() on confirm.
Q: How do I access NavController from a deeply nested composable?
@Composable
fun DeepNestedWidget() {
val navController = LocalNavEaseController.current ?: return
Button(onClick = { navController.back() }) { Text("Back") }
}
Q: What argument types can I use in @NavEaseArgs (Approach 1)?
Primitives (String, Int, Long, Boolean, Double, ) work automatically. Custom types must be .
Q: Can NavEase be used without KSP (Approaches 2 and 3)?
Yes — only navease-runtime is required. No KSP plugin, no annotation processor dependency.
Q: Are shared element transitions required?
No. enableSharedTransitions defaults to false. When false, LocalNavEaseSharedTransitionScope returns null — zero overhead.
Q: Do shared element transitions work on iOS/Desktop/Web?
Yes. SharedTransitionLayout is part of Compose Multiplatform and works on all supported platforms.
Q: How does back-with-result work internally?
Results are stored in a SnapshotStateMap inside NavController. Writing a result triggers recomposition. resultOf<T>() reads the map reactively via derivedStateOf and removes the entry via SideEffect in the same frame — no one-frame null window.
Q: Can I mix approaches in the same project?
Yes. Approach 1 uses KSP-generated NavEaseHost() with AppScreens. Approaches 2 and 3 use their own sealed classes and NavEaseHost overloads. Each approach gets its own independent NavController instance.
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: