DEV Community

myougaTheAxo
myougaTheAxo

Posted on

Compose Shared Element Transitions: Seamless Screen Animations

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)
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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() }
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
                            }
                        }
                    }
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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")
                    }
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
            }
        )
)
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

Common Pitfalls

  1. Forgetting AnimatedVisibility scope: sharedElement needs the animatedVisibilityScope parameter
  2. Mismatched keys: Source and destination must use identical keys
  3. Nesting SharedTransitionLayout: Only one per navigation hierarchy
  4. Performance: Too many shared elements can impact frame rates—use sparingly

Best Practices

  • Keep shared keys consistent (use item.id or 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)