Container transform is an animation pattern that transforms one container into another, typically using a shared element to connect two UI elements.
This article is focused on the implementation of an animation that transforms a FAB into a full-screen page.
Shared element transitions are not yet available in Jetpack Compose; they are either in Backlog or In Focus, so we will have to leverage existing animation APIs.
The final animation 🌶 :
Coding
To animate between two composables Jetpack Compose provides a convenient API — AnimatedContent.
AnimatedContent
is a container that automatically animates its content whenTransition.targetState
changes. Itscontent
for different target states is defined in a mapping between a target state and a composable function.
To implement a simple transition from one composable to another we can use the following code snippet:
@Composable | |
private fun FabContent( | |
modifier: Modifier = Modifier, | |
) { | |
var containerState by remember { mutableStateOf(ContainerState.Fab) } | |
AnimatedContent( | |
modifier = modifier, | |
targetState = containerState, | |
label = "container transform", | |
) { state -> | |
when (state) { | |
ContainerState.Fab -> Fab( | |
modifier = Modifier | |
.padding(end = 16.dp, bottom = 16.dp), | |
onClick = { containerState = ContainerState.Fullscreen } | |
) | |
ContainerState.Fullscreen -> AddContentScreen( | |
onBack = { containerState = ContainerState.Fab } | |
) | |
} | |
} | |
} |
By default, the AnimatedContent
uses delayed fadeIn and
scaleIn animations along with
SizeTransform to animate size changes of the content.
The code above gets us very close to the desired result:
however, there are a few issues:
- enter/exit transitions don’t follow an “expanding” pattern;
- the fab flies diagonally instead of staying in the same place;
- background color is not changing seamlessly.
Polishing
To fix the first two problems we need to have a shared shape animation between UI components which includes cornerRadius
, elevation
and padding
. And as you might have guessed, to improve the color animation we need to have a shared backgroundColor
animation.
Compose has many built-in animation mechanisms to animate primitive values like animateDpAsState ,
animateFloatAsState ,
animateColorAsState and many others including generic
animateValueAsState where you can animate almost everything by providing your own
TwoWayConverter<T, V>.
Job Offers
animate*AsState
— fire-and-forget animation function for*
. This Composable function is overloaded for different parameter types such asFloat
,Color
,Offset
, etc. When the providedtargetValue
is changed, the animation will run automatically.
To animate cornerRadius
change we can use animateDpAsState
as follows:
val cornerRadius: Dp by animateDpAsState(
targetValue = when (containerState) {
ContainerState.Fab -> 22.dp
ContainerState.Fullscreen -> 0.dp
}
)
The same needs to be done for padding
, elevation
, and backgroundColor
, but to synchronize all our animations we need to use the Transition API.
var containerState by remember { mutableStateOf(ContainerState.Fab) }
val transition = updateTransition(targetState = containerState)
val cornerRadius by transition.animateDp { ... }
val backgroundColor by transition.animateColor { ... }
Transition
manages all the child animations on a state level. When thetargetState
changes,Transition
will automatically start or adjust course for all its child animations to animate to the new target values defined for each animation.
Incorporating the API from above, the updated animation code is as follows:
@Composable | |
private fun FabContainer( | |
modifier: Modifier = Modifier, | |
) { | |
var containerState by remember { mutableStateOf(ContainerState.Fab) } | |
val transition = updateTransition(targetState = containerState) | |
val backgroundColor by transition.animateColor { state -> | |
when (state) { | |
ContainerState.Fab -> Colors.fabContainerColor | |
ContainerState.Fullscreen -> Colors.surface | |
} | |
} | |
val cornerRadius by transition.animateDp { state -> | |
when (state) { | |
ContainerState.Fab -> 22.dp | |
ContainerState.Fullscreen -> 0.dp | |
} | |
} | |
val elevation by transition.animateDp { ... } | |
val padding by transition.animateDp { ... } | |
transition.AnimatedContent( | |
modifier = modifier | |
// padding, shadow, and backgroundColor are shared between composables | |
.padding(end = padding, bottom = padding) | |
.shadow( | |
elevation = elevation, | |
shape = RoundedCornerShape(cornerRadius) | |
) | |
.drawBehind { drawRect(backgroundColor) } | |
) { state -> | |
when (state) { | |
ContainerState.Fab -> Fab() | |
ContainerState.Fullscreen -> AddContentScreen() | |
} | |
} | |
} |
Executing the provided code, we achieve an animation with the container transform style:
Furthermore, each animation can be configured to your liking by providing a transitionSpec. Let’s say you want
cornerRadius
to speed up quickly and slow down gradually:
val cornerRadius by transition.animateDp(
transitionSpec = {
tween(
durationMillis = 500,
easing = FastOutSlowInEasing,
)
}
) { ... }
More details about different Easing
options (with nice GIFs!) can be found here and how to customize animations in general here.
Conclusion
The shared container technique with animated properties allows us to achieve pretty elegant animations, and the customization of individual child animations lets you realize even the strangest whims of your designers. One of the downsides of this approach is that it doesn’t work with navigation frameworks, because you have to have a single shared parent for both composables.
The complete example shown here is available in the repository below:
https://github.com/rmyhal/container-transform-compose/?source=post_page—–98e5e74a15c9——————————–
[1] for those who read to the end and want to know why the enter animation on the gif at the top is a little fast, first of all, hello 👋!
And yes, it’s not only because it’s a gif, when I add an animated padding value to the
AnimatedContent
, the Compose ignores theSizeTransform
that I pass to the AnimatedContent’stransitionSpec
and uses some standardenter transition
, although when I go back (exit transition
) everything works as specified in thetransitionSpec
. The issue with more details is here, maybe I will update the article when there are updates.
Thanks for reading! 💚 Some useful links:
- Animations in Jetpack Compose official documentation
- Customizing AnimatedContent by
This article is previously published on proandroiddev.com