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.
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.
@Immutable
interface AppNavKey : NavKeyA marker interface that every screen key must implement. It extends Navigation3's NavKey so the back stack is directly compatible with NavDisplay.
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.
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.
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.
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) }
}
}@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.
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.
@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() }
}Screens can pass typed values back to their caller without coupling them together.
// FeatureAViewModel
fun navigateUpWithResult(result: String) =
navigationHandler.navigateUpWithResult(result)
// BackStackController internally:
// resultBus.sendResultAny(result, resultKey)
// navigateUp()// 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.
@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)
}
}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.
| 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 |
# 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| 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 |