DEV Community

Bruno
Bruno

Posted on

Predictive-back Transitions in Jetpack Compose

Quick, honest preface: This is my first post here, and it comes with a confession: most of the things I build never leave my dev/ folder. I hack on something, get it working, feel good about it for an afternoon, and then it quietly dies on my hard drive while I move on to the next thing. This is me trying to break that pattern; to actually ship something.

I also do want to be upfront about how this was be made. The heavy lifting on the code side was done with Claude Code and Codex, but with me steering them very pedantically, until reaching a 100% intentional end result.

This write-up on the other hand was largely AI-drafted; while I have read every line and adjusted the parts that were just off, a lot of it is still far from what I'd personally do hands-on.

My goal is for the final code to be useful, and to walk to some degree of detail through some of the parts I learned myself, in the process. Think of it as an experiment first of all, and also less as a "I wrote every word" and more as "I'm finally sharing something instead of letting it die". If that trade lets one useful idea reach you, I'll take it :D

With that out of the way, here's what I built!

Getting a back gesture to feel right is deceptively hard. The animation has to track your finger continuously, reverse cleanly if you change your mind, and still look correct whether you flick it fast, drag it slowly, or start the swipe from either edge of the screen. It's the kind of interaction that looks trivial in the apps that nail it — Telegram, Gmail, Google Calendar — and falls apart in a dozen subtle ways when you try to build it yourself.

This walkthrough builds that interaction incrementally with Jetpack Compose and Navigation 3. We start with a deliberately unanimated back stack, add regular horizontal and vertical transitions, and then replace the predictive pop with a gesture-driven card, a scrim, and a moving underlay — each property wired to the same source of truth so the whole thing stays continuous.

The sample

The app has one Home destination, a set of green A destinations, and one orange B destination. A opens horizontally; B opens vertically. Both stay interactive throughout the predictive back gesture.

Horizontal and vertical gesture-driven transitions

The content is intentionally boring — colored boxes and a list — because the point is the motion, and anything more would just get in the way of seeing it. The complete project has a focused commit for every stage below, plus a full-resolution recording.

It uses Navigation 3 1.1.2, Compose, Kotlin serialization, and a minimum SDK of 34. The simplest way to test it is Android 14 or newer with gesture navigation turned on.

What Navigation 3 actually gives you (and what it doesn't)

One thing worth clearing up before any code: the visual treatment here is plain Compose, not some Nav3-only animation API. Navigation 3 contributes exactly three things to this project:

  • the back stack and destination content, through NavDisplay;
  • separate hooks for push, pop, and predictive pop transitions;
  • a seekable AnimatedContent transition that destination content can read via LocalNavAnimatedContentScope.

Everything else — the translation, scale, corner clipping, shadow, scrim opacity, the underlay math — is standard Compose. If you're not using Nav3, you can drive the exact same rendering code from PredictiveBackHandler instead. The only reason to lean on Nav3 here is that when it's already in your app, its predictive-back integration means you don't have to maintain a second back handler yourself. That's the whole trade.

1. Start with navigation, not animation

Add the Navigation 3 artifacts, then model the destinations as serializable NavKey values:

implementation("androidx.navigation3:navigation3-runtime:1.1.2")
implementation("androidx.navigation3:navigation3-ui:1.1.2")
Enter fullscreen mode Exit fullscreen mode
@Serializable
sealed interface AppDestination : NavKey {
    @Serializable
    data object Home : AppDestination

    @Serializable
    data class ADetail(val number: Int) : AppDestination

    @Serializable
    data object BDetail : AppDestination
}
Enter fullscreen mode Exit fullscreen mode

The baseline NavDisplay needs only a back stack, an onBack callback, and an entry provider. Every transition is explicitly disabled at this stage — and that's on purpose. It gives us a control case: navigation that provably works before any motion is layered on. When the animations later misbehave, you'll be grateful you can point to a version where the plumbing alone was correct.

@Composable
fun AppNavHost() {
    val backStack = rememberNavBackStack(AppDestination.Home)
    val destinations = backStack.map { it as AppDestination }

    fun pop() {
        if (backStack.size > 1) backStack.removeAt(backStack.lastIndex)
    }

    NavDisplay(
        backStack = destinations,
        onBack = ::pop,
        transitionSpec = { noTransition() },
        popTransitionSpec = { noTransition() },
        predictivePopTransitionSpec = { _ -> noTransition() },
        entryProvider = { destination ->
            NavEntry(destination) { target ->
                when (target) {
                    AppDestination.Home -> HomeScreen(/* ... */)
                    is AppDestination.ADetail -> ADetailScreen(target.number, ::pop)
                    AppDestination.BDetail -> BDetailScreen(::pop)
                }
            }
        },
    )
}

private fun noTransition() = ContentTransform(
    targetContentEnter = EnterTransition.None,
    initialContentExit = ExitTransition.None,
)
Enter fullscreen mode Exit fullscreen mode

Adding an item pushes a key into the list; Back removes the last one. NavDisplay renders the matching NavEntry and connects its onBack to Android's back system.

2. Add the "completed" transitions first

Before making Back interactive, define what the finished animations look like. A destinations move horizontally, B moves vertically — so each entry needs a stable content key that says which motion family it belongs to:

private const val HomeContentKey = "home"
private const val ADetailContentKeyPrefix = "a-detail/"
private const val BDetailContentKey = "b-detail"

fun AppDestination.navigationContentKey(): String = when (this) {
    AppDestination.Home -> HomeContentKey
    is AppDestination.ADetail -> "$ADetailContentKeyPrefix$number"
    AppDestination.BDetail -> BDetailContentKey
}

private fun Scene<AppDestination>.navigationMotion(): NavigationMotion {
    val key = entries.lastOrNull()?.contentKey as? String
    return when {
        key?.startsWith(ADetailContentKeyPrefix) == true -> NavigationMotion.Horizontal
        key == BDetailContentKey -> NavigationMotion.Vertical
        else -> NavigationMotion.None
    }
}
Enter fullscreen mode Exit fullscreen mode

Attach the key when you create the entry:

NavEntry(
    key = destination,
    contentKey = destination.navigationContentKey(),
    content = entryContent,
)
Enter fullscreen mode Exit fullscreen mode

The direction you care about depends on whether you're pushing or popping. For a push, inspect targetState — the destination being opened. For a pop, inspect initialState — the destination being closed.

fun AnimatedContentTransitionScope<Scene<AppDestination>>.navigationTransition(
    direction: NavigationDirection,
    layoutDirection: LayoutDirection,
): ContentTransform = appNavigationTransform(
    motion = when (direction) {
        NavigationDirection.Forward -> targetState.navigationMotion()
        NavigationDirection.Back -> initialState.navigationMotion()
    },
    direction = direction,
    layoutDirection = layoutDirection,
)
Enter fullscreen mode Exit fullscreen mode

Here's the first place RTL will bite you if you're not careful: the horizontal transition enters from logical end and exits toward logical end on pop. In RTL, end is the left side of the screen. Hardcode a raw left/right pixel offset and your app will animate backwards for a chunk of the planet.

private fun horizontalEndMultiplier(layoutDirection: LayoutDirection): Int =
    if (layoutDirection == LayoutDirection.Ltr) 1 else -1

private fun horizontalTransform(
    forward: Boolean,
    layoutDirection: LayoutDirection,
): ContentTransform {
    val end = horizontalEndMultiplier(layoutDirection)
    val coveredParallax = { width: Int ->
        -end * (width * 0.30f).roundToInt()
    }

    return if (forward) {
        slideInHorizontally(AppNavigationSlideSpec) { width -> end * width }
            .togetherWith(slideOutHorizontally(AppNavigationSlideSpec, coveredParallax))
    } else {
        slideInHorizontally(AppNavigationSlideSpec, coveredParallax)
            .togetherWith(slideOutHorizontally(AppNavigationSlideSpec) { width -> end * width })
    }
}
Enter fullscreen mode Exit fullscreen mode

B does the analogous thing on the Y axis — enters from the bottom, exits toward the bottom. The covered Home screen barely moves: about 3% of its height, versus the stronger 30% horizontal parallax, just enough to signal depth without stealing attention.

Wire the regular transforms into NavDisplay:

NavDisplay(
    // ...
    transitionSpec = {
        navigationTransition(NavigationDirection.Forward, layoutDirection)
    },
    popTransitionSpec = {
        navigationTransition(NavigationDirection.Back, layoutDirection)
    },
)
Enter fullscreen mode Exit fullscreen mode

The floating B shortcut lives inside Home, not as its own destination. Its AnimatedVisibility uses matching vertical enter/exit specs, so it slides away as B opens and comes back as B closes — a small touch, but it's the kind of detail that makes the screen feel like one coherent surface instead of a stack of unrelated layers.

3. Give predictive pop its own transform

This is where it stops being ordinary navigation. A completed pop knows one thing: Back happened. A predictive pop knows three more — that a gesture is in progress, that it might get cancelled, and which physical edge the swipe started from.

NavDisplay hands you those cases as separate hooks:

NavDisplay(
    transitionSpec = { /* forward navigation */ },
    popTransitionSpec = { /* non-interactive Back */ },
    predictivePopTransitionSpec = { edge -> /* predictive Back */ },
    // ...
)
Enter fullscreen mode Exit fullscreen mode

The instinct is to reuse the full-width slide for predictive pop. Don't. Once the outgoing screen shrinks into a card, it no longer covers the viewport — so translating it by a full screen width mid-drag will expose empty space behind it. Instead, keep both scenes on-screen and apply the visual treatment inside each entry:

predictivePopTransitionSpec = { edge ->
    swipeEdge.value = SwipeEdge.fromNavigationEvent(edge)
    predictiveVertical.value =
        currentBackStack.lastOrNull() == AppDestination.BDetail
    predictiveTransition.value = true

    ContentTransform(
        targetContentEnter = EnterTransition.None,
        initialContentExit = ExitTransition.KeepUntilTransitionsFinished,
    )
}
Enter fullscreen mode Exit fullscreen mode

KeepUntilTransitionsFinished is the load-bearing detail: the outgoing entry has to stay composed after the back stack has already changed, long enough to finish drawing its committed exit. Drop it and the screen you're swiping away vanishes the instant you commit, before its exit animation can play.

Wrap each entry in a full-size Box and apply the card and underlay modifiers to the leaving entry and the one beneath it:

Box(
    Modifier
        .fillMaxSize()
        .predictiveBackUnderlay(
            underneath = isUnderneathTop(target, currentBackStack),
            vertical = predictiveVertical.value,
            layoutDirection = layoutDirection,
            predictiveTransition = predictiveTransition.value,
        )
        .predictiveBackCard(
            isStackTop = target == currentBackStack.lastOrNull(),
            vertical = target == AppDestination.BDetail,
            swipeEdge = swipeEdge.value,
            layoutDirection = layoutDirection,
            predictiveTransition = predictiveTransition.value,
        ),
) {
    DestinationContent(target)
    NavigationCoveredScrim(/* ... */)
}
Enter fullscreen mode Exit fullscreen mode

One subtlety that cost real debugging time: the production code remembers a stable entry-content lambda and reads the changing values through rememberUpdatedState. Without that, a transition that's already mid-flight can hang on to an obsolete back stack or layout direction, and you get bugs that only show up when you navigate during an animation — the worst kind to reproduce.

4. Turn the seekable transition into a single progress value

Here's the trick that makes everything else fall into place. LocalNavAnimatedContentScope exposes the AnimatedContentScope that NavDisplay created. Its transition becomes seekable during predictive back — meaning an animated float can act as one normalized progress value from 0 to 1 that every visual property reads from.

val transition = LocalNavAnimatedContentScope.current.transition

val progress by transition.animateFloat(
    transitionSpec = {
        tween(
            durationMillis = AppNavigationTransitionDurationMillis,
            easing = LinearEasing,
        )
    },
    label = "predictive-back-card-progress",
) { state ->
    if (state == EnterExitState.Visible) 0f else 1f
}
Enter fullscreen mode Exit fullscreen mode

During the gesture, progress follows your finger from 0 to 1. Linear easing keeps that mapping honest — 1:1 with the drag. Easing gets introduced later, and only for the post-release completion, which is a different phase with different rules. Keeping those two separate is the single most important idea in this whole article.

The card reads that one progress value and maps it to several properties at once:

graphicsLayer {
    val scale = 1f - (1f - 0.68f) * dragProgress
    scaleX = scale
    scaleY = scale

    shape = RoundedCornerShape(deviceCornerRadius)
    clip = deviceCornerRadius > 0.dp
    shadowElevation = (24.dp * dragProgress).toPx()
}
Enter fullscreen mode Exit fullscreen mode

The final project reads the device's actual rounded-corner insets and falls back to 32.dp. Matching the physical screen radius is what sells the illusion — the shrinking surface reads as a real detached screen, not an arbitrary rounded rectangle floating in space.

Horizontal and vertical movement

While an A card is being dragged, its small horizontal nudge follows the physical swipe edge:

val nudgeDirection = if (swipeEdge == SwipeEdge.Right) -1f else 1f
val maxSlidePx = 30.dp.toPx()
val gestureSlidePx = maxSlidePx * (1f - exp(-3f * dragProgress))
val oppositeEdgeSpacingPx = 12.dp.toPx() * dragProgress

val dragOffset = nudgeDirection *
    (gestureSlidePx - oppositeEdgeSpacingPx)
Enter fullscreen mode Exit fullscreen mode

The exponential curve is doing something specific: it gives you visible movement early in the drag, where responsiveness matters most, without ever letting the card travel far enough to get cropped. The extra 12 dp of spacing shows up on the edge opposite your gesture and stays safely inside the viewport.

After you release, the interaction stops being a physical drag and becomes a navigation completion. The card exits toward logical end — derived from LayoutDirection — no matter which edge you started the swipe from:

val offScreenX = horizontalEndMultiplier(layoutDirection) *
    (size.width + maxSlidePx)

translationX = lerp(dragOffset, offScreenX, flingProgress)
Enter fullscreen mode Exit fullscreen mode

For B, the same progress drives translationY, and after release the card finishes its exit below the viewport. This split is worth stating plainly, because it's the mental model that makes the code readable: gesture edge determines direct manipulation, destination type determines the motion axis, and layout direction determines the committed horizontal exit. Three different inputs, three different jobs.

5. Survive quick flings and cancellations

There's a nasty little lifecycle boundary right at commit. The moment onBack removes the last destination, that destination is no longer the stack top — even though its kept transition is still on screen, still drawing. And a quick fling can commit before Compose ever observes a frame with positive drag progress. Miss either case and the animation glitches.

The fix is to track whether you actually saw a drag frame, and to remember the progress at the instant of lift-off:

var dragSeen by remember { mutableStateOf(false) }
var liftOffProgress by remember { mutableFloatStateOf(0f) }

val dragging = isStackTop && exiting && progress > 0f
if (dragging) {
    dragSeen = true
    liftOffProgress = progress
}

val committed = !isStackTop && exiting &&
    (dragSeen || predictiveTransition)
Enter fullscreen mode Exit fullscreen mode

During commit, freeze the gesture-driven part at liftOffProgress and animate only the distance that's left:

val flingProgress = FastOutSlowInEasing.transform(
    ((progress - liftOffProgress) / (1f - liftOffProgress))
        .coerceIn(0f, 1f),
)
Enter fullscreen mode Exit fullscreen mode

Those few lines are what prevent the three most common ways this interaction breaks:

  • a quick swipe that appears to pop instantly with no animation at all;
  • a card that snaps back to full size for a frame before exiting;
  • leftover state from a cancelled gesture bleeding into the next navigation.

On cancellation, the seekable transition returns to Visible, and the modifier resets dragSeen and liftOffProgress once progress reaches zero — leaving everything clean for the next gesture.

6. Add a scrim that's synced to the same transition

The revealed screen needs to sit visually behind the outgoing card. Draw a black scrim only on the entry underneath the stack top, and — crucially — animate it from the same transition everything else uses:

val alpha by transition.animateFloat(
    transitionSpec = {
        tween(
            AppNavigationTransitionDurationMillis,
            easing = FastOutSlowInEasing,
        )
    },
) { state ->
    when {
        state == EnterExitState.Visible -> 0f
        underneath -> 0.34f
        state == EnterExitState.PreEnter &&
            direction == NavigationDirection.Back -> 0.34f
        else -> 0f
    }
}

Box(Modifier.drawBehind {
    drawRect(Color.Black, alpha = alpha)
})
Enter fullscreen mode Exit fullscreen mode

Because the scrim belongs to the underlay rather than the card, it fades out naturally as Home becomes the visible destination — no separate bookkeeping required. That's the payoff of routing every property through one source of truth: they stay in sync for free.

7. Move the underlay without exposing a single empty pixel

Leaving Home perfectly still makes the foreground card feel disconnected — like it's floating over a photograph instead of lifting off a real surface. So the final layer of motion starts Home slightly shifted and enlarged, then settles it back to its exact resting geometry as the gesture completes.

For the revealed entry, invert its enter progress:

val revealProgress by transition.animateFloat(/* ... */) { state ->
    if (state == EnterExitState.Visible) 1f else 0f
}

val effectProgress = 1f - revealProgress.coerceIn(0f, 1f)
val slidePx = 12.dp.toPx() * effectProgress
Enter fullscreen mode Exit fullscreen mode

Horizontal underlay movement follows layout direction, not swipe edge; vertical B movement uses the analogous Y offset:

if (vertical) {
    translationY = -slidePx
} else {
    val direction = -horizontalEndMultiplier(layoutDirection).toFloat()
    translationX = direction * slidePx
}
Enter fullscreen mode Exit fullscreen mode

Now the part I actually enjoyed working out. If you only translate the underlay, you reveal a thin empty strip at one edge — the pixels the layer just vacated. The scale has to create enough overhang to cover that translation.

For a layer scaled from its center, the extra coverage on each side is:

overhang = (scale - 1) * dimension / 2
Enter fullscreen mode Exit fullscreen mode

Require that overhang to be at least the current translation plus one physical safety pixel, then solve for scale:

scale >= 1 + 2 * (translation + safetyPixel) / dimension
Enter fullscreen mode Exit fullscreen mode

The implementation is that inequality, turned directly into code:

val safeOverhangPx = slidePx + 1f * effectProgress
val dimension = if (vertical) size.height else size.width
val safeScale = if (dimension > 0f) {
    1f + (2f * safeOverhangPx / dimension)
} else {
    1f
}

scaleX = safeScale
scaleY = safeScale
Enter fullscreen mode Exit fullscreen mode

At the start of the reveal, translation and scale are both at their maximum. Both then decrease monotonically toward 0px and 1f — so Home arrives at its exact resting geometry at completion, without ever zooming past it and correcting back mid-animation. That monotonicity is what keeps it from looking twitchy.

The underlay reuses the same lift-off bookkeeping as the foreground card, which is what keeps it alive during those zero-observed-frame flings and lets it settle from wherever the finger left it.

8. Test the two paths separately

Regular Back and predictive Back go through different hooks, so they genuinely are two code paths — and both need explicit checking. Here's the checklist I actually run:

  1. Open and close A with the toolbar Back button.
  2. Open A, drag slowly, cancel, and confirm every property returns to identity.
  3. Repeat and commit the gesture.
  4. Commit with a very quick fling.
  5. Start the gesture from both physical edges.
  6. Repeat the horizontal cases in RTL.
  7. Open and close B with toolbar Back, then predictive Back.
  8. Confirm no blank strip appears around the moving underlay at any progress value.

Android's predictive-back documentation covers the platform setup and the system-level back animations. The distinction worth holding onto is that this sample is an in-app transition between two Nav3 entries — a different problem from the OS-level one the docs mostly describe.

The result

The finished interaction has two clean phases. While your finger is down, the physical gesture edge drives a restrained card transformation and both screens stay fully reversible — change your mind and everything returns to identity. After you release, the remaining motion becomes an ordinary navigation completion that follows destination type and layout direction.

Keeping those two phases separate — and deriving every visual property from one seekable transition — is the whole reason it holds together across slow drags, cancellations, quick flings, vertical destinations, and RTL. None of the individual pieces are complicated. The difficulty is entirely in making them agree with each other, frame by frame, and that's exactly the part a single shared progress value buys you.

The GestureDrivenTransitions repository has the complete implementation and the step-by-step commit history this walkthrough follows. Please check it out!


Thanks for reading my first one. Curious to hear what was useful, what wasn't. If you build something with this or want to share where I could've done it better, I'd genuinely like to hear about it!

Top comments (0)