
Type-safe, annotation-driven navigation where destinations are types, not strings; compile-time per-screen navigators, serializable observable back stack, typed results and modal entries via codegen.
Type-safe, annotation-driven navigation for Compose Multiplatform. The destination is a type, not a string — and navigating somewhere you didn't declare doesn't compile.
🇹🇷 Türkçe: Bu README'nin Türkçesi için → README.tr.md
Gezgin runs on Navigation 3. Your navigation graph is a sealed interface tree, and a KSP processor generates a typed, per-screen navigator for every route — so a screen can only navigate along the edges you actually declared. The back stack is observable, serializable data, so process-death restore, logging, UI-less testing, and MVI fall out almost for free.
// 1 · the graph is a sealed tree — the edge you declare is the method you get
@NavGraph
@Serializable
sealed interface ShopGraph {
@GoTo(ProductRoute::class) // Catalog → Product
@Serializable data object CatalogRoute : ShopGraph
@GoTo(PaymentResult::class) // Product → PaymentResult ("buy now")
@Serializable data class ProductRoute(val id: String) : ShopGraph
// On success, REPLACE the checkout screen and wipe the shopping funnel up to & including Catalog,
// so system/predictive Back can't drop the user back into the flow they just finished:
@ReplaceTo(PaymentResult::class, clearUpTo = CatalogRoute::class, inclusive = true)
@Serializable data object CheckoutRoute : ShopGraph
@Serializable data object PaymentResult : ShopGraph // terminal — reached, never navigated *away* into the funnel
}
// 2 · the screen's typed navigator has methods ONLY for Catalog's declared edges
@Screen(CatalogRoute::class)
@Composable
fun CatalogScreen(nav: CatalogNavigator) {
ProductGrid(onClick = { product -> nav.goToProduct(product.id) }) // ✅ auto-generated by @GoTo(ProductRoute)
// nav.goToCheckout() // ❌ WON'T COMPILE — Catalog declared no edge to Checkout
}nav.goToProduct(id) exists because CatalogRoute declared @GoTo(ProductRoute::class). nav.goToCheckout() is a compile error — CheckoutRoute is a perfectly valid route, it's just not reachable from Catalog. The answer to "where can I go from here?" lives in IDE autocomplete — enforced by the shape of the API, not a lint rule you can forget.
Compose navigation today usually means one of:
navController.navigate("product/$id") — where a typo or a wrong argument type is a runtime "route not found", not a compile error.SavedStateHandle work to survive process death — each an easy place to get subtly wrong.We wanted the graph to be data you read at a glance, the reachable destinations to be enforced by the compiler, and results / process-death / testing to be the default, not extra work.
sealed interface tree; a destination is a type. Namespaced, @Serializable → process-death-safe and multiplatform-serializable for free.@GoTo / @ReplaceTo), backward (back() / @BackTo / @BackToStart / @NoBack), and multi-screen sub-flows with a typed result (@FlowGraph / ResultFlow + @GoForResult) — behavior lives in annotations, resolved at compile time, no runtime lambdas.@GoForResult generates launchX() + a re-attach xResults: Flow<NavResult<T>> that survives a real process kill.@Dialog / @BottomSheet / @FullscreenModal are the same entries with a different render — no separate dialog state to hand-manage.backStack: StateFlow, events: Flow — observe it, log it, restore it, and test navigation without a UI (GezginTestNavigator).gezgin-mvi add-on for MVI screens; @FragmentScreen for brownfield Fragment interop.A good-faith summary (as of 2026; libraries evolve — corrections welcome). Legend: ✅ first-class · ◑ possible / partial / manual · ❌ not really.
| Feature | Gezgin | Jetpack Navigation Compose | Compose Destinations | Voyager | Decompose |
|---|---|---|---|---|---|
| Compile-time type-safe destinations | ✅ | ✅ (2.8+) | ✅ (KSP) | ✅ | ✅ |
| Rejects navigating to an undeclared edge (per-source navigator) | ✅ | ❌ | ❌ | ❌ | ◑ |
Declarative forward/back behavior (@ReplaceTo/@BackTo/@NoBack) |
✅ | ◑ (manual popUpTo)
|
◑ | ◑ | ◑ |
| Type-safe result passing | ✅ | ◑ (SavedStateHandle)
|
✅ | ✅ | ✅ |
| Result survives process death | ✅ | ◑ (manual) | ◑ | ◑ | ✅ |
| Multi-screen sub-flows returning a typed result | ✅ | ❌ | ❌ | ◑ | ◑ |
| Dialog / sheet / fullscreen as back-stack entries | ✅ | ◑ (dialog) | ◑ | ◑ | ◑ |
| State-as-data (observable + serializable back stack) | ✅ | ◑ | ◑ | ◑ | ✅ |
| UI-less testing of navigation | ✅ | ◑ | ◑ | ◑ | ✅ |
| No manual graph wiring (codegen) | ✅ | ◑ | ✅ | ❌ | ❌ |
| Compose Multiplatform | ◑ (Android + desktop; iOS/web compile-level) | ◑ | ◑ | ✅ | ✅ |
| Brownfield Fragment interop | ✅ | ✅ | ❌ | ❌ | ❌ |
| Multiple back stacks (bottom-nav tabs, master/detail) | ❌ (V2) | ✅ | ✅ | ✅ | ✅ |
| Deep links | ❌ (V2) | ✅ | ✅ | ◑ | ◑ |
| Maturity | ✅ stable | ✅ | ✅ | ✅ |
Gezgin's niche: the per-source compile-time restriction, plus integrated flows-with-result, modals-as-entries, and PD-safe-by-default — all on Navigation 3. If you already like Jetpack Nav's new type-safe routes but want the compiler to also reject undeclared edges and hand you results / flows / modals / process-death out of the box, that's the gap Gezgin fills.
🔮 Honest gaps — deliberately out of this artifact, on the V2 roadmap: multiple back stacks and deep-link route dispatch. Gezgin is single-stack and does not expose or generate a URL↔route dispatch contract in this release. Generic
Throwableserialization, permanent screen-container/chrome APIs, and Fragment modal interop are also outside this artifact. Gezgin is alpha; its Android Navigation 3 family is stable while the desktop JetBrains port remains alpha.
Apply the KSP + serialization plugins and use the Maven Central coordinates (group = io.github.sahsenvar, version = 0.2.0):
plugins {
id("com.google.devtools.ksp")
kotlin("plugin.serialization")
}
dependencies {
implementation("io.github.sahsenvar:gezgin-core:0.2.0")
ksp("io.github.sahsenvar:gezgin-processor:0.2.0")
// implementation("io.github.sahsenvar:gezgin-mvi:0.2.0") // optional MVI add-on
// testImplementation("io.github.sahsenvar:gezgin-test:0.2.0") // UI-less testing: GezginTestNavigator + typed fromX()
}| Module | Role |
|---|---|
gezgin-core |
Required. Annotations, runtime, GezginDisplay (the Compose layer), modal scene strategies. DI-agnostic. |
gezgin-processor |
Required. The KSP2 processor that generates the typed navigators + entry providers. |
gezgin-mvi |
Optional. @MviViewModel / route-bound @EffectHandler + GezginMvi<S, I, E> + DI-detection (Hilt/Koin, androidx fallback). |
gezgin-test |
Optional (test). UI-less GezginTestNavigator with typed fromX() accessors. |
The two build boundaries are intentionally separate:
| Boundary | Verified versions |
|---|---|
| Gezgin root | Gradle 9.0.0, Kotlin 2.3.21, KSP 2.3.9, AGP 8.13.2, Compose Multiplatform 1.11.0; AndroidX Navigation 3 1.0.0 + lifecycle Navigation 3 2.10.0 on Android; JetBrains Navigation 3 1.0.0-alpha05 + lifecycle Navigation 3 2.10.0-alpha05 on desktop; min SDK 24. |
| Independent ZAD-shaped consumer | Its own Gradle 9.4.1 wrapper, Kotlin 2.3.21, KSP 2.3.9, AGP 9.2.1, JDK/JVM 21, compile/target SDK 37, Koin 4.2.2 + compiler plugin 1.0.1, AndroidX Navigation 3 1.0.0 + lifecycle Navigation 3 2.10.0. It resolves all four Gezgin artifacts from one exclusive repository (Maven Central in release smoke) and does not use a composite/source substitution or Maven Local fallback. |
These are different build roles, not interchangeable upgrade instructions. Full contracts: docs/gezgin-design.md §15.
Set via ksp { arg("<name>", "<value>") }:
| Option | Default | When to change |
|---|---|---|
gezgin.emitSerializers |
true |
Set false to opt out if you register the polymorphic Route SerializersModule yourself. |
gezgin.emitTestAccessors |
false |
Set true to generate the typed GezginTestNavigator.fromX() test accessors. Enable it in the module's main KSP round (where the graphs live); the accessors are generated into main, so the test source set can call nav.fromX() directly — works across modules. Add :gezgin-test as compileOnly on the main classpath (so the accessors compile; it never leaks into the app runtime) and re-add it as testImplementation for tests. |
@NavGraph
@Serializable
sealed interface HomeGraph {
@Serializable
data object FeedRoute : HomeGraph // the app-start route (given to the host)
@GoTo(ProductRoute::class)
@Serializable
data object CatalogRoute : HomeGraph
@Serializable
data class ProductRoute(val id: String) : HomeGraph // a route is data
}Membership comes from the declared supertype, not from lexical nesting — so a large graph can be split across files (one flow per file) without a 1000-line graph file.
@Screen(CatalogRoute::class)
@Composable
fun CatalogScreen(nav: CatalogNavigator) { // typed navigator generated from CatalogRoute's edges
Button(onClick = { nav.goToProduct(id = "sku-42") }) { Text("Open product") } // ✅
// nav.goToFeed() // ❌ compile error — no edge declared from Catalog to Feed
}The classic alternative — navController.navigate("product/$id") — fails at runtime on a typo. Here a wrong target or a wrong argument type is a compile error.
@ReplaceTo(OrderPlacedRoute::class) // clear the checkout flow so Back can't return to the form
@Serializable
data class PaymentRoute(val cartId: String) : CartGraph
// → nav.replaceToOrderPlaced(orderId)
@NoBack // terminal screen: system/predictive Back is a no-op here
@Serializable
data class OrderPlacedRoute(val orderId: String) : CartGraph| Annotation | Generated | Behavior |
|---|---|---|
@GoTo(X::class) |
nav.goToX(params) |
push (single-top by value) |
@ReplaceTo(X::class, clearUpTo = …) |
nav.replaceToX(params) |
clear up to a route, then push |
@BackTo(X::class) |
nav.backToX() |
pop to a specific ancestor |
@BackToStart |
nav.backToStart() |
back to a flow's start |
@NoBack |
— | close implicit/system Back for a terminal screen |
| — |
nav.back() / nav.backWithResult(r)
|
one step back / return a typed result |
@FlowGraph
@Serializable
sealed interface CheckoutFlow : ShopGraph, ResultFlow<OrderId> { // the whole flow returns an OrderId
@StartDestination @Serializable data object CartRoute : CheckoutFlow
// … PaymentRoute … ; nav.quitWith(OrderId(...)) finishes the flow and delivers the result
}
// The caller declares the result edge; its route-bound handler launches and collects it:
@GoForResult(CheckoutFlow::class)
@Serializable data object CatalogRoute : HomeGraph
// → nav.launchCheckout() + nav.checkoutResults: Flow<NavResult<OrderId>>In the maintained strict-MVI pattern, the route-bound @EffectHandler owns the generated navigator: it calls launchX(), collects xResults in LaunchedEffect, and forwards each NavResult into the VM as a typed Intent. After restore, re-composition of that caller route/handler re-attaches the collector, while the navigator's saved result-bus slot preserves the in-flight or delivered-but-unconsumed result. Keep the navigator out of the VM; suspend goToXForResult() is process-lifetime convenience, not the PD-safe strict-MVI ownership model.
@Dialog(ConfirmRoute::class) // also @BottomSheet, @FullscreenModal
@Composable
fun ConfirmDialog(route: ConfirmRoute, nav: ConfirmNavigator) {
Button(onClick = { nav.backWithResult(true) }) { Text("Yes") }
}A dialog / sheet / fullscreen modal is the same entry as a screen with a different render — it's on the back stack, it survives process death, and it can return a result exactly like any other route.
Sheets expose three independent dismissal switches. A route that must not be dismissed by the user disables all three; sheetGesturesEnabled defaults to true for source compatibility:
@Serializable
data object LockedSheetRoute : ShopGraph, BottomSheetContract {
override val dismissOnBackPress: Boolean get() = false
override val dismissOnClickOutside: Boolean get() = false
override val sheetGesturesEnabled: Boolean get() = false
}During the ZAD migration, a sheet may temporarily suppress Material's default handle without passing a composable through its route:
override val dragHandleMode: BottomSheetDragHandleMode
get() = BottomSheetDragHandleMode.NoneDefault preserves Material's handle; None passes dragHandle = null, leaving any custom handle in consumer-owned sheet content. This enum and BottomSheetContract.dragHandleMode require @OptIn(ExperimentalGezginMigrationApi::class). They are migration bridges, not the permanent presentation-slot API, and may be deprecated, replaced, or removed by the V2 design.
setContent {
val navigator = rememberNavigator(
start = FeedRoute,
topology = gezginTopology, // generated into the graph package
json = gezginJson, // generated: a process-wide stable Json
restoreKey = "$sessionGeneration:$appMode",
onRootBack = { finish() },
)
GezginDisplay(navigator = navigator) {
homeGraphEntries() // you assemble the generated provideXEntry() calls
}
}restoreKey namespaces both the saved snapshot and Android navigator-holder identity. Recreating with the same non-blank key restores the same stack; changing the key creates a fresh navigator at start. The original overload remains source-compatible and uses a stable legacy namespace, but session/account/mode-aware apps should pass an explicit persistent key.
navigator.backStack // StateFlow<List<Route>> — observe / log
navigator.events // Flow<NavEvent> — analytics / devtools
// UI-less test (gezgin-test): drive typed navigation and assert on the back stack, no Compose needed.
val nav = GezginTestNavigator(start = CatalogRoute, topology = gezginTopology)
nav.fromCatalog().goToProduct("sku-42")
assertEquals(listOf(CatalogRoute, ProductRoute("sku-42")), nav.backStack)Because the back stack is @Serializable data, process-death restore is automatic; a corrupted / incompatible snapshot falls back to a fresh start instead of crash-looping.
Maintained MVI examples use one direction only:
intent -> onIntent -> effect -> @EffectHandler(route) -> typed navigator
The ViewModel owns state and emits effects; it does not hold a navigator. The route-bound handler observes the effect and owns the typed navigation call:
@Screen(HomeRoute::class)
@Screen(FeaturedRoute::class)
@Composable
fun ColumnScope.SharedContent(
state: SharedState,
onIntent: (SharedIntent) -> Unit,
) { /* render state; emit intents */ }
@MviViewModel(HomeRoute::class)
class HomeViewModel : ViewModel(), GezginMvi<SharedState, SharedIntent, HomeEffect> {
private val effectSink = GezginEffects<HomeEffect>()
override val effects: Flow<HomeEffect> = effectSink.flow
// uiState omitted
override fun onIntent(intent: SharedIntent) {
if (intent == SharedIntent.OpenNext) effectSink.send(HomeEffect.OpenFeatured)
}
}
@EffectHandler(HomeRoute::class)
@Composable
fun HomeEffectHandler(effects: Flow<HomeEffect>, nav: HomeNavigator) {
ObserveEffects(effects) { effect ->
if (effect == HomeEffect.OpenFeatured) nav.goToFeatured()
}
}@Screen is repeatable. Every bound route has its own @MviViewModel(route) and route-bound handler. A shared content function must use State and Intent types compatible with every route; Effect and typed Navigator types may differ per route.
@TopBar(route) and @BottomBar(route) are repeatable, migration-only gezgin-mvi APIs guarded by @ExperimentalGezginMigrationApi. Generated content is an outer Column, then top bar, a Column(Modifier.fillMaxWidth().weight(1f)) preserving the content's ColumnScope, and the bottom bar only while the IME is hidden. They exist only to preserve the current ZAD screen shape and must be removed when the migration adopts its permanent app-owned container. Consumers must declare @OptIn(ExperimentalGezginMigrationApi::class) explicitly.
@FragmentScreen hosts a legacy View-based Fragment as a screen entry only, injecting the route (gezginArgs) and typed navigator (gezginNav). Apps using it must call Gezgin.initFragmentInterop(gezginJson) in Application.onCreate() before Fragment restoration. Real screen restoration after process death is already supported; this artifact adds no DialogFragment or BottomSheetDialogFragment bridge.
Apache License 2.0 — see LICENSE. Copyright 2026 Gezgin contributors.
Type-safe, annotation-driven navigation for Compose Multiplatform. The destination is a type, not a string — and navigating somewhere you didn't declare doesn't compile.
🇹🇷 Türkçe: Bu README'nin Türkçesi için → README.tr.md
Gezgin runs on Navigation 3. Your navigation graph is a sealed interface tree, and a KSP processor generates a typed, per-screen navigator for every route — so a screen can only navigate along the edges you actually declared. The back stack is observable, serializable data, so process-death restore, logging, UI-less testing, and MVI fall out almost for free.
// 1 · the graph is a sealed tree — the edge you declare is the method you get
@NavGraph
@Serializable
sealed interface ShopGraph {
@GoTo(ProductRoute::class) // Catalog → Product
@Serializable data object CatalogRoute : ShopGraph
@GoTo(PaymentResult::class) // Product → PaymentResult ("buy now")
@Serializable data class ProductRoute(val id: String) : ShopGraph
// On success, REPLACE the checkout screen and wipe the shopping funnel up to & including Catalog,
// so system/predictive Back can't drop the user back into the flow they just finished:
@ReplaceTo(PaymentResult::class, clearUpTo = CatalogRoute::class, inclusive = true)
@Serializable data object CheckoutRoute : ShopGraph
@Serializable data object PaymentResult : ShopGraph // terminal — reached, never navigated *away* into the funnel
}
// 2 · the screen's typed navigator has methods ONLY for Catalog's declared edges
@Screen(CatalogRoute::class)
@Composable
fun CatalogScreen(nav: CatalogNavigator) {
ProductGrid(onClick = { product -> nav.goToProduct(product.id) }) // ✅ auto-generated by @GoTo(ProductRoute)
// nav.goToCheckout() // ❌ WON'T COMPILE — Catalog declared no edge to Checkout
}nav.goToProduct(id) exists because CatalogRoute declared @GoTo(ProductRoute::class). nav.goToCheckout() is a compile error — CheckoutRoute is a perfectly valid route, it's just not reachable from Catalog. The answer to "where can I go from here?" lives in IDE autocomplete — enforced by the shape of the API, not a lint rule you can forget.
Compose navigation today usually means one of:
navController.navigate("product/$id") — where a typo or a wrong argument type is a runtime "route not found", not a compile error.SavedStateHandle work to survive process death — each an easy place to get subtly wrong.We wanted the graph to be data you read at a glance, the reachable destinations to be enforced by the compiler, and results / process-death / testing to be the default, not extra work.
sealed interface tree; a destination is a type. Namespaced, @Serializable → process-death-safe and multiplatform-serializable for free.@GoTo / @ReplaceTo), backward (back() / @BackTo / @BackToStart / @NoBack), and multi-screen sub-flows with a typed result (@FlowGraph / ResultFlow + @GoForResult) — behavior lives in annotations, resolved at compile time, no runtime lambdas.@GoForResult generates launchX() + a re-attach xResults: Flow<NavResult<T>> that survives a real process kill.@Dialog / @BottomSheet / @FullscreenModal are the same entries with a different render — no separate dialog state to hand-manage.backStack: StateFlow, events: Flow — observe it, log it, restore it, and test navigation without a UI (GezginTestNavigator).gezgin-mvi add-on for MVI screens; @FragmentScreen for brownfield Fragment interop.A good-faith summary (as of 2026; libraries evolve — corrections welcome). Legend: ✅ first-class · ◑ possible / partial / manual · ❌ not really.
| Feature | Gezgin | Jetpack Navigation Compose | Compose Destinations | Voyager | Decompose |
|---|---|---|---|---|---|
| Compile-time type-safe destinations | ✅ | ✅ (2.8+) | ✅ (KSP) | ✅ | ✅ |
| Rejects navigating to an undeclared edge (per-source navigator) | ✅ | ❌ | ❌ | ❌ | ◑ |
Declarative forward/back behavior (@ReplaceTo/@BackTo/@NoBack) |
✅ | ◑ (manual popUpTo)
|
◑ | ◑ | ◑ |
| Type-safe result passing | ✅ | ◑ (SavedStateHandle)
|
✅ | ✅ | ✅ |
| Result survives process death | ✅ | ◑ (manual) | ◑ | ◑ | ✅ |
| Multi-screen sub-flows returning a typed result | ✅ | ❌ | ❌ | ◑ | ◑ |
| Dialog / sheet / fullscreen as back-stack entries | ✅ | ◑ (dialog) | ◑ | ◑ | ◑ |
| State-as-data (observable + serializable back stack) | ✅ | ◑ | ◑ | ◑ | ✅ |
| UI-less testing of navigation | ✅ | ◑ | ◑ | ◑ | ✅ |
| No manual graph wiring (codegen) | ✅ | ◑ | ✅ | ❌ | ❌ |
| Compose Multiplatform | ◑ (Android + desktop; iOS/web compile-level) | ◑ | ◑ | ✅ | ✅ |
| Brownfield Fragment interop | ✅ | ✅ | ❌ | ❌ | ❌ |
| Multiple back stacks (bottom-nav tabs, master/detail) | ❌ (V2) | ✅ | ✅ | ✅ | ✅ |
| Deep links | ❌ (V2) | ✅ | ✅ | ◑ | ◑ |
| Maturity | ✅ stable | ✅ | ✅ | ✅ |
Gezgin's niche: the per-source compile-time restriction, plus integrated flows-with-result, modals-as-entries, and PD-safe-by-default — all on Navigation 3. If you already like Jetpack Nav's new type-safe routes but want the compiler to also reject undeclared edges and hand you results / flows / modals / process-death out of the box, that's the gap Gezgin fills.
🔮 Honest gaps — deliberately out of this artifact, on the V2 roadmap: multiple back stacks and deep-link route dispatch. Gezgin is single-stack and does not expose or generate a URL↔route dispatch contract in this release. Generic
Throwableserialization, permanent screen-container/chrome APIs, and Fragment modal interop are also outside this artifact. Gezgin is alpha; its Android Navigation 3 family is stable while the desktop JetBrains port remains alpha.
Apply the KSP + serialization plugins and use the Maven Central coordinates (group = io.github.sahsenvar, version = 0.2.0):
plugins {
id("com.google.devtools.ksp")
kotlin("plugin.serialization")
}
dependencies {
implementation("io.github.sahsenvar:gezgin-core:0.2.0")
ksp("io.github.sahsenvar:gezgin-processor:0.2.0")
// implementation("io.github.sahsenvar:gezgin-mvi:0.2.0") // optional MVI add-on
// testImplementation("io.github.sahsenvar:gezgin-test:0.2.0") // UI-less testing: GezginTestNavigator + typed fromX()
}| Module | Role |
|---|---|
gezgin-core |
Required. Annotations, runtime, GezginDisplay (the Compose layer), modal scene strategies. DI-agnostic. |
gezgin-processor |
Required. The KSP2 processor that generates the typed navigators + entry providers. |
gezgin-mvi |
Optional. @MviViewModel / route-bound @EffectHandler + GezginMvi<S, I, E> + DI-detection (Hilt/Koin, androidx fallback). |
gezgin-test |
Optional (test). UI-less GezginTestNavigator with typed fromX() accessors. |
The two build boundaries are intentionally separate:
| Boundary | Verified versions |
|---|---|
| Gezgin root | Gradle 9.0.0, Kotlin 2.3.21, KSP 2.3.9, AGP 8.13.2, Compose Multiplatform 1.11.0; AndroidX Navigation 3 1.0.0 + lifecycle Navigation 3 2.10.0 on Android; JetBrains Navigation 3 1.0.0-alpha05 + lifecycle Navigation 3 2.10.0-alpha05 on desktop; min SDK 24. |
| Independent ZAD-shaped consumer | Its own Gradle 9.4.1 wrapper, Kotlin 2.3.21, KSP 2.3.9, AGP 9.2.1, JDK/JVM 21, compile/target SDK 37, Koin 4.2.2 + compiler plugin 1.0.1, AndroidX Navigation 3 1.0.0 + lifecycle Navigation 3 2.10.0. It resolves all four Gezgin artifacts from one exclusive repository (Maven Central in release smoke) and does not use a composite/source substitution or Maven Local fallback. |
These are different build roles, not interchangeable upgrade instructions. Full contracts: docs/gezgin-design.md §15.
Set via ksp { arg("<name>", "<value>") }:
| Option | Default | When to change |
|---|---|---|
gezgin.emitSerializers |
true |
Set false to opt out if you register the polymorphic Route SerializersModule yourself. |
gezgin.emitTestAccessors |
false |
Set true to generate the typed GezginTestNavigator.fromX() test accessors. Enable it in the module's main KSP round (where the graphs live); the accessors are generated into main, so the test source set can call nav.fromX() directly — works across modules. Add :gezgin-test as compileOnly on the main classpath (so the accessors compile; it never leaks into the app runtime) and re-add it as testImplementation for tests. |
@NavGraph
@Serializable
sealed interface HomeGraph {
@Serializable
data object FeedRoute : HomeGraph // the app-start route (given to the host)
@GoTo(ProductRoute::class)
@Serializable
data object CatalogRoute : HomeGraph
@Serializable
data class ProductRoute(val id: String) : HomeGraph // a route is data
}Membership comes from the declared supertype, not from lexical nesting — so a large graph can be split across files (one flow per file) without a 1000-line graph file.
@Screen(CatalogRoute::class)
@Composable
fun CatalogScreen(nav: CatalogNavigator) { // typed navigator generated from CatalogRoute's edges
Button(onClick = { nav.goToProduct(id = "sku-42") }) { Text("Open product") } // ✅
// nav.goToFeed() // ❌ compile error — no edge declared from Catalog to Feed
}The classic alternative — navController.navigate("product/$id") — fails at runtime on a typo. Here a wrong target or a wrong argument type is a compile error.
@ReplaceTo(OrderPlacedRoute::class) // clear the checkout flow so Back can't return to the form
@Serializable
data class PaymentRoute(val cartId: String) : CartGraph
// → nav.replaceToOrderPlaced(orderId)
@NoBack // terminal screen: system/predictive Back is a no-op here
@Serializable
data class OrderPlacedRoute(val orderId: String) : CartGraph| Annotation | Generated | Behavior |
|---|---|---|
@GoTo(X::class) |
nav.goToX(params) |
push (single-top by value) |
@ReplaceTo(X::class, clearUpTo = …) |
nav.replaceToX(params) |
clear up to a route, then push |
@BackTo(X::class) |
nav.backToX() |
pop to a specific ancestor |
@BackToStart |
nav.backToStart() |
back to a flow's start |
@NoBack |
— | close implicit/system Back for a terminal screen |
| — |
nav.back() / nav.backWithResult(r)
|
one step back / return a typed result |
@FlowGraph
@Serializable
sealed interface CheckoutFlow : ShopGraph, ResultFlow<OrderId> { // the whole flow returns an OrderId
@StartDestination @Serializable data object CartRoute : CheckoutFlow
// … PaymentRoute … ; nav.quitWith(OrderId(...)) finishes the flow and delivers the result
}
// The caller declares the result edge; its route-bound handler launches and collects it:
@GoForResult(CheckoutFlow::class)
@Serializable data object CatalogRoute : HomeGraph
// → nav.launchCheckout() + nav.checkoutResults: Flow<NavResult<OrderId>>In the maintained strict-MVI pattern, the route-bound @EffectHandler owns the generated navigator: it calls launchX(), collects xResults in LaunchedEffect, and forwards each NavResult into the VM as a typed Intent. After restore, re-composition of that caller route/handler re-attaches the collector, while the navigator's saved result-bus slot preserves the in-flight or delivered-but-unconsumed result. Keep the navigator out of the VM; suspend goToXForResult() is process-lifetime convenience, not the PD-safe strict-MVI ownership model.
@Dialog(ConfirmRoute::class) // also @BottomSheet, @FullscreenModal
@Composable
fun ConfirmDialog(route: ConfirmRoute, nav: ConfirmNavigator) {
Button(onClick = { nav.backWithResult(true) }) { Text("Yes") }
}A dialog / sheet / fullscreen modal is the same entry as a screen with a different render — it's on the back stack, it survives process death, and it can return a result exactly like any other route.
Sheets expose three independent dismissal switches. A route that must not be dismissed by the user disables all three; sheetGesturesEnabled defaults to true for source compatibility:
@Serializable
data object LockedSheetRoute : ShopGraph, BottomSheetContract {
override val dismissOnBackPress: Boolean get() = false
override val dismissOnClickOutside: Boolean get() = false
override val sheetGesturesEnabled: Boolean get() = false
}During the ZAD migration, a sheet may temporarily suppress Material's default handle without passing a composable through its route:
override val dragHandleMode: BottomSheetDragHandleMode
get() = BottomSheetDragHandleMode.NoneDefault preserves Material's handle; None passes dragHandle = null, leaving any custom handle in consumer-owned sheet content. This enum and BottomSheetContract.dragHandleMode require @OptIn(ExperimentalGezginMigrationApi::class). They are migration bridges, not the permanent presentation-slot API, and may be deprecated, replaced, or removed by the V2 design.
setContent {
val navigator = rememberNavigator(
start = FeedRoute,
topology = gezginTopology, // generated into the graph package
json = gezginJson, // generated: a process-wide stable Json
restoreKey = "$sessionGeneration:$appMode",
onRootBack = { finish() },
)
GezginDisplay(navigator = navigator) {
homeGraphEntries() // you assemble the generated provideXEntry() calls
}
}restoreKey namespaces both the saved snapshot and Android navigator-holder identity. Recreating with the same non-blank key restores the same stack; changing the key creates a fresh navigator at start. The original overload remains source-compatible and uses a stable legacy namespace, but session/account/mode-aware apps should pass an explicit persistent key.
navigator.backStack // StateFlow<List<Route>> — observe / log
navigator.events // Flow<NavEvent> — analytics / devtools
// UI-less test (gezgin-test): drive typed navigation and assert on the back stack, no Compose needed.
val nav = GezginTestNavigator(start = CatalogRoute, topology = gezginTopology)
nav.fromCatalog().goToProduct("sku-42")
assertEquals(listOf(CatalogRoute, ProductRoute("sku-42")), nav.backStack)Because the back stack is @Serializable data, process-death restore is automatic; a corrupted / incompatible snapshot falls back to a fresh start instead of crash-looping.
Maintained MVI examples use one direction only:
intent -> onIntent -> effect -> @EffectHandler(route) -> typed navigator
The ViewModel owns state and emits effects; it does not hold a navigator. The route-bound handler observes the effect and owns the typed navigation call:
@Screen(HomeRoute::class)
@Screen(FeaturedRoute::class)
@Composable
fun ColumnScope.SharedContent(
state: SharedState,
onIntent: (SharedIntent) -> Unit,
) { /* render state; emit intents */ }
@MviViewModel(HomeRoute::class)
class HomeViewModel : ViewModel(), GezginMvi<SharedState, SharedIntent, HomeEffect> {
private val effectSink = GezginEffects<HomeEffect>()
override val effects: Flow<HomeEffect> = effectSink.flow
// uiState omitted
override fun onIntent(intent: SharedIntent) {
if (intent == SharedIntent.OpenNext) effectSink.send(HomeEffect.OpenFeatured)
}
}
@EffectHandler(HomeRoute::class)
@Composable
fun HomeEffectHandler(effects: Flow<HomeEffect>, nav: HomeNavigator) {
ObserveEffects(effects) { effect ->
if (effect == HomeEffect.OpenFeatured) nav.goToFeatured()
}
}@Screen is repeatable. Every bound route has its own @MviViewModel(route) and route-bound handler. A shared content function must use State and Intent types compatible with every route; Effect and typed Navigator types may differ per route.
@TopBar(route) and @BottomBar(route) are repeatable, migration-only gezgin-mvi APIs guarded by @ExperimentalGezginMigrationApi. Generated content is an outer Column, then top bar, a Column(Modifier.fillMaxWidth().weight(1f)) preserving the content's ColumnScope, and the bottom bar only while the IME is hidden. They exist only to preserve the current ZAD screen shape and must be removed when the migration adopts its permanent app-owned container. Consumers must declare @OptIn(ExperimentalGezginMigrationApi::class) explicitly.
@FragmentScreen hosts a legacy View-based Fragment as a screen entry only, injecting the route (gezginArgs) and typed navigator (gezginNav). Apps using it must call Gezgin.initFragmentInterop(gezginJson) in Application.onCreate() before Fragment restoration. Real screen restoration after process death is already supported; this artifact adds no DialogFragment or BottomSheetDialogFragment bridge.
Apache License 2.0 — see LICENSE. Copyright 2026 Gezgin contributors.