Reputation: 466
I'm currently playing around with the Shared Element Transition in Jetpack Compose and encountered a problem.
Problem: A running animation within a shared element is reset when the Shared Element Transition happens.
Code to reproduce:
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
private fun SharedTransition(modifier: Modifier = Modifier) {
var inLeft by remember { mutableStateOf(true) }
SharedTransitionLayout(modifier) {
AnimatedContent(
modifier = Modifier.clickable { inLeft = !inLeft },
targetState = inLeft
) { targetState ->
if (targetState) {
Box(
contentAlignment = Alignment.CenterStart,
modifier = Modifier.fillMaxWidth()
) {
AnimatedBox(
sharedTransitionScope = this@SharedTransitionLayout,
animatedContentScope = this@AnimatedContent,
)
}
} else {
Box(
contentAlignment = Alignment.CenterEnd,
modifier = Modifier.fillMaxWidth()
) {
AnimatedBox(
sharedTransitionScope = this@SharedTransitionLayout,
animatedContentScope = this@AnimatedContent,
)
}
}
}
}
}
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
private fun AnimatedBox(
sharedTransitionScope: SharedTransitionScope,
animatedContentScope: AnimatedContentScope,
modifier: Modifier = Modifier
) {
with(sharedTransitionScope) {
val transition = rememberInfiniteTransition()
val color by transition.animateColor(
initialValue = Color.Green,
targetValue = Color.Black,
animationSpec = infiniteRepeatable(
tween(durationMillis = 1500, easing = LinearEasing),
RepeatMode.Reverse,
),
)
Box(
modifier
.size(BoxSize)
.sharedElement(
rememberSharedContentState(key = "animated_box"),
animatedContentScope,
)
.background(color)
)
}
}
private val BoxSize = 48.dp
This results in:
The animation should slowly fade between green and black – back and forth. But When the transition is triggered by tapping, the animation jumps to the initial bright green. This can be seen best in the video when the box is on the right side.
I was expecting the animation to not be restarted by the transition.
I tried wrapping the animated element in a movableContentOf { }
, but the result is the same:
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
private fun MovableContentSharedTransition(modifier: Modifier = Modifier) {
val animatedBox =
remember {
movableContentWithReceiverOf<SharedTransitionScope, AnimatedContentScope> { animatedContentScope ->
val transition = rememberInfiniteTransition()
val color by transition.animateColor(
initialValue = Color.Magenta,
targetValue = Color.Black,
animationSpec = infiniteRepeatable(
tween(durationMillis = 1500, easing = LinearEasing),
RepeatMode.Reverse,
),
)
Box(
Modifier
.size(48.dp)
.sharedElement(
rememberSharedContentState(key = "animated_box"),
animatedContentScope,
)
.background(color)
)
}
}
var inLeft by remember { mutableStateOf(true) }
SharedTransitionLayout {
AnimatedContent(
modifier = modifier.clickable { inLeft = !inLeft },
targetState = inLeft
) { targetState ->
if (targetState) {
Box(
contentAlignment = Alignment.CenterStart,
modifier = Modifier.fillMaxWidth()
) {
animatedBox(this@SharedTransitionLayout, this@AnimatedContent)
}
} else {
Box(
contentAlignment = Alignment.CenterEnd,
modifier = Modifier.fillMaxWidth()
) {
animatedBox(this@SharedTransitionLayout, this@AnimatedContent)
}
}
}
}
}
Compose BOM version: "androidx.compose:compose-bom:2024.09.02"
.
Question: Is there a way to keep the animation running? Is this solveable with moveableContentOf { }
?
After Thracians answer, I experimented with some further constellations.
Does moveableContentOf { }
work with the animation?
Yes. When using moveableContentOf { }
without any shared element transition, the animation is not restarted:
moveableContentOf { }
seems to work as expected – not creating a new Composable when switching boxes, but reusing the existing instance with its animation state.
@Composable
private fun MovableContentBox(modifier: Modifier = Modifier) {
val animatedBox =
remember {
movableContentOf {
Box(
Modifier
.size(BoxSize)
.background(rememberInfiniteColorChangeTransition())
)
}
}
var inLeft by remember { mutableStateOf(true) }
Box(
modifier = modifier.clickable { inLeft = !inLeft },
) {
if (inLeft) {
Box(
contentAlignment = Alignment.CenterStart,
modifier = Modifier.fillMaxWidth()
) {
animatedBox()
}
} else {
Box(
contentAlignment = Alignment.CenterEnd,
modifier = Modifier.fillMaxWidth()
) {
animatedBox()
}
}
}
}
Is it only AnimatedContent
that produces the restart problem?
No. I tried AnimatedVisibility
, and it also causes the problem.
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
private fun MovableContentAnimatedVisibilitySharedElement(modifier: Modifier = Modifier) {
val animatedBox =
remember {
movableContentWithReceiverOf<SharedTransitionScope, AnimatedVisibilityScope> { animatedContentScope ->
Box(
Modifier
.size(BoxSize)
.sharedElement(
rememberSharedContentState(key = "animated_box"),
animatedContentScope,
)
.background(rememberInfiniteColorChangeTransition())
)
}
}
var inLeft by remember { mutableStateOf(true) }
SharedTransitionLayout {
Box(
modifier = modifier.clickable { inLeft = !inLeft },
) {
AnimatedVisibility(visible = inLeft) {
Box(
contentAlignment = Alignment.CenterStart,
modifier = Modifier.fillMaxWidth()
) {
animatedBox(this@SharedTransitionLayout, this@AnimatedVisibility)
}
}
AnimatedVisibility(visible = !inLeft) {
Box(
contentAlignment = Alignment.CenterEnd,
modifier = Modifier.fillMaxWidth()
) {
animatedBox(this@SharedTransitionLayout, this@AnimatedVisibility)
}
}
}
}
}
Does .sharedElementWithCallerManagedVisibility()
with movableContentOf {}
work?
Yes:
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
private fun MovableContentSelfControlledVisibilitySharedTransition(modifier: Modifier = Modifier) {
val animatedBox =
remember {
movableContentWithReceiverOf<SharedTransitionScope, Boolean> { isVisible ->
Box(
Modifier
.size(BoxSize)
.sharedElementWithCallerManagedVisibility(
rememberSharedContentState(key = "animated_box"),
isVisible,
)
.background(rememberInfiniteColorChangeTransition())
)
}
}
var inLeft by remember { mutableStateOf(true) }
SharedTransitionLayout {
Box(
modifier = modifier.clickable { inLeft = !inLeft },
) {
Box(
contentAlignment = Alignment.CenterStart,
modifier = Modifier.fillMaxWidth()
) {
animatedBox(this@SharedTransitionLayout, inLeft)
}
Box(
contentAlignment = Alignment.CenterEnd,
modifier = Modifier.fillMaxWidth()
) {
animatedBox(this@SharedTransitionLayout, !inLeft)
}
}
}
}
Android Jetpack Navigations NavHost
uses AnimatedContent
for transitions. Shared element transitions between destination likely have the same problem (didn't test).
My guess for the cause is: The Composable
of the animated box with the moveableContentOf { }
is actually added twice at the same time in the hierarchy. Thus it can't be moved and it's state can't be shared.
Solutions thus far:
AnimatedContent
/ AnimatedVisibility
and hoist the animation state (answer by Thracian).
moveableContentOf { }
and .sharedElementWithCallerManagedVisibility()
AnimatedVisibilityScope
is provided from a different party (NavHost).Full code: https://github.com/aruh/shared-element-transition-animation-reset-sample
Upvotes: 1
Views: 128
Reputation: 67228
This is because of how AnimatedContent
works. It shows Composables between states and removes one from Composition when transition ends.
You are adding new AnimatedBox
Composables to composition and because of that new InfiniteTransition
and Color is produced each time AnimatedBox
enters composition.
If you want Color to persist just move it out of AnimatedBox and pass it as a parameter from parent via InfiniteTransition.
@Preview
@Composable
fun SharedTransition() {
var inLeft by remember { mutableStateOf(true) }
val transition: InfiniteTransition = rememberInfiniteTransition()
val color by transition.animateColor(
initialValue = Color.Green,
targetValue = Color.Black,
animationSpec = infiniteRepeatable(
tween(durationMillis = 1500, easing = LinearEasing),
RepeatMode.Reverse,
),
)
SharedTransitionLayout(Modifier) {
AnimatedContent(
modifier = Modifier.clickable { inLeft = !inLeft },
targetState = inLeft
) { targetState ->
if (targetState) {
Box(
contentAlignment = Alignment.CenterStart,
modifier = Modifier.fillMaxWidth()
) {
AnimatedBox(
sharedTransitionScope = this@SharedTransitionLayout,
animatedContentScope = this@AnimatedContent,
color = color
)
}
} else {
Box(
contentAlignment = Alignment.CenterEnd,
modifier = Modifier.fillMaxWidth()
) {
AnimatedBox(
sharedTransitionScope = this@SharedTransitionLayout,
animatedContentScope = this@AnimatedContent,
color = color
)
}
}
}
}
}
Source code
// TODO: remove screen as soon as they are animated out
val currentlyVisible = remember(this) { mutableStateListOf(currentState) }
val contentMap = remember(this) { mutableScatterMapOf<S, @Composable() () -> Unit>() }
// This is needed for tooling because it could change currentState directly,
// as opposed to changing target only. When that happens we need to clear all the
// visible content and only display the content for the new current state and target state.
if (!currentlyVisible.contains(currentState)) {
currentlyVisible.clear()
currentlyVisible.add(currentState)
}
if (currentState == targetState) {
if (currentlyVisible.size != 1 || currentlyVisible[0] != currentState) {
currentlyVisible.clear()
currentlyVisible.add(currentState)
}
if (contentMap.size != 1 || contentMap.containsKey(currentState)) {
contentMap.clear()
}
// TODO: Do we want to support changing contentAlignment amid animation?
rootScope.contentAlignment = contentAlignment
rootScope.layoutDirection = layoutDirection
}
Upvotes: 1