Predictive back turns a commitment into a preview, and your BackHandler can break it
Predictive back shows a preview of the destination during the gesture. Supporting it means responding to progress rather than to a completed press, and the old BackHandler cannot express that.

Day 97 of 100. A platform behaviour that works automatically for most screens and is broken by exactly one common piece of code.
The symptom
An app where the back gesture does nothing until it's finished:
BackHandler(enabled = sheetOpen) {
sheetOpen = false
}
On Android 14+, dragging from the screen edge should shrink the current screen and reveal what's behind it, following your finger — so you can see where you're going and let go to cancel. On this screen nothing moves until the gesture completes, then the sheet vanishes abruptly.
The app also doesn't show the system's own preview animation, because it has declared that it handles back itself.
Why the obvious fix fails
The obvious fix is to remove the handler, which restores the system animation and breaks the sheet — back now exits the screen instead of closing it.
The second attempt is to animate on completion:
BackHandler(enabled = sheetOpen) {
scope.launch { sheetState.hide(); sheetOpen = false }
}
Better, and it still animates after the decision. The user gets no preview during the gesture and no ability to change their mind by dragging back.
BackHandler reports one thing — that back happened. Predictive back needs the progress
while it's happening.

The actual mechanism
PredictiveBackHandler gives you the gesture as a flow of progress events:
PredictiveBackHandler(enabled = sheetOpen) { progress: Flow<BackEventCompat> ->
try {
progress.collect { event ->
// event.progress: 0f..1f · event.swipeEdge · event.touchX / touchY
sheetOffset = lerp(0f, sheetHeight, event.progress)
}
// the flow completed → the user committed
sheetOpen = false
} catch (e: CancellationException) {
// the flow was cancelled → the user let go and changed their mind
sheetOffset = 0f
}
}
The shape is the whole API, and it's unusual enough to state plainly:
- Collecting the flow means the gesture is in progress. Each
event.progressis a fraction from 0 to 1. - The flow completing normally means the user committed — do the navigation.
- A
CancellationExceptionmeans the user cancelled — animate back to where you were.
That's why it's a suspend lambda rather than a callback. Kotlin's cancellation already
models "this was abandoned part-way", so the API borrows it rather than inventing a
onBackCancelled callback.
This is Day 71's argument again: sequential input reads better as a suspending sequence than as a set of handlers.
What you get for free
Worth knowing before writing any of it, because most screens need none:
Activity-level back — the system shrinks and slides your whole app, revealing the launcher or the previous app. Requires only the manifest opt-in:
<application android:enableOnBackInvokedCallback="true">
Navigation Compose transitions — a NavHost's popEnterTransition and
popExitTransition are driven by the gesture's progress automatically. A back gesture
scrubs the transition rather than playing it after the fact.
Material components — ModalBottomSheet, ModalNavigationDrawer and SearchBar
handle it themselves. Day 39's argument for using them over hand-rolled overlays gains
another item.
So the work is confined to custom modal surfaces and custom navigation. Which is a small list, and the reason this post is mostly about not breaking what already works.
The thing that breaks it
Stated plainly because it's the single most common cause:
A BackHandler that's enabled when it shouldn't be. A handler with
enabled = true unconditionally intercepts every back gesture, so the system can't render
its preview for any screen behind it.
BackHandler { /* … */ } // always on — blocks everything
BackHandler(enabled = sheetOpen) { /* … */ } // on only while relevant
The enabled parameter isn't an optimisation. It's how the system knows whether you're
handling this gesture, and getting it wrong disables predictive back app-wide from that
screen down.
The audit is a grep for BackHandler and a check that each one has a condition.
The related trap is a handler that stays registered after its screen is no longer relevant.
BackHandler is a composable, so it deregisters when it leaves the composition — but one
inside an always-composed wrapper with a stale enabled flag keeps intercepting. Deriving
enabled from state rather than from a separate boolean avoids it.
Custom navigation
If you have your own back stack — Day 31's pane navigator, or a custom wizard — the progress can drive the transition:
var backProgress by remember { mutableFloatStateOf(0f) }
PredictiveBackHandler(enabled = canGoBack) { progress ->
try {
progress.collect { backProgress = it.progress }
navigateBack()
backProgress = 0f
} catch (e: CancellationException) {
animate(backProgress, 0f) { value, _ -> backProgress = value }
}
}
Box(
Modifier.graphicsLayer {
scaleX = lerp(1f, 0.9f, backProgress)
scaleY = lerp(1f, 0.9f, backProgress)
translationX = lerp(0f, 48f, backProgress)
}
) { CurrentPane() }
Note the transform reads backProgress inside graphicsLayer — Day 82, because the
progress updates on every frame of the gesture.
Matching the system's own motion matters here. A custom preview that shrinks differently from the platform's looks wrong beside it, and the platform's is: scale toward 90%, translate away from the swipe edge, round the corners slightly.
The design question
Worth asking before implementing: should this gesture be previewable at all?
Predictive back exists because back is now reversible — the user can see the destination and change their mind. That's right for navigation and for dismissing a surface.
It's wrong for a destructive or committing action. A back gesture that discards unsaved
work shouldn't preview the discard; it should show Day 39's confirmation dialog. Those
screens legitimately keep a plain BackHandler, and the guidance is to make them rare.
How to prove it
Predictive back must be enabled in Developer Options on some versions, and that's the first step — a lot of "it doesn't work" is the setting.
Then the check: from any screen, drag slowly from the edge and hold. Three things should be true — something moves with your finger, the destination is visible behind it, and releasing back at the edge cancels cleanly with no flicker.
Do it on every screen, not just the one you implemented. A single always-on BackHandler
several levels up disables the preview for everything below it, and the symptom appears on
screens whose own code is fine.
What this generalizes to
The principle is an interaction that shows its consequence needs progress, not just an outcome. A callback saying "back happened" can't drive a preview, because by the time it fires the decision is made.
That's the same shift as Day 62's animation targets and Day 73's anchored drags: modelling the in-between rather than only the endpoints is what makes an interaction feel reversible. Systems that only report completion force every interaction to be a commitment.
Which is a reasonable lens for judging any gesture API. If it can only tell you that something happened, every interaction built on it is all-or-nothing; if it reports progress, the interaction can be explored and abandoned. Users strongly prefer the second.
Tomorrow, Day 98: notifications — the UI your app shows when it isn't running.
Day 97 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Predictive back.