Shared elements¶
A shared element transition animates one piece of UI from its position on the outgoing screen to its position on the incoming one — a list row title growing into a detail header, for example.
Verified to compile, not verified visually
The scopes below are wired and the code compiles and runs on Android, but the animation itself has not been visually confirmed on a device. Treat this page as the intended API and check the result before relying on it.
Setup¶
Wrap the navigation host in SharedElementTransitionLayout, inside ContentWithOverlays if you
use overlays:
ContentWithOverlays {
SharedElementTransitionLayout(Modifier.fillMaxSize()) {
NavigableOrbitContent(navigator, backStack, Modifier.fillMaxSize())
}
}
That provides LocalSharedTransitionScope. The other half, LocalAnimatedVisibilityScope, is
provided by AnimatedNavDecoration for each record.
Marking an element¶
Give the same key to the element on both screens:
Text(
text = state.id,
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.orbitSharedElement("title-${state.id}"),
)
Keys must be unique within a screen and identical across the two screens. Deriving them from a stable id — not a list index — is what makes the pairing correct.
Degrading gracefully¶
orbitSharedElement returns the modifier unchanged when either scope is missing. A screen using
it therefore still renders correctly:
- inside
OrbitContentwith no navigation host, - under
NoOpNavDecoration, - in a
@Preview, - in a custom decoration that does not provide
LocalAnimatedVisibilityScope.
You get no animation rather than a crash.
Custom decorations¶
If you write your own NavDecoration, provide the scope or shared elements silently stop
working:
AnimatedContent(
targetState = args.first(),
contentKey = contentKey,
transitionSpec = { fadeIn() togetherWith fadeOut() },
) { arg ->
CompositionLocalProvider(LocalAnimatedVisibilityScope provides this) {
key(contentKey(arg)) { content(arg) }
}
}
Reaching the scopes directly¶
For effects orbitSharedElement does not cover, both composition locals are public:
val sharedScope = LocalSharedTransitionScope.current
val animatedScope = LocalAnimatedVisibilityScope.current
if (sharedScope != null && animatedScope != null) {
with(sharedScope) {
Box(
Modifier.sharedBounds(
rememberSharedContentState("card-$id"),
animatedVisibilityScope = animatedScope,
),
)
}
}
sharedBounds suits containers that change shape; sharedElement, which orbitSharedElement
uses, suits content that keeps its identity.
These APIs are marked @ExperimentalSharedTransitionApi by Compose, so expect them to shift
between Compose releases.