Shared navigation has no back stack owner, and that is the hard part
Android has a system back gesture with an owner. iOS has a navigation controller. A shared back stack answers to both.
TL;DR
A navigation library gives you a back stack. What it cannot give you, in shared code, is an owner — something that knows when the user asked to go back, and what happens when the stack is empty.
Android has a system back gesture dispatched through OnBackPressedDispatcher, and an empty stack
means finish the activity. iOS has an interactive edge swipe owned by UINavigationController, no
"back" event to intercept, and an empty stack means... nothing; there is no concept of finishing.
So the shared stack is correct and the integration is where the bugs live: back exiting the app from a detail screen, an iOS edge swipe that animates while the shared stack does not move, and a dialog that back dismisses on one platform and not the other.
The case this post is about
A three-level flow — list, detail, edit — on Compose Multiplatform navigation:
// commonMain
@Composable
fun AppNav() {
val nav = rememberNavController()
NavHost(nav, startDestination = "list") {
composable("list") { ListRoute(onItem = { nav.navigate("detail/$it") }) }
composable("detail/{id}") { EditRoute(onEdit = { nav.navigate("edit/$it") }) }
composable("edit/{id}") { EditRoute(onDone = { nav.popBackStack() }) }
}
}
Correct, shared, and it works when you navigate with the in-app controls. Every problem below comes from the platform's own back affordance.
The symptom
Four behaviours, and they are genuinely different problems rather than one bug.
Android back exited the app from detail. The system back gesture was not reaching the shared
NavController at all, so the activity handled it:
list → detail → [system back] → app exits (expected: back to list)
iOS edge swipe animated and then snapped back. The gesture belongs to
UINavigationController, which was not driving the shared stack, so the UI moved and the state did
not:
detail → [edge swipe] → view slides out, then jumps back to detail
An unsaved-changes dialog could be bypassed. On Android, popBackStack was intercepted by a
confirmation; the iOS swipe never consulted it, so edits were discarded silently on one platform
only.
And deep links resumed with an empty stack. Opening edit/42 from a notification put one entry
on the stack, so back had nowhere to go — exiting the app on Android and leaving a dead-end screen
on iOS.
Why the obvious fix failed
"Call nav.popBackStack() from the Android back handler." The right instinct, and my first
version swallowed back entirely at the root:
BackHandler { nav.popBackStack() } // returns false at the root, and we consumed the event
At the start destination popBackStack() returns false and the app no longer exits — which users
read as a frozen app. The handler has to know whether it handled anything.
"Disable the iOS edge swipe." Removes a platform-expected gesture to make a cross-platform abstraction easier. Users notice, and it is the wrong trade: the gesture is the platform's convention.
"Keep a separate back stack per platform." Two sources of truth that must agree about what screen is showing. They will not.
"Use a single Channel of navigation events." Helps with outgoing navigation and does nothing
for incoming platform gestures, which are the whole problem. The direction that breaks is
platform-to-shared.
The actual mechanism
Three things that "navigation" bundles, and only one is portable.
The stack itself is portable. A list of destinations with arguments and saved state is ordinary data, and a shared library models it fine.
The input is not. Android dispatches a back event you can intercept in a chain of callbacks,
each able to consume it. iOS has no back event — it has a continuous, interruptible, cancellable
gesture owned by a UINavigationController, reported through delegate callbacks as it
progresses.
The word doing the damage there is cancellable. An Android back press is a single discrete fact: it happened, something consumed it, the stack changed. An interactive swipe is a sequence — begun, 40% through, released, and then either completed or reverted — and a shared stack modelled as "pop on back" has no state for the middle of it and no event for the reversal.
That is where a "leaving this screen" confirmation goes wrong in a way that looks like a bug in your code. Hooking the gesture's start means the dialog appears mid-swipe, over a half-dismissed screen, and stays up when the user changes their mind and the gesture reverts. Hooking only completion means the dialog appears after the screen has already gone, which is too late to be a confirmation of anything.
So the portable abstraction cannot be a back event. It has to be a back transaction with a begin, a progress fraction and a terminal outcome of completed-or-cancelled — on Android an instantaneous one, on iOS one that spans frames and can end either way. Modelling the simpler platform's shape and adapting the other is the choice that produces unfixable iOS behaviour, and it is the natural one to make because Android is usually the first target.
The terminal behaviour is not. On Android, back at the root finishes the activity, and the OS expects your handler to decline so that can happen. On iOS there is no finish; an app at its root simply stays there.
That second difference is the deep one. An event you can accept or reject is not the same shape as a gesture with a progress fraction that can be abandoned halfway — a predictive-back-style model rather than a boolean.

A shared back stack needs a per-platform adapter, not a per-platform copy. The adapter translates the platform's own affordance into a stack operation, and reports back whether it consumed the interaction.
The fix
One shared stack, one shared "can we go back" question, and a thin adapter per platform.
// commonMain — the stack plus an explicit, interceptable intent
class AppNavigator(private val nav: NavHostController) {
/** Returns true if this navigator handled the back intent. */
fun onBack(): Boolean {
// 1. a guard may claim it (unsaved changes)
guards.lastOrNull()?.let { if (it.intercept()) return true }
// 2. otherwise pop if there is anywhere to go
return nav.popBackStack()
}
val canGoBack: Boolean get() = nav.previousBackStackEntry != null
private val guards = mutableListOf<BackGuard>()
fun addGuard(g: BackGuard) { guards += g }
fun removeGuard(g: BackGuard) { guards -= g }
}
fun interface BackGuard { fun intercept(): Boolean }
onBack() returning a boolean is the detail that fixes the frozen-app bug — the Android adapter can
decline and let the OS finish the activity:
// commonMain — Compose, both platforms
@Composable
fun AppNav(navigator: AppNavigator) {
// enabled only while there IS somewhere to go. At the root, back falls through to the OS.
BackHandler(enabled = navigator.canGoBack) { navigator.onBack() }
NavHost(…)
}
The unsaved-changes guard becomes shared, so both platforms consult it:
// commonMain — in the edit screen
val navigator = LocalNavigator.current
DisposableEffect(hasUnsavedChanges) {
val guard = BackGuard {
if (hasUnsavedChanges) { showDiscardDialog = true; true } else false
}
navigator.addGuard(guard)
onDispose { navigator.removeGuard(guard) }
}
And the iOS gesture is wired to the same onBack() rather than left to the navigation
controller:
// iOS — the Compose view hosts the whole UI, so the shared stack owns navigation.
// Disable UINavigationController's own pop and route the gesture to the shared navigator.
final class ComposeHostController: UIViewController, UIGestureRecognizerDelegate {
override func viewDidLoad() {
super.viewDidLoad()
navigationController?.interactivePopGestureRecognizer?.delegate = self
}
func gestureRecognizerShouldBegin(_ g: UIGestureRecognizer) -> Bool {
// The shared navigator decides. If it handles the intent, the system must not also pop.
return !navigator.onBack()
}
}
Two honest notes about this fix.
The iOS gesture becomes discrete, not continuous. Routing shouldBegin to onBack() loses the
interactive drag — the screen changes on gesture start rather than following the finger. That is a
real fidelity loss, and the alternative (driving the shared stack from the gesture's progress
fraction) needs the navigation library to expose a fractional pop, which it does not. I chose
correctness over the animation and would revisit it if the library gains the hook.
Deep links need an explicit synthesised stack:
// commonMain
fun openDeepLink(route: String) {
nav.navigate("list") { popUpTo(0) } // ensure a parent exists
if (route != "list") nav.navigate(route)
}
Without that, back from a notification-opened screen has nowhere to go — on Android it exits, on iOS it dead-ends, and both are wrong.
How we proved it
That back pops rather than exiting, and still exits at the root — both halves, because the first attempt fixed one and broke the other:
// androidInstrumentedTest
@Test
fun backPopsFromDetailAndExitsFromRoot() {
navigateTo("detail/42")
pressBack()
assertEquals("list", currentRoute()) // popped, did not exit
pressBack()
assertTrue(activity.isFinishing) // at root: OS handled it
}
before: test 1 FAILED (app exited from detail)
first attempt: test 1 PASSED, test 2 FAILED (app frozen at root)
after: both PASSED
That middle row is why BackHandler(enabled = canGoBack) is written the way it is.
That the guard fires on both platforms. The cross-platform claim, asserted in commonTest
against the navigator rather than the UI:
// commonTest
@Test
fun guardInterceptsBackOnEveryPlatform() {
val navigator = AppNavigator(fakeController(stack = listOf("list", "edit/42")))
var intercepted = false
navigator.addGuard { intercepted = true; true }
assertTrue(navigator.onBack()) // handled by the guard
assertTrue(intercepted)
assertEquals("edit/42", fakeController.current) // did NOT pop
}
jvmTest(desktop) PASSED
androidUnitTest PASSED
iosSimulatorArm64Test PASSED
And the iOS swipe on a real device, since that is the half no unit test reaches:
detail → edge swipe
before: view slides out, snaps back (stack unchanged)
after: pops to list, stack and UI agree
edit with unsaved changes → edge swipe
before: discarded silently
after: discard dialog shown ← the guard now runs on iOS
One limit worth repeating: the gesture is no longer interactive on iOS, which is a visible
regression in feel traded for state correctness. And the deep-link fix synthesises a list parent
unconditionally, which is right for this app's flat hierarchy and would need real parent resolution
in a deeper one.
What this generalizes to
Portable state, platform input. The pattern recurs everywhere in cross-platform UI: the model crosses, the gestures and system events do not. Budget for an adapter per platform rather than expecting the abstraction to cover both ends.
A handler that cannot decline is a handler that breaks the platform. Returning a boolean — "did I handle this?" — is what let the OS do its job at the root. Any time you intercept a system event, the ability to pass it on is part of the contract.
Discrete and continuous interactions are different shapes. An event you accept or reject does not model a cancellable gesture with progress. When an abstraction spans both, one side loses fidelity, and it is better to choose which deliberately than to discover it in review.
Deep links are a stack-construction problem, not a navigation problem. Arriving directly at a leaf means the history that should exist does not. Every deep link needs an answer to "what does back do", and the answer is usually to synthesise the parents.
Related
- A shared ViewModel has no lifecycle, so you have to give it one — the same missing-owner problem
- @Preview is Android-only, and your shared UI has no preview at all — tooling that does not cross
- A platform bridge is an interface with a lifetime problem — adapters that need a platform host
Keep reading
This is part of 100 Days of Kotlin Multiplatform — the boundaries, the sharp edges, and the things that only show up on the second target. Follow MobileByteSensei for the rest.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".