Animation and Masking in Jetpack Compose with the grahpicsLayer() and Drawing Modifiers

Omar Sahl
12 min readSep 22, 2024

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.

Why use the drawing modifiers for animations?

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.dp

Box(
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 100x100 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.

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 and resetAnimationSpec are both TweenSpecs that define the duration and easing of our animation. The forwardAnimationSpec is used when the animation is playing forward, running for 10 seconds with LinearEasing. The resetAnimationSpec is used when we reset the animation, and it’s pretty quick, just runs for 1 second with an EaseInSine easing.
  • Next, we define two functions: togglePlay and reset. The togglePlay function toggles the animation between playing and pausing. The reset function resets the animation back to the beginning by stopping any ongoing animation and then setting the progress back to 0f.
    Both functions manipulate the Animatable object by calling a combination of stop(), animateTo(), and snapTo(), passing the appropriate TweenSpec.
  • Finally, we set up our UI by creating a Box that contains our Content composable and two buttons in a Row. 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 the Content 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:

The wavelength and amplitude of a sine wave

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:

  1. We start by calling drawContent(). Without this, the composable’s original content would not be drawn.
  2. Next, we calculate the wave’s starting coordinate on the x-axis (wavesStartX). Notice that we multiply the width by progress to animate the width of the rectangle as the animation progresses. Additionally, we add 2 * amplitude to ensure the waves extend outside the bounds when progress is 1. Finally, we subtract amplitude to make the waves start outside the bounds when progress is 0.
  3. 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.
  4. After setting the starting point, we use relativeLineTo() again to shift the starting point based on the animated yOffset. This creates the effect of the wave moving downward as the animation progresses.
  5. We then loop (wavesCount + 1) * 2 times and in each iteration, we add a quadratic Bézier curve to the path using the controlPointOffset and nextPointOffset values that we calculated earlier. This creates the sine wave pattern.
  6. 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.
  7. Finally, we draw the wavePath on the canvas using the gradient and the SrcAtop 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!

If you enjoyed this article, consider giving it a clap (or 50 😉) and follow me for more content on Android development. See you!

--

--