Adetunji Dahunsi

Animating ContentScale during image shared element transitions

A Jetpack Compose twist on an old favorite

TJ Dahunsi

Mar 21 2024 ยท 7 min read

I'm obsessed with animations in apps. They provide a sense of place, grant visual continuity and greatly improve the user experience. For details as to why, read this evergreen blog post about building smarter animations with motional intelligence.

Shared element transitions today

This sense of place and continuity is brought to the fore for navigation animations in apps with shared elements, especially with media. To simplify things, this blog will restrict media to just images. Shared elements in the Android View system rely on a smoke and mirrors effect to create the illusion that an element is moving from one destination to the next. It goes something like this:

  • Screen A passes the size and position of the shared element to screen B.
  • Screen B reviews the information passed to it and matches its own views to the shared elements.
  • Screen B then places the matching views where they used to be on Screen A.
  • Screen B finally animates them to their final position.

This generally works fine, however there are few things that bother me about this:

  • There are 2 views created that render the same information with minor differences, if any at all.
  • If the image fetched is asynchronous, and the image has had a transformation applied (different aspect ratio for example); you may have to use Fragment.postponeEnterTransition()to postpone the transition and wait till the image on screen B is loaded. After loading is complete, Fragment.startPostponedEnterTransition() may be used to resume the transition. This interruption can cause jank.
  • If the image is being loaded in a different aspect ratio, there's still a mild visual discontinuity as the size and position of the shared element is animated, but the ContentScale is not.

Can Jetpack Compose improve on this?

Thinking differently with Jetpack Compose

I LOVE Jetpack Compose. It requires a new mental model, but with it comes new, and often better ways to approach and solve existing problems. Specifically, with the problem defined above, there are a few neat APIs we can take advantage of to have the closest literal implementation of the concept of a shared element transition on any platform. They are:

  • Compose general animation and state management APIs: To interpolate the ContentScale
  • moveableContentOf: To move UI in composition from one place to another.
  • LookaheadScope: To perform lookahead measurements to guide moving elements to their destination.
  • ApproachLayoutModifierNode: Implementation Modifier.Node used to guide elements that have moved to their destination after lookahead.

Interpolating ContentScale

Interpolating ContentScale in Compose is as simple as using the linear interpolation lerp function. To do so, a Composable can be written that remembers and updates the current ContentScale, and updates the previous ContentScale whenever the current one changes.

When a change occurs, an animation is launched to drive the interpolation fraction from 0f to 1f. Then an anonymous ContentScale that reads this fraction is finally returned to act as the real ContentScale in the composition.

1@Composable 2private fun ContentScale.interpolate(): ContentScale { 3 var interpolation by remember { 4 mutableFloatStateOf(1f) 5 } 6 var previousScale by remember { 7 mutableStateOf(this) 8 } 9 val currentScale by remember { 10 mutableStateOf(this) 11 }.apply { 12 if (value != this@interpolate) previousScale = when { 13 interpolation == 1f -> value 14 else -> CapturedContentScale( 15 capturedInterpolation = interpolation, 16 previousScale = previousScale, 17 currentScale = value 18 ) 19 }.also { interpolation = 0f } 20 value = this@interpolate 21 } 22 23 LaunchedEffect(currentScale) { 24 animate( 25 initialValue = 0f, 26 targetValue = 1f, 27 animationSpec = spring( 28 stiffness = Spring.StiffnessMedium 29 ), 30 block = { progress, _ -> 31 interpolation = progress 32 }, 33 ) 34 } 35 36 return remember { 37 object : ContentScale { 38 override fun computeScaleFactor( 39 srcSize: Size, 40 dstSize: Size 41 ): ScaleFactor { 42 val start = previousScale.computeScaleFactor( 43 srcSize = srcSize, 44 dstSize = dstSize 45 ) 46 val stop = currentScale.computeScaleFactor( 47 srcSize = srcSize, 48 dstSize = dstSize 49 ) 50 return if (start == stop) stop 51 else lerp( 52 start = start, 53 stop = stop, 54 fraction = interpolation 55 ) 56 } 57 } 58 } 59}

If for any reason the interpolation animation is interrupted before it completes, an intermediate ContentScale that captures the interpolation progress is created and set as the current ContentScale and the process continues as normal:

1private class CapturedContentScale( 2 private val capturedInterpolation: Float, 3 private val previousScale: ContentScale, 4 private val currentScale: ContentScale, 5 ) : ContentScale { 6 7 override fun computeScaleFactor( 8 srcSize: Size, 9 dstSize: Size 10 ): ScaleFactor = lerp( 11 start = previousScale.computeScaleFactor( 12 srcSize = srcSize, 13 dstSize = dstSize 14 ), 15 stop = currentScale.computeScaleFactor( 16 srcSize = srcSize, 17 dstSize = dstSize 18 ), 19 fraction = capturedInterpolation 20 ) 21}

This can be put together for the demo screen below:


Moving shared elements between navigation destinations

Now that ContentScale can be interpolated, the next step is actually sharing a Composable across navigation destinations while preserving its existing state. More succintly, I'd like to perform a Composable transplant.

The definition that follows is the Composable I want to move:

1data class ImageArgs( 2 val url: String?, 3 val description: String? = null, 4 val contentScale: ContentScale 5) 6 7@Composable 8fun Image( 9 ImageArgs: ImageArgs, 10 modifier: Modifier 11) { 12 Box(modifier) { 13 AsyncImage( 14 modifier = modifier, 15 model = ImageArgs.url, 16 contentDescription = null, 17 contentScale = ImageArgs.contentScale.interpolate() 18 ) 19 } 20}

To achieve this, I need:

  • A single LookaheadScope at the root of the navigation hierarchy.
  • Screen A to use a moveableContentOf Composable with an ApproachLayoutModifierNode attached to render its shared element.
  • Screen A to signal navigation to Screen B.
  • The navigation state to be read on both screens.
  • Screen A to immediately stop composing the moveable shared element.
  • Screen B to immediately start composing the shared element.
  • The animation APIs to gradually animate the size, position AND the ContentScale of the shared element to the destination.

When implemented, I achieve the following result:


In the above, the shared element state is preserved across both screens. i.e; it is a literal shared element. No dual views, no smoke and mirrors, no postponing and starting transitions. Just well designed and aptly named Jetpack Compose APIs doing exactly what they say on the tin. I still find it mind blowing that this is even possible. This is one of those things that I'm not even sure where I'd start to do this with Views.

Thinking outside the Box() {}

So that was cool. Let's push on a bit.

Predictive Back

Predictive back in Android is a cool feature that lets users see where they're going when they perform the back gesture without committing to it. Since we're pushing boundaries, let's assume that apps can have 1 - N navigation destinations active at any one time.

Using the setup described above, a skeuomorphic predictive back navigation transition of quite literally popping a navigation destination off the top of a stack can be created, while still having the shared element fall to the lower destination that will end up at the top.


Large screens

You're not sated? Perhaps you're even unimpressed. Maybe 2 simultaneous navigation destinations isn't impressive. Fair shout. May I interest you in 3? With the shared element transition still working?


Declarative APIs for Declarative UIs

The above is part of a series of experiments I have under the working title "Declarative APIs for Declarative UIs".

The basic argument is, Jetpack Compose is amazing and powerful, but it is a significant paradigm shift. Should we also start to rethink the APIs that assemble the state for Compose in the UI layer to get the best from it?

I think the answer is yes, and phrased even stronger: Jetpack Compose requires first frame ready developer readable and writable state production all across the UI layer to truly shine as the next generation UI toolkit that it is.

In the examples above, I needed the following APIs to be first frame ready in the UI layer:

  • Pagination as state: Paginated data is an immutable List subclass that can be written to and read from at any time. For shared elements with navigation, the pagination pipeline can be seeded from navigation arguments and available instantaneously at the point the shared element transition starts. My experiment for this is Tiler.
  • Navigation as state: Navigation data is immutable data hoisted outside the composition and transparently readable and writable anywhere in the application. Changes to navigation are read downstream, and combined with other UI signals like display size, can be dynamically adapted to the visible UI. My experiment for this is treeNav.
  • A robust UDF state management solution: I personally prefer MVI, and my experiment for this is Mutator.

Each of these will have dedicated blog posts deconstructing how they contributed to the examples shown above, so stay tuned. Also, if you're still on the fence on Compose, you really ought to come on down. It's quite nice here.

The app hosting all these experiments can be found below.