Nav3 Router

repository·master·Indexed 40 days ago

https://github.com/arttttt/nav3router

A Kotlin Multiplatform navigation library built on top of Jetpack Navigation 3. It provides a decoupled, type-safe API to manage navigation state from shared business logic, such as ViewModels, independent of the UI layer. Features include a CommandQueue for buffering navigation commands, support for typed results that survive process death, and a dedicated nav3-router-test artifact for unit testing navigation logic.

Tokens
2.8K
Snippets
8
Records
10
Agent score
0%

What's inside Nav3 Router

  1. How Nav3 Router architecture works

    master

    Nav3 Router uses a decoupled architecture to separate navigation logic from the UI. This allows you to issue navigation commands from ViewModels or business logic even before the UI is ready.

    • Router: The high-level, platform-agnostic API used by your business logic (e.g., ViewModels) to issue commands like push or pop.
    • CommandQueue: A buffer that decouples the Router from the Navigator. It queues commands during configuration changes or when the UI is unavailable and ensures execution on the main thread.
    • Navigator: The platform-specific implementation that translates abstract commands into direct manipulations of the Jetpack Navigation 3 NavBackStack.
  2. Define your screens using NavKey

    master

    To use Nav3 Router, define your screens as a @Serializable sealed interface that implements NavKey. This provides full type safety for your navigation graph.

    import androidx.navigation3.runtime.NavKey
    import kotlinx.serialization.Serializable
    
    @Serializable
    sealed interface Screen : NavKey {
        @Serializable
        data object Home : Screen
        
        @Serializable
        data class Details(val itemId: String) : Screen
        
        @Serializable
        data object Settings : Screen
    }
  3. Set up Nav3Router in Compose

    master

    You can set up navigation in two ways:

    Option A: Simple Setup

    Use rememberNavBackStack and Nav3Host. The router is provided automatically within the Nav3Host lambda.

    Inject a Router<Screen> instance from your DI container (like Koin or Hilt) and pass it to Nav3Host. This allows your ViewModels to use the same router instance.

    @Composable
    fun App() {
        // Option B: Using DI
        val router: Router<Screen> = koinInject()
        val backStack = rememberNavBackStack(Screen.Home)
        
        Nav3Host(
            backStack = backStack,
            router = router
        ) { backStack, onBack, _ ->
            NavDisplay(
                backStack = backStack,
                onBack = onBack,
                entryProvider = entryProvider {
                    entry<Screen.Home> {
                        HomeScreen(onNavigateToDetails = { id -> router.push(Screen.Details(id)) })
                    }
                    // ... other entries
                }
            )
        }
    }
  4. Install Nav3 Router

    master

    Add the dependency to your build.gradle.kts file. For Kotlin Multiplatform (KMP) projects, add it to commonMain. For Android-only projects, add it to the standard dependencies block. An optional test-support artifact is available for unit testing navigation logic.

    // For shared module in KMP project
    kotlin {
        sourceSets {
            commonMain.dependencies {
                implementation("io.github.arttttt.nav3router:nav3router:latest")
            }
            commonTest.dependencies {
                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")
    }
  5. Unit testing navigation with nav3-router-test

    master

    Use the nav3-router-test artifact to test navigation logic in plain unit tests without Compose or Nav3Host.

    Testing Stack State

    Use router.bindForTest(scope) to get an observable back stack. You can then assert against the contents of the list.

    Testing Command Emission

    Use RecordingNavigator with router.bindForTest(recorder) to assert that specific commands (like Push, Pop, etc.) were emitted in the correct order.

    Testing Result Handling

    To test a screen awaiting a result, drive the producer side within your test using router.popWithResult(value).

    @Test
    fun `opens details`() = runTest {
        Dispatchers.setMain(StandardTestDispatcher(testScheduler))
    
        val router = Router<Screen>()
        val backStack = router.bindForTest(scope = backgroundScope)
    
        HomeViewModel(router).openDetails("42")
        advanceUntilIdle()
    
        assertEquals(Screen.Details("42"), backStack.last())
    
        Dispatchers.resetMain()
    }
  6. Navigate for Result

    master

    Nav3 Router allows passing typed results back to a caller. Results are addressed by their type and are stored in a saveable store, making them durable across configuration changes and process death.

    1. Define a Serializable Result Type

    @Serializable
    data class SelectedColor(val argb: Long)

    2. Producing a Result

    From the child screen, use popWithResult(value) to return a value and pop, or sendResult(value) to return a value without popping.

    // Inside a screen entry
    ColorPickerScreen(onPick = { argb -> router.popWithResult(SelectedColor(argb)) })

    3. Consuming a Result

    Use registerForResult<T> in a ViewModel or DisposableEffect. This is the only method that survives process death because it re-attaches to the saveable store.

    class HomeViewModel(private val router: Router<Screen>) : ViewModel() {
        private val pickColor = router.registerForResult<SelectedColor>(
            onResult = { selected -> /* handle result */ },
            onCancelled = { /* handle cancel */ }
        )
    
        fun pickColor() = router.push(Screen.ColorPicker)
    
        override fun onCleared() = pickColor.dispose()
    }

    Ephemeral Consumption

    The following methods are convenient but do not survive process death (they are captured at the call site):

    • pushForResult<T>(screen) { result -> ... }: One-call ergonomic form.
    • openForResult<T> { screen }: A suspend function that returns the result or null.
    • resultFlow<T>(): A cold Flow of results.
    // Ephemeral example
    @OptIn(EphemeralResultApi::class)
    val selected: SelectedColor? = router.openForResult { Screen.ColorPicker }
  7. Manage navigation command buffering with CommandQueue

    master

    The CommandQueue<T> class manages the queuing and execution of navigation commands. It is designed to solve timing issues where navigation commands are issued before the navigation system (the Navigator) is ready (e.g., during configuration changes).

    Key Behaviors:

    • Buffering: If no Navigator is currently registered, commands passed to executeCommands are stored in a pending queue.
    • Automatic Execution: As soon as a Navigator is registered via setNavigator, all queued commands are immediately executed in the order they were received.
    • Thread Safety: All command execution is automatically dispatched to the Main thread using a CoroutineScope, making executeCommands safe to call from any thread.
    • Lifecycle: You can stop command execution by calling removeNavigator(), which causes subsequent commands to be queued until a new navigator is provided.
    // Example of using CommandQueue to buffer commands until a navigator is ready
    val commandQueue = CommandQueue<MyScreen>()
    
    // This command will be queued because no navigator is set yet
    commandQueue.executeCommands(arrayOf(NavigateToCommand(MyScreen.Home)))
    
    // Later, when the navigator is initialized...
    commandQueue.setNavigator(myNavigatorInstance)
    // The Home navigation command is now automatically executed on the Main thread.
  8. Router API reference

    master

    The Router interface provides the following methods for managing the navigation stack:

    MethodDescription
    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
  9. Use CommandQueue.executeCommands to issue navigation commands

    master

    Use executeCommands(commands: Array<out Command<T>>) to trigger navigation actions. This method is thread-safe and will either apply the commands to the current Navigator immediately or buffer them if no navigator is available. Execution always occurs on the Dispatchers.Main thread.

    commandQueue.executeCommands(arrayOf(command1, command2))
  10. Register or remove a navigator in CommandQueue

    master

    The CommandQueue implements NavigatorHolder<T>, allowing you to manage the active Navigator instance:

    • setNavigator(navigator: Navigator<T>): Registers a new navigator. This triggers the immediate execution of all currently pending commands in the queue.
    • removeNavigator(): Clears the current navigator. Any commands issued after this call will be queued until setNavigator is called again.
    // Registering a navigator triggers pending commands
    commandQueue.setNavigator(myNavigator)
    
    // Removing the navigator causes new commands to be buffered
    commandQueue.removeNavigator()