Compose Shared Element Transitions: Seamless Screen Animations
Jetpack Compose's SharedTransitionLayout enables stunning animations when navigating between screens. Let's explore how to create professional, smooth transitions!
What Are Shared Element Transitions?
A shared element transition animates an element as it "moves" from one screen to another. Common examples:
- Image thumbnail → expanded detail view
- List item → detail card
- Profile icon → full profile screen
In Compose, we use SharedTransitionLayout to coordinate these animations.
Core Components
1. SharedTransitionLayout
Wraps the container that holds both source and destination screens:
@Composable
fun MyApp() {
SharedTransitionLayout {
// Navigation host with transition coordination
NavHost(navController, startDestination = "list") {
composable("list") {
ListScreen(navController)
}
composable("detail") {
DetailScreen(navController)
}
}
}
}
2. sharedElement Modifier
Apply to elements you want to animate:
@Composable
fun ListItem(item: Item, onClick: () -> Unit, sharedTransitionScope: SharedTransitionScope) {
with(sharedTransitionScope) {
Image(
painter = painterResource(item.imageRes),
contentDescription = null,
modifier = Modifier
.size(100.dp)
.sharedElement(
state = rememberSharedContentState(key = "image_${item.id}"),
animatedVisibilityScope = this@animatedVisibility
)
.clickable { onClick() }
)
}
}
3. sharedBounds for Containers
Animate container sizes and positions:
@Composable
fun ListCard(item: Item) {
Card(
modifier = Modifier
.fillMaxWidth()
.sharedBounds(
sharedContentState = rememberSharedContentState(key = "card_${item.id}"),
animatedVisibilityScope = this@animatedVisibility
)
) {
Row(modifier = Modifier.padding(16.dp)) {
Image(/* ... */, modifier = Modifier.size(80.dp))
Column(modifier = Modifier.weight(1f)) {
Text(item.title, style = MaterialTheme.typography.titleMedium)
Text(item.subtitle, style = MaterialTheme.typography.bodySmall)
}
}
}
}
Full Example: List-to-Detail Navigation
List Screen
@Composable
fun ListScreen(
navController: NavHostController,
sharedTransitionScope: SharedTransitionScope
) {
val items = listOf(
Item(1, "Kotlin", "A concise language", R.drawable.kotlin_logo),
Item(2, "Compose", "Modern UI toolkit", R.drawable.compose_logo),
Item(3, "Android", "Mobile platform", R.drawable.android_logo)
)
with(sharedTransitionScope) {
LazyColumn {
items(items) { item ->
AnimatedVisibility(
visible = true,
enter = fadeIn(),
exit = fadeOut()
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.sharedBounds(
sharedContentState = rememberSharedContentState(key = "card_${item.id}"),
animatedVisibilityScope = this@AnimatedVisibility
)
.clickable {
navController.navigate("detail/${item.id}")
}
) {
Row(modifier = Modifier.padding(16.dp)) {
Image(
painter = painterResource(item.imageRes),
contentDescription = null,
modifier = Modifier
.size(80.dp)
.clip(RoundedCornerShape(8.dp))
.sharedElement(
state = rememberSharedContentState(key = "image_${item.id}"),
animatedVisibilityScope = this@AnimatedVisibility
)
)
Column(
modifier = Modifier
.weight(1f)
.padding(start = 16.dp)
) {
Text(item.title, style = MaterialTheme.typography.titleMedium)
Text(item.subtitle, style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
}
}
}
Detail Screen
@Composable
fun DetailScreen(
itemId: Int,
navController: NavHostController,
sharedTransitionScope: SharedTransitionScope
) {
val item = getItemById(itemId)
with(sharedTransitionScope) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
) {
Column(modifier = Modifier.fillMaxSize()) {
// Expanded image with shared transition
Image(
painter = painterResource(item.imageRes),
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.height(300.dp)
.sharedElement(
state = rememberSharedContentState(key = "image_${item.id}"),
animatedVisibilityScope = this@AnimatedVisibility
),
contentScale = ContentScale.Crop
)
// Content card
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.sharedBounds(
sharedContentState = rememberSharedContentState(key = "card_${item.id}"),
animatedVisibilityScope = this@AnimatedVisibility
)
) {
Column(modifier = Modifier.padding(20.dp)) {
Text(item.title, style = MaterialTheme.typography.headlineMedium)
Spacer(modifier = Modifier.height(8.dp))
Text(item.subtitle, style = MaterialTheme.typography.bodyMedium)
Spacer(modifier = Modifier.height(16.dp))
Text(item.description, style = MaterialTheme.typography.bodySmall)
}
}
Spacer(modifier = Modifier.weight(1f))
// Back button (non-shared animation)
AnimatedVisibility(
visible = true,
enter = slideInUp() + fadeIn(),
exit = slideOutDown() + fadeOut()
) {
Button(
onClick = { navController.popBackStack() },
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(16.dp)
) {
Text("Back")
}
}
}
}
}
}
Customizing Transitions with boundsTransform
Control the animation curve and duration:
Box(
modifier = Modifier
.sharedBounds(
sharedContentState = rememberSharedContentState(key = "custom_bounds"),
animatedVisibilityScope = this@AnimatedVisibility,
boundsTransform = { initial, target ->
// Custom animation spec
tween<Rect>(durationMillis = 600, easing = EaseOutCubic)
}
)
)
Combining with animateEnterExit
Non-shared elements on detail screen fade/slide in:
AnimatedVisibility(
visible = true,
enter = slideInUp() + fadeIn(),
exit = slideOutDown() + fadeOut()
) {
Text("This animates independently", style = MaterialTheme.typography.bodyLarge)
}
Common Pitfalls
- Forgetting AnimatedVisibility scope: sharedElement needs the animatedVisibilityScope parameter
- Mismatched keys: Source and destination must use identical keys
- Nesting SharedTransitionLayout: Only one per navigation hierarchy
- Performance: Too many shared elements can impact frame rates—use sparingly
Best Practices
- Keep shared keys consistent (use
item.idor similar) - Limit to 3-5 shared elements per transition
- Use boundsTransform for custom animations
- Combine with enter/exit animations for non-shared elements
- Test on low-end devices to ensure smooth 60fps
Level up your Android skills! Get 8 Android App Templates → https://myougatheax.gumroad.com
Top comments (0)