Skip to content

TonyGnk/navigation3-isolated-stack-controller-demo

Repository files navigation

Navigation3 — BackStackController Demo

A focused Android demo that shows how to wrap Jetpack Navigation3 behind a hand-rolled BackStackController, wired together with Hilt for dependency injection and featuring deep link support.

The goal is not to replace Navigation3 — it is to show how to own the back stack as plain Kotlin state while keeping every screen and ViewModel completely unaware of the concrete navigation implementation.


Module structure

app/                        → Activity, Hilt wiring, NavigationView, StartupViewModel
core/
  navigation/               → BackStackController, NavigationHandler, NavMode,
                              NavigationResultBus, BackResultEffect, AppNavKey
feature/
  home/                     → HomeScreen + HomeViewModel
  featureA/                 → FeatureAScreen + FeatureAViewModel
  featureB/                 → FeatureBScreen + FeatureBViewModel

Dependency graph (arrows = "depends on"):

app  ──►  core:navigation
app  ──►  feature:home
app  ──►  feature:featureA
app  ──►  feature:featureB

feature:home     ──►  core:navigation
feature:featureA ──►  core:navigation
feature:featureB ──►  core:navigation

Feature modules never depend on each other. They navigate by calling methods on the NavigationHandler interface — they do not reference any concrete key from another feature.


Core concepts

AppNavKey

@Immutable
interface AppNavKey : NavKey

A marker interface that every screen key must implement. It extends Navigation3's NavKey so the back stack is directly compatible with NavDisplay.


BackStackController (core:navigation)

The single source of truth for the back stack. It is a plain class with no Android framework dependency.

class BackStackController {
    val backStack: SnapshotStateList<AppNavKey>  // observed by NavDisplay
    val resultBus: NavigationResultBus

    fun buildInitialStack(keys: List<AppNavKey>)
    fun navigate(key: AppNavKey, mode: NavMode = NavMode.SinglePush)
    fun navigateUp()
    fun isVisible(key: AppNavKey): Boolean
    fun <T : Any> navigateUpWithResult(result: T, resultKey: String = ...)
}

backStack is a SnapshotStateList — Compose recomposes NavDisplay automatically on every change. No flows, no channels, no side effects needed at this level.

buildInitialStack is called once before setContent, so NavDisplay never sees an empty list.


NavMode

Controls how navigate() modifies the stack:

Mode Behaviour
SinglePush Adds the key only if it is not already on top. (default)
Push Always adds, even if already present.
PopToExisting If the key exists anywhere in the stack, pops everything above it. Otherwise pushes.
MoveToTop If the key exists, removes it from its current position and re-adds it on top. Otherwise pushes.

FeatureAScreen renders a button for every NavMode entry so you can observe each behaviour live.


NavigationHandler (core:navigation)

The interface that screens and ViewModels depend on. It describes what can happen — not how.

interface NavigationHandler {
    fun navigateToHome()
    fun navigateToFeatureA(mode: NavMode = NavMode.SinglePush)
    fun navigateToFeatureB(mode: NavMode = NavMode.SinglePush)
    fun navigateUp()
    fun isVisible(key: AppNavKey): Boolean
    fun <T : Any> navigateUpWithResult(result: T, resultKey: String = ...)
    fun buildInitialStack(uri: String?)
    fun handleRuntimeDeepLink(uri: String?)
}

Feature ViewModels receive a NavigationHandler via Hilt injection. They never import BackStackController or any concrete key from another feature module.


AppNavigationHandler (app)

The only place in the codebase that knows all three screen keys. It implements NavigationHandler by delegating to BackStackController and owns the deep link URI parsing.

class AppNavigationHandler @Inject constructor(
    private val controller: BackStackController,
) : NavigationHandler {

    override fun navigateToFeatureA(mode: NavMode) = controller.navigate(FeatureAKey, mode)
    override fun navigateToFeatureB(mode: NavMode) = controller.navigate(FeatureBKey, mode)
    // ...

    override fun buildInitialStack(uri: String?) {
        val deepLinkKey = resolveDeepLinkKey(uri)   // null → just Home
        controller.buildInitialStack(buildList {
            add(HomeKey)
            deepLinkKey?.let { add(it) }
        })
    }

    override fun handleRuntimeDeepLink(uri: String?) {
        resolveDeepLinkKey(uri)?.let { controller.navigate(it, NavMode.SinglePush) }
    }
}

Hilt wiring

NavigationModule (app)

@Module
@InstallIn(ActivityRetainedComponent::class)
abstract class NavigationModule {

    // NavigationHandler  →  AppNavigationHandler
    @Binds @ActivityRetainedScoped
    abstract fun bindNavigationHandler(impl: AppNavigationHandler): NavigationHandler

    companion object {
        // BackStackController is created here so the module controls its lifecycle
        @Provides @ActivityRetainedScoped
        fun provideBackStackController(): BackStackController = BackStackController()
    }
}

ActivityRetainedScoped means both objects survive configuration changes (rotation) but are destroyed when the Activity is permanently finished — exactly the lifetime you want for a navigation state.

How injection flows

NavigationModule
  ├── provides  BackStackController  (@ActivityRetainedScoped)
  └── binds     NavigationHandler   →  AppNavigationHandler(@Inject, receives BackStackController)

NavigationViewModel  (@HiltViewModel)  ←  BackStackController
StartupViewModel     (@HiltViewModel)  ←  NavigationHandler
HomeViewModel        (@HiltViewModel)  ←  NavigationHandler
FeatureAViewModel    (@HiltViewModel)  ←  NavigationHandler
FeatureBViewModel    (@HiltViewModel)  ←  NavigationHandler

NavigationViewModel receives the concrete BackStackController because NavigationView needs direct access to backStack and resultBus. Everything else receives the interface.


Navigation UI

NavigationView (app)

@Composable
fun NavigationView(entryBuilder: EntryProviderScope<AppNavKey>.() -> Unit) {
    val viewModel = hiltViewModel<NavigationViewModel>()

    CompositionLocalProvider(LocalNavigationResultBus provides viewModel.controller.resultBus) {
        NavDisplay(
            backStack = viewModel.controller.backStack,
            onBack = { viewModel.controller.navigateUp() },
            entryDecorators = listOf(
                rememberSaveableStateHolderNavEntryDecorator(), // saves/restores per-entry UI state
                rememberViewModelStoreNavEntryDecorator(),      // scopes ViewModels to each entry
            ),
            entryProvider = entryProvider(builder = entryBuilder),
            // slide + fade transitions …
        )
    }
}

rememberViewModelStoreNavEntryDecorator() is the key decorator: it gives every back stack entry its own ViewModelStore, so hiltViewModel() inside a screen is scoped to that entry, not to the Activity. When an entry is popped, its ViewModel is cleared.

The entryProvider DSL in MainActivity maps keys to composables:

NavigationView {
    entry<HomeKey>     { HomeScreen() }
    entry<FeatureAKey> { FeatureAScreen() }
    entry<FeatureBKey> { FeatureBScreen() }
}

Back results

Screens can pass typed values back to their caller without coupling them together.

Sending (Feature A → Home)

// FeatureAViewModel
fun navigateUpWithResult(result: String) =
    navigationHandler.navigateUpWithResult(result)

// BackStackController internally:
//   resultBus.sendResultAny(result, resultKey)
//   navigateUp()

Receiving (Home)

// HomeScreen
BackResultEffect<String> { result ->
    viewModel.onResultReceived(result)
}

BackResultEffect is a composable LaunchedEffect that collects from NavigationResultBus for the given type. The bus is provided as a CompositionLocal by NavigationView, so any screen inside NavDisplay can access it without prop-drilling.

NavigationResultBus uses a Channel per result type. Results are buffered so they are not lost if the collector is not yet active.


Startup & deep link flow

StartupViewModel (app)

@HiltViewModel
class StartupViewModel @Inject constructor(
    private val navigationHandler: NavigationHandler,
) : ViewModel() {

    private val _isNavigationReady = MutableStateFlow(false)
    val isNavigationReady: StateFlow<Boolean> = _isNavigationReady.asStateFlow()

    fun setDeepLink(uri: String?) {
        navigationHandler.buildInitialStack(uri)  // populates BackStackController
        _isNavigationReady.value = true
    }

    fun handleRuntimeDeepLink(uri: String?) {
        navigationHandler.handleRuntimeDeepLink(uri)
    }
}

MainActivity

override fun onCreate(savedInstanceState: Bundle?) {
    // Keep the splash screen visible until the stack is ready
    installSplashScreen().setKeepOnScreenCondition {
        !startupViewModel.isNavigationReady.value
    }
    super.onCreate(savedInstanceState)
    enableEdgeToEdge()

    // Synchronously builds the initial stack (with optional deep link destination)
    startupViewModel.setDeepLink(intent.dataString)

    lifecycleScope.launch {
        // Wait for the stack to be populated, then hand off to Compose
        startupViewModel.isNavigationReady.first { it }
        setContent { /* NavigationView { … } */ }
    }
}

override fun onNewIntent(intent: Intent) {
    super.onNewIntent(intent)
    setIntent(intent)
    startupViewModel.handleRuntimeDeepLink(intent.dataString)
}

setDeepLink is synchronous, so isNavigationReady flips to true immediately. The lifecycleScope.launch pattern mirrors how a real app would handle an async first-launch check (e.g. reading a datastore to decide whether to show onboarding).

android:launchMode="singleTask" in the manifest ensures that a deep link fired while the app is already running calls onNewIntent instead of creating a second Activity instance.

Supported deep link URIs

URI Result
nav3demo://feature-a Opens with stack: Home → Feature A
nav3demo://feature-b Opens with stack: Home → Feature B
(anything else / none) Opens with stack: Home

Test with ADB

# Cold start — opens directly on Feature A (back press returns to Home)
adb shell am start -a android.intent.action.VIEW \
    -d "nav3demo://feature-a" \
    gr.tonygnk.navigation3stackcontroller

# While the app is already running — navigates to Feature B inside the live stack
adb shell am start -a android.intent.action.VIEW \
    -d "nav3demo://feature-b" \
    gr.tonygnk.navigation3stackcontroller

Key library versions

Library Version
Kotlin 2.3.10
AGP 9.1.0
Compose BOM 2026.03.00
Navigation3 1.1.0-beta01
Hilt 2.59.2
lifecycle-viewmodel-navigation3 2.10.0
core-splashscreen 1.0.1

About

Jetpack Navigation3 demo with a hand-rolled BackStackController, Hilt DI, NavMode strategies, back results, and deep link support

Topics

Resources

Stars

Watchers

Forks

Contributors

Languages