Engineering

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.

MobileByteSensei 9 min read
kmpcomposenavigation

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.

Three bundled pieces of navigation: the stack as portable data, the input which is a consumable discrete event on Android and a continuous cancellable gesture owned by UINavigationController on iOS, and the terminal behaviour where an empty stack finishes the activity on Android and means nothing on iOS — so only the stack crosses and both the input and the terminal case need per-platform integration

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.

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".

Follow by RSS