Jetpack Compose’s animation API is both powerful and enjoyable to work with. And when combined with the graphicsLayer()
and drawing modifiers, it really open up possibilities for creating some really cool animations. In this article, we’ll dive into exactly that by exploring how to create the following loading animation:
Let’s get started.
Before we look at how to create that loading animation, let’s first talk about why the drawing modifiers are particularly useful for animations.
To answer this question, let’s quickly go over Compose’s three main phases for rendering a frame: Composition, Layout, and Drawing.
If you are already familiar with that, feel free to skip this section and jump directly to the animation implementation section below.
Let’s examine a simple composable example:
@Preview
@Composable
fun SlidingBox() {
val infiniteTransition = rememberInfiniteTransition(label = "infiniteTransition")
val progress = infiniteTransition.animateFloat(
label = "offset",
initialValue = -1f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = 1_000,
easing = EaseInOut
),
repeatMode = RepeatMode.Reverse
)
)
val offset = 100.dpBox(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxSize()
.background(AppColors.DarkBlue)
) {
Box(
modifier = Modifier
.size(100.dp)
.offset(offset * progress.value)
.background(AppColors.Pink, RoundedCornerShape(10.dp))
)
}
}
In this code, the SlidingBox
composable uses an InfiniteTransition
to continuously animate the offset of a small 100×100 dp Box
. The key part of the code is the use of the offset()
modifier to achieve this effect.
The result is the following animation:
This works, but if we take a closer look in the Layout Inspector, we’ll see that it’s definitely not the most efficient way to create such animation:
Notice that number? That’s not good. You see, in the offset(offset * progress.value)
call, we’re reading the progress
state during the composition phase. Since Compose tracks state reads for each phase, it invalidates and recomposes the entire SlidingBox
composable with every animation frame, leading to this huge recomposition count.
However, if we think about it, the composition phase’s responsibility is to basically convert data into UI. When data changes, we recompose to reflect the new content. But in our case, the content itself has changed — only its offset.
To optimize this, we should defer the state reading to the layout phase. To do that, let’s update our offset()
modifier call to use the one with the lambda argument:
@Preview
@Composable
fun SlidingBox() {
// ...
val offsetPx = with(LocalDensity.current) {
100.dp.toPx()
}Box(...) {
Box(
modifier = Modifier
.size(100.dp)
// We now use the offset modifier with the lambda argument.
.offset {
IntOffset(
x = (offsetPx * progress.value).roundToInt(),
y = 0
)
}
.background(AppColors.Pink, RoundedCornerShape(10.dp))
)
}
}
With this change, we achieve the same animation, but we now read the progress
state’s value inside a lambda that’s executed during the layout phase. This means that whenever the state changes, only the layout (and potentially the drawing) phase needs to be re-executed.
If we check the Layout Inspector now, we’ll see that we no longer recompose with each animation frame. That’s a significant improvement compared to before.
Up until now, we haven’t used any drawing modifiers, so let’s change that with one final example:
@Preview
@Composable
fun ColoredBox() {
val infiniteTransition = rememberInfiniteTransition(label = "infiniteTransition")
val color = infiniteTransition.animateColor(
label = "color",
initialValue = AppColors.Pink,
targetValue = AppColors.Purple,
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = 1_000,
easing = EaseInOut
),
repeatMode = RepeatMode.Reverse
)
)Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxSize()
.background(AppColors.DarkBlue)
) {
Box(
modifier = Modifier
.size(100.dp)
.background(color.value, RoundedCornerShape(10.dp))
)
}
}
In this snippet, we have code similar to the previous example, but this time we’re animating the color of the Box
, not its offset.
When we run this code, we’ll see the following animation:
However, we run into the same problem as before: a high recomposition count. And once again, the content itself hasn’t changed, in fact, neither the size nor the placement has changed either, only a single graphics property — the color.
To optimize this, we’ll apply the same technique we used earlier by deferring the color
state read to the drawing phase. To do this, we’ll use the drawBehind()
drawing modifier:
@Preview
@Composable
fun ColoredBox() {
// ...
val color = infiniteTransition.animateColor(...)Box(...) {
Box(
modifier = Modifier
.size(100.dp)
.drawBehind {
drawRoundRect(
color = color.value,
cornerRadius = CornerRadius(10.dp.toPx())
)
}
)
}
}
With this change, we’re now reading the color
state in the drawing phase, which means that whenever the state changes, only the drawing phase is re-executed. Cool!
Now, with that in mind, let’s jump into creating the loading animation.
There are two main components involved in drawing this loading animation:
- The content we’re animating in and revealing — in this example, a simple text that says:
“Loading\nPlease\nWait.”
- The shape that acts as a mask, drawn on top of the content to create the reveal effect.
The following image illustrates this (no masking applied):
Creating the Animation Content
So, let’s start by creating the content:
@Composable
private fun Content(modifier: Modifier = Modifier) {
Text(
text = "Loading\nPlease\nWait.",
modifier = modifier,
fontSize = 100.sp,
lineHeight = 90.sp,
fontWeight = FontWeight.Black,
color = MaterialTheme.colorScheme.surfaceContainer // 0xFF11112A
)
}
As you can see, it’s a simple Text
composable with a large font size and a default color.
Creating the Animation Screen and Control UI
Now let’s create the screen where we’ll show our animation and add some driver code to control the animation, allowing us to play, pause, and reset it:
@Composable
fun LoadingAnimation() {
val coroutineScope = rememberCoroutineScope()
val progressAnimation = remember { Animatable(0f) }
val forwardAnimationSpec = remember {
tween<Float>(
durationMillis = 10_000,
easing = LinearEasing
)
}
val resetAnimationSpec = remember {
tween<Float>(
durationMillis = 1_000,
easing = EaseInSine
)
}fun reset() {
coroutineScope.launch {
progressAnimation.stop()
progressAnimation.animateTo(0f, resetAnimationSpec)
}
}
fun togglePlay() {
coroutineScope.launch {
if (progressAnimation.isRunning) {
progressAnimation.stop()
} else {
if (progressAnimation.value == 1f) {
progressAnimation.snapTo(0f)
}
progressAnimation.animateTo(1f, forwardAnimationSpec)
}
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
) {
CompositionLocalProvider(
LocalContentColor provides MaterialTheme.colorScheme.onBackground
) {
Content(
modifier = Modifier
.align(Alignment.Center)
// This is the most important part, which we will create next.
.loadingRevealAnimation(
progress = progressAnimation.asState()
)
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.padding(24.dp)
.safeContentPadding()
.align(Alignment.BottomCenter)
) {
FilledIconButton(onClick = ::reset) {
Icon(
painter = painterResource(R.drawable.ic_skip_back),
contentDescription = "Reset"
)
}
Button(onClick = ::togglePlay) {
AnimatedContent(
label = "playPauseButton",
targetState = progressAnimation.isRunning
) {
val icon = if (it) R.drawable.ic_pause else R.drawable.ic_play
Icon(
painter = painterResource(icon),
contentDescription = "Play"
)
}
Text("Play")
}
}
}
}
}
That might look like a lot of code, but it’s pretty straightforward. Here’s a breakdown of what’s going on:
- We create an
Animatable
object (progressAnimation
) that serves as the main driver of our animation, controlling the animation’s progress. - The
forwardAnimationSpec
andresetAnimationSpec
are bothTweenSpec
s that define the duration and easing of our animation. TheforwardAnimationSpec
is used when the animation is playing forward, running for 10 seconds withLinearEasing
. TheresetAnimationSpec
is used when we reset the animation, and it’s pretty quick, just runs for 1 second with anEaseInSine
easing. - Next, we define two functions:
togglePlay
andreset
. ThetogglePlay
function toggles the animation between playing and pausing. Thereset
function resets the animation back to the beginning by stopping any ongoing animation and then setting the progress back to0f
.
Both functions manipulate theAnimatable
object by calling a combination ofstop()
,animateTo()
, andsnapTo()
, passing the appropriateTweenSpec
. - Finally, we set up our UI by creating a
Box
that contains ourContent
composable and two buttons in aRow
. The first button resets the animation, and the second button toggles between playing and pausing the animation. - The key part of the animation is the
loadingRevealAnimation()
modifier we apply to theContent
composable. We’ll implement that next.
Here’s the result of the above code:
Creating the Mask and Reveal Effect
To create the reveal effect, we draw a custom shape with a gradient that acts as a mask over the content. This mask defines which parts of the content will be drawn with the gradient. Wherever the mask and the content overlap, the content is drawn using that gradient, while the areas outside the mask are drawn using their original color. Then by animating the mask, we gradually reveal more of the content over time. This is exactly what the loadingRevealAnimation()
modifier does:
private fun Modifier.loadingRevealAnimation(
progress: State<Float>
): Modifier = this
.drawWithCache {
onDrawWithContent {
drawContent()
drawRect(
brush = Gradient,
size = size.copy(width = size.width * progress.value)
)
}
}private val Gradient = Brush.linearGradient(
colorStops = arrayOf(
0.0f to AppColors.Pink,
0.4f to AppColors.Purple,
0.7f to AppColors.LightOrange,
1.0f to AppColors.Yellow
)
)
In this code, we create a modifier factory called loadingRevealAnimation()
that uses Compose’s drawWithContent()
. We first call drawContent()
, which is important because it draws the composable’s content. Then, we draw a rectangle over that content using drawRect()
. We then animate the width of this rectangle by multiplying the total width by progress
, which is the state we pass into the modifier. This gives us the following animation:
We’re getting there. Now, to achieve the desired reveal effect, we need to implement masking by telling Compose to draw the rectangle only where it overlaps the content. We can do this by applying a blend mode — specifically, the SrcAtop
blend mode.
private fun Modifier.loadingRevealAnimation(
progress: State<Float>
): Modifier = this
.drawWithContent {
drawContent()
drawRect(
brush = Gradient,
// We added the SrcAtop blend mode.
blendMode = BlendMode.SrcAtop,
size = size.copy(width = size.width * progress.value)
)
}
This would actually give us the same result as before. So, to actually see the magic of the custom blend mode, this is where the graphicsLayer()
modifier comes into play. You see, for the custom blend mode to work, we need to set something called a CompositingStrategy
—specifically, CompositingStrategy.Offscreen
. Let’s check the documentation for CompositingStrategy.Offscreen
:
Rendering of content will always be rendered into an offscreen buffer first then drawn to the destination regardless of the other parameters configured on the graphics layer. This is useful for leveraging different blending algorithms for masking content.
For example, the contents can be drawn into this graphics layer and masked out by drawing additional shapes with [BlendMode.Clear]
This is exactly what we need. Let’s add that:
private fun Modifier.loadingRevealAnimation(
progress: State<Float>
): Modifier = this
// We added this graphicsLayer() modifier call along with the compositingStrategy.
.graphicsLayer(
compositingStrategy = CompositingStrategy.Offscreen
)
.drawWithContent {
drawContent()
drawRect(
brush = Gradient,
blendMode = BlendMode.SrcAtop,
size = size.copy(width = size.width * progress.value)
)
}
Now, if we run this code, we’d get exactly what we’re looking for:
Now, let’s take it a step further and use a custom shape instead of the simple rectangle we’re using. I decided to use a rectangle with one edge having an animated sine-ish wave pattern, like so:
To draw the wave, we will use a custom path with some Bézier curves, which would allow us to mimic the smooth, flowing shape of a sine wave. We will also need to know three things: the wave count, the wavelength, and the amplitude:
Additionally, we will introduce an offset on the y-axis to animate the wave downward.
So, let’s modify our loadingRevealAnimation()
modifier to accept these arguments (the wavelength will be calculated dynamically later on):
private fun Modifier.loadingRevealAnimation(
progress: State<Float>,
yOffset: State<Float>,
wavesCount: Int = 2,
amplitudeProvider: (totalSize: Size) -> Float = { it.minDimension * 0.1f}
): Modifier
The amplitudeProvider
lambda takes the total canvas size and returns the value for the amplitude. By default, we use 10% of the minimum dimension of the canvas size.
Next, we’ll use the drawWithCache()
modifier along with onDrawWithContent
to draw our wave path. The drawWithCache
modifier allows us to cache the Path
object, avoiding unnecessary reallocations:
private fun Modifier.loadingRevealAnimation(
progress: State<Float>,
yOffset: State<Float>,
wavesCount: Int = 2,
amplitudeProvider: (totalSize: Size) -> Float = { it.minDimension * 0.1f}
): Modifier = this
.graphicsLayer(
compositingStrategy = CompositingStrategy.Offscreen
)
.drawWithCache {
val height = size.height
val waveLength = height / wavesCount
val nextPointOffset = waveLength / 2f
val controlPointOffset = nextPointOffset / 2f
val amplitude = amplitudeProvider(size)
val wavePath = Path()onDrawWithContent {
// We'll construct the wave path next.
...
drawPath(
path = wavePath,
brush = Gradient,
blendMode = BlendMode.SrcAtop
)
}
}
Here we calculate the waveLength
based on the height and the wavesCount
. We also create a Path
instance (wavePath
). Finally, both nextPointOffset
and controlPointOffset
will be used to add Bézier curves to the path, which we’ll implement next:
...onDrawWithContent {
drawContent()
val wavesStartX = (size.width + 2 * amplitude) * progress.value - amplitude
wavePath.reset()
wavePath.relativeLineTo(wavesStartX, -waveLength)
wavePath.relativeLineTo(0f, waveLength * yOffset.value)
repeat((wavesCount + 1) * 2) { i ->
val direction = if (i and 1 == 0) -1 else 1
wavePath.relativeQuadraticBezierTo(
dx1 = direction * amplitude,
dy1 = controlPointOffset,
dx2 = 0f,
dy2 = nextPointOffset
)
}
wavePath.lineTo(0f, height)
wavePath.close()
drawPath(
path = wavePath,
brush = Gradient,
blendMode = BlendMode.SrcAtop
)
}
Here’s a breakdown of what this code does:
- We start by calling
drawContent()
. Without this, the composable’s original content would not be drawn. - Next, we calculate the wave’s starting coordinate on the x-axis (
wavesStartX
). Notice that we multiply the width byprogress
to animate the width of the rectangle as the animation progresses. Additionally, we add2 * amplitude
to ensure the waves extend outside the bounds whenprogress
is 1. Finally, we subtractamplitude
to make the waves start outside the bounds whenprogress
is 0. - Then we start constructing the wave by first moving (using
relativeLineTo
) the starting point to(wavesStartX, -waveLength)
. We’ll explain why we use-waveLength
later on. - After setting the starting point, we use
relativeLineTo()
again to shift the starting point based on the animatedyOffset
. This creates the effect of the wave moving downward as the animation progresses. - We then loop
(wavesCount + 1) * 2
times and in each iteration, we add a quadratic Bézier curve to the path using thecontrolPointOffset
andnextPointOffset
values that we calculated earlier. This creates the sine wave pattern. - Once the waves are added to the path, we use
lineTo()
to move the path to the end position, and then we close the path. - Finally, we draw the
wavePath
on the canvas using the gradient and theSrcAtop
blend mode.
To reason why we start the wave path at -waveLength
is to take advantage of the periodic nature of the sine wave. By starting the wave one cycle before the bounds of the canvas and extending it one cycle beyond the bounds of the canvas, we create the illusion of an infinitely moving downward wave.
The following GIF illustrates this:
So, if we clip the drawing area to the red rectangle, we get the effect we’re looking for:
To sum up, here’s the full implementation of the loadingRevealAnimation()
modifier:
private fun Modifier.loadingRevealAnimation(
progress: State<Float>,
yOffset: State<Float>,
wavesCount: Int = 2,
amplitudeProvider: (totalSize: Size) -> Float = { it.minDimension * 0.1f }
): Modifier = this
.graphicsLayer(
compositingStrategy = CompositingStrategy.Offscreen
)
.drawWithCache {
val height = size.height
val waveLength = height / wavesCount
val nextPointOffset = waveLength / 2f
val controlPointOffset = nextPointOffset / 2f
val amplitude = amplitudeProvider(size)
val wavePath = Path()onDrawWithContent {
drawContent()
val wavesStartX = (size.width + 2 * amplitude) * progress.value - amplitude
wavePath.reset()
wavePath.relativeLineTo(wavesStartX, -waveLength)
wavePath.relativeLineTo(0f, waveLength * yOffset.value)
repeat((wavesCount + 1) * 2) { i ->
val direction = if (i and 1 == 0) -1 else 1
wavePath.relativeQuadraticBezierTo(
dx1 = direction * amplitude,
dy1 = controlPointOffset,
dx2 = 0f,
dy2 = nextPointOffset
)
}
wavePath.lineTo(0f, height)
wavePath.close()
drawPath(
path = wavePath,
brush = Gradient,
blendMode = BlendMode.SrcAtop
)
}
}
And with that, our loading animation is ready.
Thank you for reading! I hope this article has been helpful. If you have any questions or suggestions, feel free to share them in the comments below.
Happy coding!