Skip to content

Back Navigation

People use back constantly, often without looking. On both platforms it's interactive: the screen moves with the finger, shows where you're going, and lets you change your mind halfway. A swipe that fires a fixed animation once you pass a threshold can't do that.

Notes

Open a note and swipe right. Halfway through, try to change your mind.

Let back follow the finger the whole way, show the screen underneath, and decide on release, so people can change their mind.

iPhone

The pushed screen tracks the finger 1:1 while the screen below slides in more slowly from the left, under a dim that lifts as it arrives. The demo uses 30%. On release, project the swipe (Momentum): a short fast flick pops, a slow drag that stops short springs back, and both continue at the finger's speed (Springs).

Since iOS 7 the swipe starts at the leading edge (interactivePopGestureRecognizer). Since iOS 26 a second recognizer, interactiveContentPopGestureRecognizer, pops on a left-to-right pan anywhere in a pushed screen's content (switch the demo to Anywhere). Now every horizontal gesture in a pushed screen competes with back: carousels, chart scrubbers, sliders, leading swipe actions. In UIKit, make the content pop wait for your recognizer with require(toFail:). SwiftUI's NavigationStack gets the same behaviour, and I found no SwiftUI modifier to control it.

Keep the Back button. Apple calls the edge swipe a shortcut for it, not a replacement.

Android: predictive back

Android's back comes from either edge. Since Android 16, apps that target API 36 get predictive back by default: onBackPressed() isn't called and KEYCODE_BACK isn't dispatched. Google Play has required API 36 for new apps and updates since 2026-08-31, so this is now every app.

Settings
Display
  • Dark themeOn
  • BrightnessAdaptive
  • Font sizeDefault

Drag in from the left or right edge. Let go to go back, or slide back to cancel.

The in-app preview, from Google's spec:

  • The screen shrinks from 100% to 90% and keeps 8dp from the screen edge.
  • It shifts with the finger by at most width ÷ 20 − 8dp sideways, and the same fraction of the height vertically.
  • Progress runs through the STANDARD_DECELERATE curve, so most of the motion happens early in the swipe.
  • In a full-screen transition, the outgoing screen has faded out by 35% progress and the incoming one fades in from there.

Navigation Compose 2.8 and later scrubs your popEnterTransition and popExitTransition with the gesture. ModalBottomSheet, the modal drawer and SearchBar have it built in. Use PredictiveBackHandler for custom surfaces. Enable a BackHandler only while there's something to dismiss, like unsaved edits or selection mode. An always-on handler traps people and breaks the preview of going home.

React Native and Flutter

Use the native stack: @react-navigation/native-stack or Expo Router's Stack. They run on UINavigationController and Android fragments, so the real gesture comes free. @react-navigation/stack and expo-router/js-stack reimplement it in JavaScript and don't feel the same. On iOS, fullScreenGestureEnabled lets the swipe start anywhere on the screen.

Flutter's default page transitions already do both: the predictive back builder on Android, the Cupertino edge swipe on iOS. A hand-rolled PageRouteBuilder slide loses both. Intercept back with PopScope, not the deprecated WillPopScope.

Tabs keep their place

Back moves through a stack, and each tab has its own. Scroll the library, open an album, switch tabs and come back.

Library

Scroll the library and open an album. Switch to Search and back, then tap Library again, twice.

When the tab resets, you have to scroll and tap your way back. Keep each tab's stack and scroll position alive while it's hidden. Tapping the tab you're already on does something useful:

  • iPhone: UIKit returns to the tab's root on any tap, "even if the tab was already selected". Scrolling to the top on a tap at the root is the usual follow-up, though Apple doesn't document it. SwiftUI's TabView doesn't promise either, so wire both yourself.
  • Android: Material says re-selecting the active destination should reset its scroll to the top. Navigation Compose keeps each tab's stack with saveState and restoreState.
  • Expo Router native tabs pop to the root and scroll to the top unless you pass disablePopToTop or disableScrollToTop.

The common bug is one NavigationStack wrapped around the whole TabView, which loses every tab's history on switch.

Usage

ContentView.swift
struct RootView: View {
    @State private var tab: AppTab = .library
    @State private var libraryPath = NavigationPath()

    var body: some View {
        TabView(selection: Binding(
            get: { tab },
            set: { tapped in
                // Re-tap the current tab: back to its root.
                if tapped == tab { libraryPath = NavigationPath() }
                tab = tapped
            })) {
            Tab("Library", systemImage: "music.note.house.fill", value: .library) {
                NavigationStack(path: $libraryPath) { LibraryView() } // one stack per tab
            }
            Tab(value: .search, role: .search) {
                NavigationStack { SearchView() }
            }
        }
    }
}

// UIKit, iOS 26+: let a horizontal scrubber win over the content back swipe.
navigationController?.interactiveContentPopGestureRecognizer?
    .require(toFail: scrubGesture)
// A custom pane that previews back like the M3 spec.
var progress by remember { mutableFloatStateOf(0f) }
var edge by remember { mutableIntStateOf(BackEventCompat.EDGE_LEFT) }

PredictiveBackHandler(enabled = paneOpen) { events ->
    try {
        events.collect { e -> progress = e.progress; edge = e.swipeEdge }
        paneOpen = false                       // released: commit
    } catch (e: CancellationException) {
        progress = 0f                          // slid back: cancel (animate it)
        throw e
    }
}

DetailPane(Modifier.graphicsLayer {
    val s = lerp(1f, 0.9f, progress)           // 100% → 90%
    scaleX = s; scaleY = s
    val shift = size.width / 20f - 8.dp.toPx()
    translationX = shift * progress * if (edge == BackEventCompat.EDGE_LEFT) 1f else -1f
})

// Only while there's something to lose.
BackHandler(enabled = hasUnsavedChanges) { showDiscardDialog = true }
// app/_layout.tsx: Expo Router's Stack is the native stack.
import { Stack } from "expo-router";

export default function Layout() {
  return (
    <Stack
      screenOptions={{
        fullScreenGestureEnabled: true, // iOS: swipe back from anywhere
      }}
    />
  );
}

// app/editor.tsx: no swipe-back while there are unsaved edits.
export default function Editor() {
  const [dirty, setDirty] = useState(false);
  return (
    <>
      <Stack.Screen options={{ gestureEnabled: !dirty }} />
      <EditorForm onChange={() => setDirty(true)} />
    </>
  );
}
// The defaults, spelled out: predictive back on Android, edge swipe on iOS.
MaterialApp(
  theme: ThemeData(
    pageTransitionsTheme: const PageTransitionsTheme(builders: {
      TargetPlatform.android: PredictiveBackPageTransitionsBuilder(),
      TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
    }),
  ),
  home: const LibraryPage(),
);

// Block back only while there's something to lose.
PopScope(
  canPop: !hasUnsavedChanges,
  onPopInvokedWithResult: (didPop, result) {
    if (!didPop) showDiscardDialog(context);
  },
  child: const EditorPage(),
);

Never take back away except to protect unsaved work, and even then ask, don't block silently. Reduced motion doesn't remove the gesture: the screen still tracks the finger, because that motion is the person's own. Shorten the settle and drop any bounce, and replace the push animation for button taps with a quick fade.

Resources

Search concepts

Search for a command to run...