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.
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,
)
}
}
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
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
For using Shared Element Transition in your project:
Ensure your Jetpack Compose version is 1.7.0-alpha08 or higher.
Wrap your @Composable
functions in SharedTransitionLayout
.
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!