Nav3Router
1.1.0indexedLibrary simplifies navigation management with a clean, decoupled API, supporting type-safe commands, lifecycle awareness, and easy testability through a command queue and architectural components.
Library simplifies navigation management with a clean, decoupled API, supporting type-safe commands, lifecycle awareness, and easy testability through a command queue and architectural components.
A simple yet powerful Kotlin Multiplatform navigation library built on top of Jetpack Navigation 3. Provides a clean, decoupled API for managing navigation state from your shared business logic (ViewModels, Presenters, etc.).
@SerializableThe library is built around three core architectural components:
┌─────────┐ ┌──────────────┐ ┌───────────┐
│ Router │ ───► │ CommandQueue │ ───► │ Navigator │
└─────────┘ └──────────────┘ └───────────┘
▲ │ │
│ │ ▼
ViewModel/BL Buffers & Queues Jetpack Nav3
BackStack
Router - High-level, platform-agnostic API for navigation. Use it from ViewModels or business logic to issue commands like push, pop, and replaceStackThis architecture ensures:
Add the dependency to your build.gradle.kts:
// For shared module in KMP project
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.arttttt.nav3router:nav3router:latest") // Check latest version
}
commonTest.dependencies {
// Optional: test-support helpers (bindForTest, RecordingNavigator)
implementation("io.github.arttttt.nav3router:nav3router-test:latest")
}
}
}
// For Android-only project
dependencies {
implementation("io.github.arttttt.nav3router:nav3router:latest")
testImplementation("io.github.arttttt.nav3router:nav3router-test:latest") // optional
}
import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable
@Serializable
sealed interface Screen : NavKey {
@Serializable
data object Home : Screen
@Serializable
data class ( itemId: String) : Screen
Settings : Screen
}
@Composable
fun App() {
val backStack = rememberNavBackStack(Screen.Home)
Nav3Host(
backStack = backStack
) { backStack, onBack, router -> // router provided by Nav3Host
NavDisplay(
backStack = backStack,
onBack = onBack,
entryProvider = entryProvider {
entry<Screen.Home> {
HomeScreen(
onNavigateToDetails = { itemId ->
router.push(Screen.Details(itemId))
}
)
}
entry<Screen.Details> { screen ->
DetailsScreen(
itemId = screen.itemId,
onBack = { router.pop() }
)
}
entry<Screen.Settings> {
SettingsScreen()
}
}
)
}
}
{
router: Router<Screen> = koinInject()
backStack = rememberNavBackStack(Screen.Home)
Nav3Host(
backStack = backStack,
router = router
) { backStack, onBack, _ ->
NavDisplay(
backStack = backStack,
onBack = onBack,
entryProvider =
)
}
}
(
router: Router<Screen>
) : ViewModel() {
{
router.push(Screen.Details(itemId))
}
{
router.push(Screen.Settings)
}
}
// Push single screen
router.push(Screen.Details("item-123"))
// Push multiple screens at once
router.push(
Screen.Details("item-1"),
Screen.Details("item-2"),
Screen.Settings
)
// Replace current screen
router.replaceCurrent(Screen.Home)
// Navigate back
router.pop()
// Navigate back to specific screen
router.popTo(Screen.Home)
// Replace entire stack (useful for login/logout flows)
router.replaceStack(Screen.Login)
// Clear to root (useful for "Home" button)
router.clearStack()
// Make current screen the only one and exit
router.dropStack()
Open a screen and get a typed value back. Results are addressed by their type and travel through
a saveable store, so your screens stay clean — no result fields or marker interfaces on any NavKey.
The result type just needs to be @Serializable:
@Serializable
data class SelectedColor(val argb: Long)
From the screen that produces the result, call popWithResult (return the value and pop) or
sendResult (return it without popping — e.g. a "decider" screen that forwards a result):
entry<Screen.ColorPicker> {
ColorPickerScreen(
onPick = { argb -> router.popWithResult(SelectedColor(argb)) },
)
}
pushForResult — one call, ergonomicOpen a screen and handle its result in a single call:
@OptIn(EphemeralResultApi::class)
router.pushForResult<SelectedColor>(Screen.ColorPicker) { selected ->
// handle the result
}
pushForResult(andopenForResult/resultFlowbelow) are marked@EphemeralResultApi: the callback/continuation is captured at the call site, so they survive configuration change in a retained scope but not process death. For full durability useregisterForResult.
registerForResult — the durable coreA plain Router method that registers a typed handler and returns a ResultRegistration to
dispose. Invoke it from somewhere that re-runs on recreation (a ViewModel, or a
DisposableEffect); it re-attaches to the saveable store, so a pending result survives
configuration change and process death — no callback is ever serialized:
class HomeViewModel(
private val router: Router<Screen>,
) : ViewModel() {
private val pickColor = router.registerForResult<SelectedColor>(
onResult = { selected -> /* ... */ },
onCancelled = { /* screen left without a result */ },
)
fun pickColor() = router.push(Screen.ColorPicker)
override = pickColor.dispose()
}
In Compose:
DisposableEffect(router) {
val registration = router.registerForResult<SelectedColor> { selected -> /* ... */ }
onDispose { registration.dispose() }
}
openForResult / resultFlow — suspend & Flow sugar@OptIn(EphemeralResultApi::class)
val selected: SelectedColor? = router.openForResult { Screen.ColorPicker } // suspends until result or cancel
@OptIn(EphemeralResultApi::class)
router.resultFlow<SelectedColor>().collect { selected -> /* ... */ }
onCancelled (the screen was dismissed without producing a result) is precise with
pushForResult / openForResult; for a long-lived registerForResult it is best-effort.The separate nav3-router-test artifact lets you drive and assert navigation in plain unit tests —
no Compose, no Nav3Host. Keep navigation logic in a Router-driven class (a ViewModel/presenter),
then bind a real Router to a back stack with bindForTest and assert the resulting stack.
class HomeViewModel(private val router: Router<Screen>) {
fun openDetails(id: String) = router.push(Screen.Details(id))
}
= runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
router = Router<Screen>()
backStack = router.bindForTest(scope = backgroundScope)
HomeViewModel(router).openDetails()
advanceUntilIdle()
assertEquals(Screen.Details(), backStack.last())
Dispatchers.resetMain()
}
For command-level assertions (which command was emitted, independent of the resulting stack), bind a
RecordingNavigator instead:
val recorder = RecordingNavigator()
router.bindForTest(recorder)
viewModel.openDetails("42")
advanceUntilIdle()
assertEquals(listOf(Push(Screen.Details("42"))), recorder.commands)
To unit-test a screen that awaits a result, drive the producer side from the test:
val router = Router<Screen>()
router.bindForTest(scope = backgroundScope)
var picked: Color? = null
viewModel.pickColor { picked = it } // opens the picker for a result
advanceUntilIdle()
router.popWithResult(Color.Red) // the picker returns
advanceUntilIdle()
assertEquals(Color.Red, picked)
CommandQueue - Acts as a buffer, decoupling Router from Navigator. Queues commands when UI isn't ready (e.g., during configuration changes) and ensures main thread executionNavigator - Platform-specific implementation that executes commands. Translates abstract commands into direct manipulations of Navigation 3's NavBackStack| Method | Description |
|---|
push(vararg screens) | Pushes one or more screens onto the stack |
pop() | Removes the top screen. Triggers system back if it's the last screen |
replaceCurrent(screen) | Replaces the current top screen with a new one |
replaceStack(vararg screens) | Replaces the entire navigation stack with new screens |
popTo(screen) | Navigates back to a specific screen, removing all screens above it |
clearStack() | Removes all screens except the root |
dropStack() | Keeps only the current screen, then triggers system back |
| Method | Side | Description |
|---|
popWithResult(value) | producer | Returns value to the caller, then pops |
sendResult(value) | producer | Returns value without popping (forwarding) |
registerForResult { result -> } | consumer | Durable, type-keyed handler; returns a ResultRegistration |
pushForResult(screen) { result -> } | consumer | One-call ergonomic form (ephemeral) |
openForResult { screen } | consumer | suspend, returns the result or null (ephemeral) |
resultFlow() | consumer | Cold Flow of results (ephemeral) |
Surfaced from shared tags and platforms — no rankings paid for.