Shared Element Transitions in Jetpack Compose

Delight your users with playful animations

Introduction

In today's article we'll explore a recently released feature in Jetpack Compose, shared element transitions across different screens. The GIF below showcases what we will implement today:

Let's get started.

Creating the baseline app

The first thing we will need is an app without the shared element transitions, as our baseline. For this app we will have 2 screens, it's a typical list-detail app. For the list screen we have a column of images with some text, and the details screen will have a larger image and larger text. I will not go over these screens in detail as these are pretty basic, the interesting part will come later when we add the shared element transitions. Let's have a look at the list screen first;

This screen displays a list of cells, each consisting of a row, with an image on the left, loaded from the web using Coil, and some text on the right side. When the user clicks on one of the cells we invoke the received lambda with the URL corresponding to the image in the cell. Let's have a look at the details now:

This screen is also pretty basic, we receive a URL and we load the corresponding image with Coil and display some text below it. All we need now is connecting these 2 screens using the Jetpack Compose navigation framework:


This composable simply connects the 2 screens, we define a route for each of them, with the details taking the URL as parameter, and we navigate from the list to the details passing the URL, and from the details we navigate back by popping the backstack. So far this is pretty run of the mill. If we run this app we get this result:

As we can see, we have the default fade in / fade out transition between the 2 screens. Let's move on to adding the shared element transitions.

Shared element transitions

Dependencies

The first thing we need to do is update our dependencies; the APIs we need are not available in the stable versions of Jetpack Compose, we have to use alpha variants of a few components. Using version catalogs, the libraries and their versions that we need in alpha are:

After syncing the dependencies, we can start to modify our baseine app to add the animations.

Setting the stage

All the animations using shared elements are based on SharedTransitionLayout - this is a compose layout that exposes a SharedTransitionScope that any child composible within said layout can use to trigger shared element transitions. As we want to use shared element transitions across different screens in our app, and all the children need to be within this scope, we have to place the SharedTransitionLayout in the compose hierarchy so that all screens are a child of this node. In our case this means that the SharedTransitionLayout needs to be the parent of the NavHost, as that's the common root for all screens.

So the first thing we need to do is wrap the NavHost in a SharedTransitionLayout:

The only update is the [1] SharedTransitionLayout that is now wrapping the NavHost.

Animating the image

Next we are going to animate the image. To achieve this we will use a new Modifier, Modifier.sharedElement. This is a Modifier that takes 2 required arguments a sharedContentState that is used to identify the element for the transition, and animatedVisibilityScope, a visibility scope that allows children composables to define their own enter/exit transitions. Crucially, this is provided by the NavHost composable, so that's where we will access this scope.

So, to animate the image, what we need is to add the sharedElement  modifier to the images in the list, using a unique identifier for each of them.

I'll note at this point that the sharedElement modifier is only available within the SharedTransitionScope scope provided by SharedTransitionLayout so we need to ensure the scope is available to the ListScreen composable; we could either pass the scope as an argument and then wrap the composable with a with(scope) or, alternatively, we can make the ListScreen composable an extension function on SharedTransitionScope - here we'll do the latter.

Let's see what changes we need to implement to the list screen to enable using the image as a shared transition element:

That's all we need for the list screen, let's see how we need to update the calling site to provide these additional arguments:

The only change is to pass the animatedVisibilityScope which we can access from the NavHost. The  SharedTransitionScope is already available as this composable is wrapped in a SharedTransitionLayout.

Now we have to do the same on the details side, adding the same modifier sharedElement to the image:

As we did with the list composable, we need to update how we are calling the details composable:

As we did with the list, we pass the AnimatedVisibilityScope  that we get from the NavHost. And with this the shared image transition is complete, if we run the app we get this result:

The image is now nicely animating between the 2 screens, but the text is just fading in and out. Let's address that next.

Animating the text

Animating the text basically follows the same principle as animating the image, we need to add the modifier to animate in both the origin and destination screens, using the same key on both sides, so that the framework can connect the source with the destination.

However, if we do so, we will see that the animation is pretty rough, the text is being relayout during the transition and looks jarring. Let's see this first and then I'll show how to fix this behaviour.

So to enable the text animation we need to update the list and details composables to add the sharedElement modifier to the list and details composables as we did for the image:

in both cases we simply add the same modifier as we did for the image. Now let's see the outcome:

As we can see, it doesn't look great, the text is being relayout as the animation is undergoing. There are 2 ways we can fix this, using the skipToLookaheadSize modifier, and using the sharedBounds modifier. The skipToLookaheadSize modifier instructs the animation framework to measure the animated child at its final size and to layout the child at that size. Basically, what we would be doing by using skipToLookaheadSize is to measure the text in the details screen and lay that text at its final size. Right now what happens is that the text bounds keep changing as we animate, and the text reflows based on those constraints, and it looks bad. If we add skipToLookaheadSize to both the list and details screens as shown below, we get this result

Definitively better, the text is no longe reflowing, but the animation leaves a bit to be desired. Let's try with sharedBounds. This modifier is a cousin of sharedElement but differs in that it is meant for content that is visually different. Unlike the image, which is the same in both the list and details screen, the text is significantly different in both screens, so using sharedBounds seems more appropiate. sharedBounds takes the same arguments as sharedElement so all we have to do is replace one with the other, and remove the skipToLookaheadSize :

And with this we get a more pleasant result:

And this is pretty much it, we have our image and text animating between the list and details screens. We can make this animation a bit nicer by sliding the list to the left as the transition takes place, which we can do by adding animations to the NavHost; this gets us the animation shown at the beginning of this article:

And this concludes this article. Here we've covered only the basics of transition animations, both sharedElement and sharedBounds accept additional arguments which can be used to customize the transition animation; you can explore those options on your own to see what is possible with the transitions framework.

The code used for this article is available on this GitHub repo. Branch main contains the baseline code, while branch feature/shared_transition has the shared element transition updates.