paint-brush
Shared Element Transition in Jetpack Compose: A Guideby@arttttt
306 reads
306 reads

Shared Element Transition in Jetpack Compose: A Guide

by Android InsightsJuly 13th, 2024
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

Artem explains how to animate your app using **Shared Element Transition**. This feature allows UI elements to animate as they transition between app screens. To demonstrate, I've created a simple app with just two screens: a list on the first screen and detailed information on the second.
featured image - Shared Element Transition in Jetpack Compose: A Guide
Android Insights HackerNoon profile picture

Hello! My name is Artem, and I develop Android applications.


Recently, I started sharing my experiences on this topic.


This article is a text version of a video on my YouTube channel Android Insights.


In this article, I want to explain how to animate your app using Shared Element Transition. This feature allows UI elements to animate as they transition between app screens, making the user interface more memorable.


This feature was recently added, so ensure your project uses Jetpack Compose version 1.7.0-alpha07 or higher.


To demonstrate, I've created a simple app with just two screens: a list on the first screen and detailed information on the second.

Basic Application

For simplicity, I'm not using any navigation libraries. Here's the code for the root @Composable function:

@Composable
fun CatsContent(modifier: Modifier) {
   var selectedCat: Cat? by remember { mutableStateOf(null) }


   if (selectedCat != null) {
       BackHandler { selectedCat = null }


       CatDetails(
           modifier = modifier.fillMaxSize(),
           cat = selectedCat!!,
       )
   } else {
       CatsList(
           modifier = modifier.fillMaxSize(),
           onCatClicked = { cat ->
               selectedCat = cat
           }
       )
   }
}

When an item in the list is clicked, it is assigned to the selectedCat variable. If selectedCat is not null, the @Composable with the detailed information is displayed.


Here's the code for CatDetails and CatsList functions:

@Composable
fun CatDetails(
   modifier: Modifier,
   cat: Cat,
) {
   Column(
       modifier = modifier.verticalScroll(
           state = rememberScrollState(),
       ),
       verticalArrangement = Arrangement.spacedBy(8.dp),
   ) {
       Image(
           painter = painterResource(cat.iconRes),
           contentDescription = null,
       )


       Text(
           modifier = Modifier
               .padding(
                   horizontal = 8.dp,
               ),
           text = stringResource(cat.textRes)
       )
   }
}

@Composable
fun CatsList(
   modifier: Modifier,
   onCatClicked: (Cat) -> Unit,
) {
   val cats = rememberCatsList()


   Box(
       modifier = modifier,
   ) {
       LazyColumn(
           verticalArrangement = Arrangement.spacedBy(8.dp),
       ) {
           items(
               items = cats,
               key = { cat -> cat.iconRes },
               contentType = { cat -> cat::class }
           ) { item ->
               Cat(
                   modifier = Modifier
                       .fillParentMaxWidth()
                       .clickable {
                           onCatClicked(item)
                       }
                       .padding(
                           horizontal = 8.dp
                       ),
                   cat = item,
               )
           }
       }
   }
}


@Composable
fun Cat(
   modifier: Modifier,
   cat: Cat,
) {
   Row(
       modifier = modifier,
       horizontalArrangement = Arrangement.spacedBy(8.dp),
       verticalAlignment = Alignment.CenterVertically,
   ) {
       Image(
           modifier = Modifier.size(128.dp),
           painter = painterResource(cat.iconRes),
           contentScale = ContentScale.Crop,
           contentDescription = null,
       )


       Text(
           modifier = Modifier,
           text = stringResource(cat.textRes),
           maxLines = 3,
           overflow = TextOverflow.Ellipsis,
       )
   }
}

Adding Transition

Now, let's add the rest of the required code in two steps

// OptIn is required because ExperimentalSharedTransitionApi is unstable
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun CatsContent(
   modifier: Modifier
) {
   var selectedCat: Cat? by remember { mutableStateOf(null) }


   SharedTransitionLayout {
       AnimatedContent(
           targetState = selectedCat != null
       ) { targetState ->
           if (targetState) {
               BackHandler { selectedCat = null }


               CatDetails(
                   modifier = modifier.fillMaxSize(),
                   cat = selectedCat!!,
               )
           } else {
               CatsList(
                   modifier = modifier.fillMaxSize(),
                   onCatClicked = { cat ->
                       selectedCat = cat
                   }
               )
           }
       }
   }
}


Application behavior has already changed

Animated transition between screens


There are animations for transitioning between screens using the AnimatedContent function, but this is still not what we need.


It's time to add the rest of the code.

@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun CatsList(
   modifier: Modifier,
   onCatClicked: (Cat) -> Unit,
   sharedTransitionScope: SharedTransitionScope,
   animatedVisibilityScope: AnimatedVisibilityScope,
) {
   val cats = rememberCatsList()


   Box(
       modifier = modifier,
   ) {
       LazyColumn(
           verticalArrangement = Arrangement.spacedBy(8.dp),
       ) {
           items(
               items = cats,
               key = { cat -> cat.iconRes },
               contentType = { cat -> cat::class }
           ) { item ->
               Cat(
                   modifier = Modifier
                       .fillParentMaxWidth()
                       .clickable {
                           onCatClicked(item)
                       }
                       .padding(
                           horizontal = 8.dp
                       ),
                   cat = item,
                   // passing SharedTransitionScope
                   sharedTransitionScope = sharedTransitionScope,
                   // passing AnimatedVisibilityScope
                   animatedVisibilityScope = animatedVisibilityScope,
               )
           }
       }
   }
}


@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun Cat(
   modifier: Modifier,
   cat: Cat,
   sharedTransitionScope: SharedTransitionScope,
   animatedVisibilityScope: AnimatedVisibilityScope,
) {
   with(sharedTransitionScope) {
       Row(
           modifier = modifier,
           horizontalArrangement = Arrangement.spacedBy(8.dp),
           verticalAlignment = Alignment.CenterVertically,
       ) {
           Image(
               modifier = Modifier
                   .size(128.dp)
                   // using brand new Modifier sharedElement 
                   .sharedElement(
                       state = rememberSharedContentState(key = cat.iconRes.toString()),
                       animatedVisibilityScope = animatedVisibilityScope,
                   ),
               painter = painterResource(cat.iconRes),
               contentScale = ContentScale.Crop,
               contentDescription = null,
           )


           Text(
               modifier = Modifier,
               text = stringResource(cat.textRes),
               maxLines = 3,
               overflow = TextOverflow.Ellipsis,
           )
       }
   }
}

@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun CatDetails(
    modifier: Modifier,
    cat: Cat,
    sharedTransitionScope: SharedTransitionScope,
    animatedVisibilityScope: AnimatedVisibilityScope,
) {
    with(sharedTransitionScope) {
        Column(
            modifier = modifier.verticalScroll(
                state = rememberScrollState(),
            ),
            verticalArrangement = Arrangement.spacedBy(8.dp),
        ) {
            Image(
                // using brand new Modifier sharedElement
                modifier = Modifier.sharedElement(
                    state = rememberSharedContentState(key = cat.iconRes.toString()),
                    animatedVisibilityScope = animatedVisibilityScope,
                ),
                painter = painterResource(cat.iconRes),
                contentDescription = null,
            )

            Text(
                modifier = Modifier
                    .padding(
                        horizontal = 8.dp,
                    ),
                text = stringResource(cat.textRes)
            )
        }
    }
}


This time, everything looks as it was intended. The Image moves animatedly from one screen to another


What changed in the code? The use of a Modifier has been added:

Modifier.sharedElement


This Modifier, along with the SharedTransitionLayout, is responsible for the magic of moving shared content.


In order to be able to use it, you must be inside the SharedTransitionScope. This can be achieved in several ways:

// use the function with/run/apply etc. 
with(sharedTransitionScope)

// create an extension to SharedTransitionScope
fun SharedTransitionScope.Cat

// use context receivers, in which case you can move the
// AnimatedVisibilityScope there so as not to pass it as a parameter
context(SharedTransitionScope, AnimatedVisibilityScope)
@OptIn(ExperimentalSharedTransitionApi::class) 
@Composable 
fun Cat


This is the minimum set of parameters that need to be passed to sharedElement for it to work correctly

sharedElement(
  state = rememberSharedContentState(
    key = cat.iconRes.toString(),
  ),
  animatedVisibilityScope = animatedVisibilityScope,
)


state - to track the state of the animation. If there are several elements on the screen that can be animated, then it is imperative to use a unique key for each of the @Composable.


animatedVisibilityScope - scope for the transition animation itself


You also need to use Modifier.sharedElement on the screen you are navigating to. The key on the target screen must match where the transition started, otherwise nothing will work.


Also, since Shared Element Transition has just appeared in Jetpack Compose, everything is in an experimental state and an annotation needs to be added

@OptIn(ExperimentalSharedTransitionApi::class)


For each function that uses this functionality. Basically, that's all you need to know to get started using Shared Element Transition. Let's take a quick recap

Conclusion

For using Shared Element Transition in your project:

  1. Ensure your Jetpack Compose version is 1.7.0-alpha08 or higher.


  2. Wrap your @Composable functions in SharedTransitionLayout.


  3. Ensure the keys are passed in rememberSharedContentState are unique for each @Composable to animate and match the keys in the destination.


Thanks to all. I hope you found this information helpful!