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 |
---|---|---|
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()
When the first
NavigationEvent
is received, the current width of the first pane is recorded.For all navigation events, the new width of the first pane is calculated using the progress of the event.
A
LaunchedEffect
is used to observe changes to the desired pane width, and then drag theDraggableState
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 aModifier.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 theModifier
to read private fields in the state holder that are gotten from the Composition.
First, the DragToPopState
. It has:
A
Channel
for creating aFlow
ofBackStatus
, a sealed class hierarchy describing the status of the back gesture.A suspending function
awaitEvents()
for using theDirectNavigationEventInput
to dispatch aNavigationEvent
when the drag offset changes.Flow.collectLatest()
is used because it is very important that theDirectNavigationEventInput
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 |
---|---|
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 |
---|
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:
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.Track the status of the back gesture. It can be one of:
In progress: Its ongoing
Cancelled: It was cancelled.
Committed: It was fully committed and the app went back.
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.Use the
Modifier.sharedElementWithCallerManagedVisibility
with thisBoolean
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 aModifier
.The sticky shared element has a bit of a jelly effect when translated as the backing
ApproachlayoutModifierNode
for the sticky shared elementModifier
implicitly runs approach animations as theNavEntry
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.