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.
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.
- 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_DECELERATEcurve, 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.
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
TabViewdoesn'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
saveStateandrestoreState. - Expo Router native tabs pop to the root and scroll to the top unless
you pass
disablePopToTopordisableScrollToTop.
The common bug is one NavigationStack wrapped around the whole TabView,
which loses every tab's history on switch.
Usage
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
interactiveContentPopGestureRecognizer - The iOS 26 recognizer that pops a pushed screen on a left-to-right pan anywhere in its content.
Predictive back design guide - Google's motion numbers for back previews, from the 90% scale and 8dp margin to the 35% cross-fade.
Add support for the predictive back gesture - The Android timeline, PredictiveBackHandler in Compose, and why back callbacks shouldn't always be on.
Native Stack Navigator - The React Native stack built on UINavigationController and Fragment, with its gesture options.
Predictive back in Flutter - Turning on predictive back in a Flutter app, and PopScope in place of the deprecated WillPopScope.