Skip to content

Repository files navigation

Gezgin

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.

License Kotlin Compose Multiplatform Status

🇹🇷 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.


The 10-second example

// 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 errorCheckoutRoute 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.


Why we needed Gezgin

Compose navigation today usually means one of:

  • Stringly-typed routesnavController.navigate("product/$id") — where a typo or a wrong argument type is a runtime "route not found", not a compile error.
  • A single global controller you can call from anywhere, so "which screens can this screen reach?" is unanswerable without reading the entire graph.
  • Manual plumbing to pass a result back, and manual SavedStateHandle work to survive process death — each an easy place to get subtly wrong.
  • Navigation intent scattered across composables and view-models instead of declared in one place you can read.

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.


Why Gezgin

  • No string routes. The graph is a sealed interface tree; a destination is a type. Namespaced, @Serializable → process-death-safe and multiplatform-serializable for free.
  • Navigating to an undeclared destination doesn't compile. Each route gets a typed navigator with methods only for the edges you declared.
  • The whole vocabulary is declarative. Forward (@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.
  • Results are type-safe and process-death-safe. @GoForResult generates launchX() + a re-attach xResults: Flow<NavResult<T>> that survives a real process kill.
  • Modals are first-class back-stack entries. @Dialog / @BottomSheet / @FullscreenModal are the same entries with a different render — no separate dialog state to hand-manage.
  • State-as-data. backStack: StateFlow, events: Flow — observe it, log it, restore it, and test navigation without a UI (GezginTestNavigator).
  • DI-agnostic. Hilt / Koin / manual — Gezgin never forces a DI framework. Optional gezgin-mvi add-on for MVI screens; @FragmentScreen for brownfield Fragment interop.
  • Boilerplate is generated. Graph wiring, the result channel, entry registration — all KSP.

How it compares

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 ⚠️ alpha ✅ 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 Throwable serialization, 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.


Installation

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.

KSP options

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.

Examples, in order

1 · The graph is a sealed route tree

@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.

2 · ⭐ Navigating to an undeclared destination doesn't compile

@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.

3 · Forward & back are a declarative vocabulary

@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

4 · Sub-flows that return a typed, PD-safe 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.

5 · Modals are back-stack entries, not special state

@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.None

Default 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.

6 · Host wiring

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.

7 · State-as-data → observe, restore, test without a UI

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.

Strict MVI add-on

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.

Fragment interop

@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.


Learn more

License

Apache License 2.0 — see LICENSE. Copyright 2026 Gezgin contributors.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages