3 unique predictive back animations you can create with the navigation events library

Swipe to pop, drag to pop, and sticky shared elements animations

TJ Dahunsi

Aug 27 2025 · 16 mins

I recently wrote on the importance of persistent UI elements and how they convey a sense of placement in the app by convincingly anchoring key UI elements in place as a user navigates:

Navigation however, does not need to be atomic. Users don't always want to commit to navigation actions immediately, especially for navigation actions like going back where state is lost. Navigation instead can be progressive, offering a preview of the effect of the destructive navigation action. This is the rationale behind the predictive back design on Android, and where the new Jetpack navigation event library shines, giving precise control not only consuming navigation events, but for generating them too.

This blog post outlines 3 unique predictive back animations you can create with the navigation event library, visualized in the table below:

Slide to pop

Drag top Pop

Sticky Shared Elements

slide to pop

drag to pop

sticky_shared_element

For all animations, example code is shown in the context of the navigation 3 recipes repo, and full source is at the following pull request.

Slide to pop

One of the most frequently occurring canonical layouts are feeds and list detail views. However, it is very common to have a feed layout transition to a list detail view when an item in the feed is clicked.

A feed transitioning to a list detail layout on larger screens

When going back, a standard crossfade crossfade would work for the predictive back animation, but the "back" destination is already present and visible. A better animation may be to just simply expand the pane for the existing destination that remains, while shifting out the outgoing one.

The current code in the navigation recipes repo does this with the shared element modifier, but this is only triggered after back is pressed, or the predictive back gesture is triggered. To be a little fancier, we can make the pane widths completely dynamic at runtime and fully gesture driven with a few custom modifiers and navigation event driven state.

Gesture with the draggable Modifier

With the Navigation3 library, it's possible to render multiple NavigationEntry instances side by side, as seen in this TwoPaneSceneStrategy in the nav3-recipes repo, complete with an expanding pane predictive back transition. This is a fantastic place to start implementing custom animations. To make the layout more dynamic, we can use Modifier.Draggable to implement pane separators to resize either NavEntry.

The TwoPaneSceneStrategy already renders two entries side by side in a weighted row:

class TwoPaneScene<T : Any>( val firstEntry: NavEntry<T>, val secondEntry: NavEntry<T> ) : Scene<T> { override val content: @Composable (() -> Unit) = { Row(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.weight(0.5f)) { firstEntry.Content() } Column(modifier = Modifier.weight(0.5f)) { secondEntry.Content() } } } }

To change the width of the panes with a gesture, the weights of each wrapping Column will need to be dynamically adjusted with the gesture. For this, I'll reach for a custom compose layout I've built that has the dynamic weight adjustment, but with no gesture handling: a SplitLayout. A SplitLayout is a Row (or Column) that allows for directly manipulating the weights of its children.

@Composable fun SlideToPopLayout( ..., entries: List<NavEntry<*>> ) { val splitLayoutState = remember { SplitLayoutState( orientation = Orientation.Horizontal, maxCount = 2, initialVisibleCount = entries.size, keyAtIndex = { index -> entries[index].contentKey } ) }.also { it.visibleCount = entries.size } SplitLayout( state = splitLayoutState, modifier = Modifier.fillMaxSize(), itemContent = { index -> Box( modifier = Modifier.constrainedSizePlacement( orientation = Orientation.Horizontal, minSize = splitLayoutState.size / 3, atStart = index == 0, ) ) { entries[index].Content() } }, ) }

Next is the pane separator. A pane separator can be created using Modifier.draggable, and passing the drag deltas directly to the SplitLayoutState.dragBy(...) method. Internally, the SplitLayoutState will use the deltas to update the weight for each pane. This allows for drags on the pane separator to dynamically resize the panes.

@Composable fun SlideToPopLayout( ... ) { val density = LocalDensity.current val splitLayoutState = ... val draggableState = rememberDraggableState { splitLayoutState.dragBy( index = 0, delta = with(density) { it.toDp() } ) } SplitLayout( state = splitLayoutState, ... itemSeparators = { _, offset -> PaneSeparator( splitLayoutState = splitLayoutState, draggableState = draggableState, offset = offset, onBack = onBack, ) }, itemContent = { ... }, ) } @Composable private fun PaneSeparator( splitLayoutState: SplitLayoutState, draggableState: DraggableState, modifier: Modifier = Modifier, offset: Dp, onBack: () -> Unit, ) { val interactionSource = remember { MutableInteractionSource() } val isHovered by interactionSource.collectIsHoveredAsState() val isPressed by interactionSource.collectIsPressedAsState() val isDragged by interactionSource.collectIsDraggedAsState() val active = isHovered || isPressed || isDragged val width by animateDpAsState( label = "App Pane Draggable thumb", targetValue = if (active) DraggableDividerSizeDp else 8.dp ) val density = LocalDensity.current Box( modifier = modifier .offset { IntOffset( x = with(density) { (offset - (width / 2)).roundToPx() }, y = 0, ) } .width(width) .fillMaxHeight() ) { Box( modifier = Modifier .align(Alignment.Center) .fillMaxWidth() .height(DraggableDividerSizeDp) .draggable( state = draggableState, orientation = splitLayoutState.orientation, interactionSource = interactionSource, onDragStopped = { if (splitLayoutState.weightAt(0) > DragPopThreshold) onBack() } ) .background(MaterialTheme.colorScheme.primary, CircleShape) .hoverable(interactionSource) ) { if (active) Icon( modifier = Modifier .align(Alignment.Center), imageVector = Icons.Rounded.Code, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimary, ) } } } private val PaneSeparatorTouchTargetWidthDp = 16.dp

Consume navigation events

The last step is to use the NavigationEventHandler API to consume back events generated by the user's gestures. The events from NavigationEventHandler provide the progress of the back gesture, and the progress value can then be used to dispatch raw deltas to the DraggableState based on the width of the first pane, and the total width of the layout.

The width of the first pane in the layout is calculated from the weight using the SplitLayoutState:

val SplitLayoutState.firstPaneWidth get() = (weightAt(0) * size.value).roundToInt()
  1. When the first NavigationEvent is received, the current width of the first pane is recorded.

  2. For all navigation events, the new width of the first pane is calculated using the progress of the event.

  3. A LaunchedEffect is used to observe changes to the desired pane width, and then drag the DraggableState by those changes.

@Composable private fun SlideToPopNavigationEventHandler( enabled: Boolean, splitLayoutState: SplitLayoutState, draggableState: DraggableState, onBack: () -> Unit, ) { var started by remember { mutableStateOf(false) } var widthAtStart by remember { mutableIntStateOf(0) } var desiredPaneWidth by remember { mutableFloatStateOf(0f) } NavigationEventHandler( enabled = enabled, ) { events -> try { events.collectIndexed { index, event -> if (index == 0) { widthAtStart = splitLayoutState.firstPaneWidth started = true } val progress = event.progress val distanceToCover = splitLayoutState.size.value - widthAtStart desiredPaneWidth = (progress * distanceToCover) + widthAtStart } } finally { started = false } onBack() } // Make sure desiredPaneWidth is synced with paneSplitState.width before the back gesture LaunchedEffect(Unit) { snapshotFlow { started to splitLayoutState.firstPaneWidth } .collect { (isStarted, paneAnchorStateWidth) -> if (isStarted) return@collect desiredPaneWidth = paneAnchorStateWidth.toFloat() } } // Dispatch changes as the user presses back LaunchedEffect(Unit) { snapshotFlow { started to desiredPaneWidth } .collect { (isStarted, paneWidth) -> if (!isStarted) return@collect draggableState.dispatchRawDelta(delta = paneWidth - splitLayoutState.firstPaneWidth.toFloat()) } } }

Put it together

With the above, the SlideToPopLayout becomes:

@Composable fun SlideToPopLayout( onBack: () -> Unit, entries: List<NavEntry<*>> ) { val splitLayoutState = remember { SplitLayoutState(...) } val draggableState = rememberDraggableState { splitLayoutState.dragBy(...) } SplitLayout( state = splitLayoutState, modifier = Modifier.fillMaxSize(), itemSeparators = { _, offset -> PaneSeparator(...) }, itemContent = { ... }, ) SlideToPopNavigationEventHandler( enabled = entries.all { it.metadata.containsKey(TwoPaneScene.TWO_PANE_KEY) }, splitLayoutState = splitLayoutState, draggableState = draggableState, onBack = onBack, ) }

Shared element slide to pop

Gesture driven slide to pop

Drag to pop

There are two sides to the navigation event API; the consumer side, and the producer side. So far, only the consumer side has been explored. On the producer side, there's a NavigationEventInput which allows you to send navigation events to the NavigationEventDispatcher. This allows for converting regular UI gestures into navigation events. A classic use case for this is a drag to dismiss gesture.

Gesture with the draggable2D Modifier

Similar to the previous animation, drag to dismiss also requires drag gestures, but along two axes instead of just one. So instead of using Modifier.draggable, this gesture uses Modifier.draggable2D.

Creating a drag to dismiss Modifier is fairly straightforward. First we start with a DragToDismissState for managing a generic drag to dismiss gesture:

@Stable class DragToDismissState( internal val coroutineScope: CoroutineScope, enabled: Boolean = true, animationSpec: AnimationSpec<Offset> = DefaultDragToDismissSpring ) { var enabled by mutableStateOf(enabled) var animationSpec by mutableStateOf(animationSpec) var offset by mutableStateOf(Offset.Zero) internal set internal val draggable2DState = Draggable2DState { dragAmount -> offset += dragAmount } internal var startDragImmediately by mutableStateOf(false) }

Next, this state can be combined with Modifier.draggable2D to create a higher order Modifier; Modifier.dragToDismiss. The Modifier will need to report 3 main events:

  • Drag start.

  • Drag progress.

  • Drag end or cancel. Where a cancellation is a drag that stopped without reaching the dismissal threshold.

This can be achieved with a Modifier with the following signature:

fun Modifier.dragToDismiss( state: DragToDismissState, shouldDismiss: (Offset, Velocity) -> Boolean, onStart: () -> Unit = {}, onCancelled: (reset: Boolean) -> Unit = {}, onDismissed: () -> Unit, ): Modifier = draggable2D( state = state.draggable2DState, startDragImmediately = state.startDragImmediately, enabled = state.enabled, onDragStarted = { onStart() }, onDragStopped = { velocity -> if (shouldDismiss(state.offset, velocity)) { onDismissed() state.offset = Offset.Zero } else { onCancelled(false) // animate resetting the dragged item back to its original position ... onCancelled(true) } } )

The full source of the Modifier has been omitted for brevity, but can be seen in at the link below:

Link.

This modifier can then be built upon for a Modifier.dragToPop() that has access to the app's navigation state.

Produce navigation events

Integrating the above with navigation events requires yet another higher order Modifier, Modifier.dragToPop(). This is because access to the composition is needed for the following:

  • The current NavigationEventDispatcherOwner.

  • Launching an effect for creating a snapshotFlow for drag offset changes.

This particular modifier:

  • Delegates to the just defined Modifier.dragToDismiss() that does not have a Modifier.Node implementation.

  • Needs access to a CoroutineScope and Composition locals.

A pattern I like to follow for these kinds of modifiers, is to:

  • Use a UI logic state holder with a private constructor. The constructor has arguments that are retrieved from the Composition.

  • Create a Companion object for the state holder that:

    • Has a public rememberStateHolder Composable function: This fishes out variables needed from the Composition, and passes them to the private constructor of the state holder.

    • Holds the actual Modifier function: This allows the Modifier to read private fields in the state holder that are gotten from the Composition.

First, the DragToPopState. It has:

  • A Channel for creating a Flow of BackStatus, a sealed class hierarchy describing the status of the back gesture.

  • A suspending function awaitEvents() for using the DirectNavigationEventInput to dispatch a NavigationEvent when the drag offset changes. Flow.collectLatest() is used because it is very important that the DirectNavigationEventInput sends inputs in the correct order.

@Stable class DragToPopState private constructor( private val dismissThresholdSquared: Float, private val dragToDismissState: DragToDismissState, private val input: DirectNavigationEventInput, ) { private val channel = Channel<BackStatus>() private var dismissOffset by mutableStateOf<IntOffset?>(null) suspend fun awaitEvents() { channel.consumeAsFlow() .collectLatest { status -> when (status) { BackStatus.Completed.Cancelled -> { input.cancel() } BackStatus.Completed.Commited -> { input.complete() } BackStatus.Seeking -> { input.start(dragToDismissState.navigationEvent(progress = 0f)) snapshotFlow(dragToDismissState::offset).collectLatest { input.progress( dragToDismissState.navigationEvent( min( a = dragToDismissState.offset.getDistanceSquared() / dismissThresholdSquared, b = 1f, ) ) ) } } } } } } sealed class BackStatus { data object Seeking : BackStatus() sealed class Completed : BackStatus() { data object Commited : Completed() data object Cancelled : Completed() } } private fun DragToDismissState.navigationEvent( progress: Float ) = NavigationEvent( touchX = offset.x, touchY = offset.y, progress = progress, swipeEdge = NavigationEvent.EDGE_LEFT, )

Next, is rememberDragToPopState, which fishes things out of the Composition like the density to calculate the dismissal threshold, and properly sets up the DirectNavigationInput:

@Stable class DragToPopState private constructor( ... ) { ... companion object { @Composable fun rememberDragToPopState( dismissThreshold: Dp = 200.dp ): DragToPopState { val floatDismissThreshold = with(LocalDensity.current) { dismissThreshold.toPx().let { it * it } } val dragToDismissState = rememberUpdatedDragToDismissState() val dispatcher = checkNotNull( LocalNavigationEventDispatcherOwner.current ?.navigationEventDispatcher ) val input = remember(dispatcher) { DirectNavigationEventInput() } DisposableEffect(dispatcher) { dispatcher.addInput(input) onDispose { dispatcher.removeInput(input) } } val dragToPopState = remember(dragToDismissState, input) { DragToPopState( dismissThresholdSquared = floatDismissThreshold, dragToDismissState = dragToDismissState, input = input ) } LaunchedEffect(dragToPopState) { dragToPopState.awaitEvents() } return dragToPopState } } }

Finally, the Modifier is defined, which does not need access to the Composition; just its state holder.

@Stable class DragToPopState private constructor( ... ) { ... companion object { fun Modifier.dragToPop( dragToPopState: DragToPopState, ): Modifier = dragToDismiss( state = dragToPopState.dragToDismissState, shouldDismiss = { offset, _ -> offset.getDistanceSquared() > dragToPopState.dismissThresholdSquared }, // Enable back preview onStart = { dragToPopState.channel.trySend(BackStatus.Seeking) }, onCancelled = cancelled@{ hasResetOffset -> if (hasResetOffset) return@cancelled dragToPopState.channel.trySend(BackStatus.Completed.Cancelled) }, onDismissed = { dragToPopState.dismissOffset = dragToPopState.dragToDismissState.offset.round() dragToPopState.channel.trySend(BackStatus.Completed.Commited) } ) .offset { dragToPopState.dismissOffset ?: dragToPopState.dragToDismissState.offset.round() } } }

Put it together

Use of the Modifier is like any Modifier; in the lambda for the Profile NavEntry, its content Composable invokes Modifier.dragToPop().

entry<Profile> { profile -> ContentGreen( title = "Profile (single pane only)", modifier = Modifier .dragToPop(rememberDragToPopState()) ) }

Predictive back pop

Drag to pop

Predictive back pop

Drag to pop

Sticky shared element back previews

This last example is the most complex, as it requires a fair amount of book keeping that is typically the first sign that your app may want to create its own custom NavDisplay with the standard NavDisplay as a delegate. Let's start by outlining the problem.

Controlling the shared element Modifier

Typically, use of Modifier.sharedElement takes in an AnimatedVisibilityScope as a parameter. This, in combination with the Transition used in the NavDisplay means that by default, shared element transitions between navigation destinations are seekable. Adding a circle for the profile's avatar illustrates this:

Non sticky shared element

Non sticky shared element

However, this can be an undesired effect. When going back from screens where media is the focus, a "sticky shared element" is often desired, where the shared element adheres onto the outgoing screen until the back gesture is fully committed.

To create a sticky shared element Modifier, we need absolute control of when the shared element transition is triggered, and Compose has an API specifically for this: Modifier.sharedElementWithCallerManagedVisibility. With this API, instead of passing an AnimatedVisibilityScope to manage the transition, a visible flag is passed, and changes to it trigger a shared element transition. The next step therefore is knowing exactly when to trigger the transition.

Observe navigation event state

In the two examples above, the snippets were active consumers or producers of navigation events. This example however, requires a much more passive approach. The NavigationEventDispatcher provides a state field, for reading the latest NavigationEventState as a StateFlow, without interrupting any other NavigationEventHandler instances that want to handle the events. Better yet, it provides information about the NavigationEventHandler currently handling the event through the NavigationEventState.currentInfo field, so passive observers can selectively enable or disable behavior.

For this example, the passively observed state will need to be tightly synchronized with the current NavEntry shown, as well as the Transition running in the NavDisplay by using the following steps:

  1. Create a custom scene key that encodes the exact state of the backstack it was created for. This is because even after the predictive back gesture has been cancelled, the NavDisplay animates into a settled position instead of stopping its transition abruptly.

  2. Track the status of the back gesture. It can be one of:

    1. In progress: Its ongoing

    2. Cancelled: It was cancelled.

    3. Committed: It was fully committed and the app went back.

  3. Combine the information from the two steps above in a UI logic state holder to produce a Boolean value for whether or not the shared element is visible.

  4. Use the Modifier.sharedElementWithCallerManagedVisibility with this Boolean value.

Creating a custom scene key

Since the custom scene key needs to uniquely identify a backstack, a list of the content keys of each NavEntry in the back stack is used to identify it:

data class TwoPaneSceneKey( val contentKeys: List<Any>, )

With this custom scene key, it becomes possible to identify what scenes the backing Transition in the NavDisplay is animating between:

private val Transition<*>.sceneTargetDestinationKey: TwoPaneSceneKey? get() { val target = parentTransition?.targetState as? Pair<*, *> ?: return null return target.second as TwoPaneSceneKey } private val Transition<*>.sceneCurrentDestinationKey: TwoPaneSceneKey? get() { val target = parentTransition?.currentState as? Pair<*, *> ?: return null return target.second as TwoPaneSceneKey }

Tracking the status of the predictive back gesture

Next, is determining the current status of the predictive back gesture which can be done with a functional programming style fold. It determines if the back gesture was cancelled by checking if the NavigationEventState.currentInfo is the same between consecutive states.

@Composable private fun rememberBackStatus(): State<BackStatus> { val navigationEventDispatcher = LocalNavigationEventDispatcherOwner.current!! .navigationEventDispatcher return remember(navigationEventDispatcher) { navigationEventDispatcher.state .filter { it.currentInfo is NavDisplayInfo } .distinctUntilChangedBy { it.progress } .scan( listOf(navigationEventDispatcher.state.value) ) { history, state -> (history + state).takeLast(2) } .filter { it.size == 2 } .map { (previous, current) -> when (current) { is NavigationEventState.Idle<*> -> when (previous.currentInfo) { current.currentInfo -> BackStatus.Completed.Cancelled else -> BackStatus.Completed.Commited } is NavigationEventState.InProgress<*> -> BackStatus.Seeking } } } .collectAsState(BackStatus.Completed.Commited) }

Managing sticky shared element state

Finally, the state holder for managing the visibility of the sticky shared element can be created. In a production app, this most likely will be the ScaffoldState covered in the following article:

Link.

For this example, the following suffices:

@Composable fun rememberStickyAnimatedVisibilityScope(): StickyAnimatedVisibilityScope { val backStatus = rememberBackStatus() val animatedContentScope = LocalNavAnimatedContentScope.current return remember(animatedContentScope) { StickyAnimatedVisibilityScope( backStatus = backStatus::value, animatedContentScope = animatedContentScope, ) } } class StickyAnimatedVisibilityScope( val backStatus: () -> BackStatus, animatedContentScope: AnimatedContentScope, ) : AnimatedVisibilityScope by animatedContentScope { val isStickySharedElementVisible: Boolean get() = if (isShowingBackContent) !isEntering else isEntering private val isEntering get() = transition.targetState == EnterExitState.Visible private val isShowingBackContent: Boolean get() { val currentSize = transition.sceneCurrentDestinationKey?.contentKeys?.size ?: 0 val targetSize = transition.sceneTargetDestinationKey?.contentKeys?.size ?: 0 val animationIsSettling = targetSize < currentSize return when (backStatus()) { BackStatus.Seeking -> animationIsSettling BackStatus.Completed.Cancelled -> animationIsSettling BackStatus.Completed.Commited -> false } } }

Put it together

There has been quite a bit of work done so far, and unfortunately, it comes with a few caveats:

  • While the sticky shared element is in fact sticky, it is exempt from ALL transitions being run in the scene. This means that any transitions specified in the NavDisplay will simply not affect it. Scale animations will need to be done manually in layout with a Modifier.

  • The sticky shared element has a bit of a jelly effect when translated as the backing ApproachlayoutModifierNode for the sticky shared element Modifier implicitly runs approach animations as the NavEntry layout is displaced.

To work around this, the default predictivePopTransitionSpec in the NavDisplay for the profile NavEntry is removed. Instead, the its NavEntry is wrapped with a Box, and Modifier.fillMaxSize() with a fraction derived from the navigation event's progress is used for the scale down animation.

entry<Profile>( metadata = NavDisplay.predictivePopTransitionSpec { // Remove the default pop back transition spec, the scale // down animation will be done manually in layout EnterTransition.None togetherWith fadeOut(targetAlpha = 0.8f) } ) { profile -> val stickyAnimatedVisibilityScope = rememberStickyAnimatedVisibilityScope() val scale = LocalNavigationEventDispatcherOwner.current!! .navigationEventDispatcher .state .map { when (it.currentInfo) { is NavDisplayInfo -> max(1f - it.progress, 0.7f) else -> 1f } } .collectAsState(1f) Box( modifier = Modifier .fillMaxSize() ) { ContentGreen( title = "Profile (single pane only)", modifier = Modifier .align(Alignment.Center) .dragToPop(rememberDragToPopState()) // Use the scale from the navigation event progress // to manually scale down the size of the container .fillMaxSize(fraction = scale.value) ) { val profileColor = Color.fromColorLong(profile.colorLong) Box( modifier = Modifier .statusBarsPadding() .padding(vertical = 16.dp) // Use the sticky shared element .sharedElementWithCallerManagedVisibility( sharedContentState = rememberSharedContentState( profileColor ), visible = stickyAnimatedVisibilityScope.isStickySharedElementVisible, ) .background( color = profileColor, shape = CircleShape, ) .size(80.dp) ) } } }

The impact of this shown in the table below. Notice how the default predictivePopTransitionSpec scales down the text in the second screen recording, but leaves the sticky shared element the same size. In the third recording, a manual layout scale animation, the parent layout is scaled down, but all elements inside it keep their original size.

Default seeking shared element

Sticky shared element with default predictivePopTransitionSpec

Sticky Shared Element with manual layout scale

Ultimately, with the current version of Jetpack Compose, it comes down to what motion artifacts you can tolerate with shared elements when doing a predictive back animation.

Wrap up

Though its still in alpha, the navigation events library is already looking like an indispensable utility for disambiguating the nuance of events and state with respect to navigation. While navigation 3 provides APIs for treating navigation as state, the navigation events library complements it by providing enough information to create frame perfect animations in tight coordination with the navigation state and general application state.

In a way, its a bit Goldilocks in its approach - it offers specific and semantic APIs for the observers, producers and even consumers of app navigation metadata.

,