Ordinary navigation transitions cross-fade or slide the whole screen. The thumbnail in the list disappears, then reappears full-size in the detail view — two unrelated images as far as the eye is concerned, with no visual continuity between them.
#The old way
@Composable
fun ImageDetail(imageUrl: String) {
var isExpanded by remember { mutableStateOf(false) }
if (!isExpanded) {
AsyncImage(
model = imageUrl,
contentDescription = null,
modifier = Modifier
.size(80.dp)
.clickable { isExpanded = true }
)
} else {
AsyncImage(
model = imageUrl,
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.clickable { isExpanded = false }
)
}
}
80.dp one frame, full width the next. No morph, no continuity — a jump.
#The new way
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun ImageDetail(imageUrl: String) {
var isExpanded by remember { mutableStateOf(false) }
SharedTransitionLayout {
AnimatedContent(targetState = isExpanded, label = "image-expand") { expanded ->
AsyncImage(
model = imageUrl,
contentDescription = null,
modifier = if (!expanded) {
Modifier
.size(80.dp)
.sharedElement(
rememberSharedContentState(key = "hero-image"),
animatedVisibilityScope = this@AnimatedContent
)
.clickable { isExpanded = true }
} else {
Modifier
.fillMaxWidth()
.aspectRatio(1f)
.sharedElement(
rememberSharedContentState(key = "hero-image"),
animatedVisibilityScope = this@AnimatedContent
)
.clickable { isExpanded = false }
}
)
}
}
}
#Why it matters
- The element morphs position and size across the transition instead of cutting. It reads as one continuous object rather than two unrelated images.
rememberSharedContentState(key)is what links the two states. Matching keys on both sides, and Compose interpolates the bounds for you.- Works with
AnimatedContent, as shown here, or withNavHost— so it isn't limited to top-level navigation between screens.
#Gotcha
sharedElement only animates when source and target are both inside the same SharedTransitionScope and part of the same AnimatedContent/NavHost transition at the moment it runs. Move the expanded state into a separate Dialog, or into an unrelated composition, and the modifier silently does nothing. No crash. No animation. A hard cut, and no error to point at.