Mobile Computing · Cross-Platform Development

Back Navigation When the Two Platforms Disagree

Android has a system back action with predictive gestures; iOS has an edge swipe owned by the navigation controller. Reconciling them means modelling history in the shared core and restoring it after process death.

Back is the one navigation control every mobile user has. It is also the control whose semantics differ most between the two platforms, and the difference is not cosmetic. Android exposes back as a system-level action: a gesture, a button, or a system call, delivered to the foreground application, which may or may not handle it. iOS has no system back action at all; going back is a control the navigation controller draws, an edge swipe the navigation controller recognises, and a stack the navigation controller owns.

A shared navigation model that ignores this produces the two classic defects. On Android, back exits the application from a nested screen because the shared router treated the gesture as a request to pop its own stack and the stack was already empty. On iOS, the edge swipe is intercepted by the system navigation gesture and the shared router never learns that a pop happened, so its stack and the visible screen diverge by exactly 1 entry, and the next back gesture pops the wrong screen.

Two stacks, one truth

The mechanism that resolves this is to make the shared core the owner of the history and to make each platform’s navigation container a projection of it. The core holds an ordered list of entries, each identified by a key and a typed route, and it accepts 4 transitions: push, pop, replace, and reset-to-root. The platform container is told to display the projection, and its own back affordances are mapped onto those transitions.

The mapping is where the platforms diverge and where the work is.

On Android, the system delivers a back request and the application either handles it or does not. The modern form is a callback registered with the activity, which receives a progress value before the gesture completes and a commit or cancel at the end. That progress value is what allows a predictive animation: the screen can animate towards the destination while the gesture is still in progress, and reverse if the user cancels. A shared router that only learns about the pop after the fact cannot drive that animation, so the router must expose a begin/commit/cancel triple rather than a single pop.

On iOS, the interactive pop gesture belongs to the navigation controller. The application can enable or disable it, and it can observe the transition, but the controller performs the pop. If the shared router is the owner, the controller’s pop must be reported back to it, and the router must treat the report as authoritative rather than as a request. Getting the direction wrong produces a double pop: the controller pops, reports, and the router, believing it must act, pops again.

Where the two models genuinely conflict

Four conflicts recur, and each has a documented resolution.

Root-level back. On Android, back at the root of an application’s navigation is not a no-op: the system may move the task to the background, or finish the activity. On iOS there is nothing to press at the root, so the question does not arise. A shared router that treats “pop at root” as an error will crash on Android where the correct behaviour is to let the system handle it. The router should report “not handled” and let the platform decide, which is exactly what the Android back callback contract provides.

Modal dismissal. A modal screen on iOS is dismissed by a gesture that is not a stack pop, and its dismissal is animated by the presentation controller. On Android, a full-screen dialog participates in back like any other screen. If the shared model treats modals as ordinary stack entries, iOS’s dismissal gesture must be mapped to a pop, and Android’s back must be mapped to the same pop, and the router ends up with one model that both platforms can drive. If the shared model treats modals as a separate layer, the platform containers need two stacks, which is more faithful and more work.

Deep links. A link that arrives while the application is running should place the user at the destination with a sensible history behind it, so that back goes somewhere meaningful rather than out of the application. Both platforms deliver the link, and neither defines what the history should be; that is application policy. The shared core is the right place for it, because the policy must be identical on both platforms, and it is usually expressed as a synthetic stack: a root, a parent list, and the destination.

Process death. Both platforms may terminate a backgrounded application and later restore it. Android provides a saved-state bundle tied to the activity, with a size limit that is deliberately modest; iOS provides a state restoration mechanism the application opts into. Neither restores an arbitrary object graph, and neither restores the history unless the application writes it down. On Android the bundle travels through a per-process binder transaction buffer on the order of 1 MB, shared with every other transaction the process makes, which is why large saved states fail rather than merely being slow. Since the shared core already models the history as data, writing it down is the same operation as persisting any other value, and the router can restore its stack from a serialised form and hand it to the platform container on launch.

Serialising history so it survives death

The history is a list of entries. Each entry needs a stable identifier, a route, and the parameters that make the screen reconstructible. It must not contain the screen’s state; the screen reconstructs that from its own persistence, keyed by the entry identifier.

interface HistoryEntry {
  key: string;              // stable across restoration; used to match saved state
  route: string;            // e.g. "order/detail"
  params: Record<string, string>;
}

interface History {
  entries: HistoryEntry[];
  index: number;            // the visible entry; entries after it are forward history
}

// Serialised history must be versioned: the route table changes between releases.
function encodeHistory(h: History): string {
  return JSON.stringify({ v: 3, entries: h.entries, index: h.index });
}

There are 2 details that matter more than they appear to. The key must be stable, because it is how a screen’s saved state is matched to its entry after restoration; generating a fresh key on restore orphans the saved state. And the index must be stored, because a restored stack with the right entries but the wrong visible entry shows the wrong screen even though every entry is present.

The version field is not optional. A route that existed in the previous release may be gone, and restoring it produces a screen that cannot be built. The correct behaviour is a migration that drops unknown routes and repairs the index, or a reset to a safe root. Failing loudly is better than restoring into an inconsistent state, because the user’s next back press will otherwise pop into a screen that was never there.

Restoring the screen, not just the route

Restoring the history restores which screens exist. Restoring what is on them is a separate obligation, and the platforms offer different amounts of help.

On Android, the saved-state bundle is delivered to the screen when it is recreated, and it is intended for small values: an identifier, a scroll position, a partially typed string. It is not a place for a list of records, because the bundle is written to the system and its size is bounded. On iOS, state restoration encodes objects the application marks, and the same discipline applies: identifiers rather than records.

The shared core should therefore treat restoration as a 2-step process. The history restores the skeleton, and each screen restores its content by reading from the same store that held it, using the identifier in its parameters. This works because the store is already persistent, as described in Shared State Without a Shared Heap, and the screen’s content is derived state rather than screen-local state. A screen whose content exists only in memory cannot be restored by either platform, and the defect appears as an empty screen after the process is killed and the user returns to it.

The animation contract

Predictive back on Android imposes a requirement that the shared router must satisfy before any animation work is possible: the router must be able to name the destination before the transition commits. That means the history must have a well-defined “previous” entry at all times, including when the current screen was reached by a deep link with a synthetic stack, and including when the previous entry is a different navigation container such as a modal layer.

When the destination is not known in advance, the gesture cannot animate towards it, and the platform falls back to a non-predictive transition. That is acceptable behaviour, and it is what happens when the router reports that it cannot handle the request. The failure to avoid is claiming to handle the gesture and then animating to a destination that differs from the one the gesture previewed, which produces a visible jump at commit. The gesture reports its progress as a value between 0 and 1 before committing, so the router has to be able to name a destination for every point in that range, not merely at the end.

On iOS, the corresponding requirement is that the interactive pop must be cancellable at any point, with the screen returning to its pre-gesture position. A shared router that mutates its history when the gesture begins, rather than when it commits, cannot support cancellation, and the visible result is a screen that has already been popped internally while still being displayed.

What belongs to the platform

2 pieces of back behaviour are correctly platform-owned and should not be pushed into the shared model.

The first is the visual affordance. Android’s back gesture region and iOS’s edge swipe region are system-defined, and their geometry is not something an application should reimplement. The application supplies the destination and the animation; the platform supplies the gesture. The same division of labour governs the rest of the chrome, as argued in One Description, Two Design Languages.

The second is the exit decision. Whether back at the root backgrounds the task, finishes the activity, or does nothing is a platform policy that changes between versions. The router’s job is to report that it has no entry to pop and to stop there. Any application that overrides this to implement “press back twice to exit” is fighting the platform’s own behaviour, and the behaviour it fights changes with each release.

The remaining shared responsibility is consistency: the same sequence of user actions should produce the same history on both platforms, so that a bug report describing a navigation path is reproducible on either device. That property is worth more than any individual animation, and it comes from the router owning the history rather than from either platform container.

References

  1. Android Documentation: Back gestures and predictive back — Android Developers
  2. Apple Documentation: UINavigationController — Apple
  3. Apple Documentation: About the app launch sequence — Apple
  4. Android Documentation: Saved State — Android Developers