Skip to content

WhatsApp’s Circular Reveal with Jetpack Compose

In this article, I’ll show you how to build the circular reveal animation WhatsApp uses with Jetpack Compose.

1. Using AnimatedVisibility

My first attempt was to use AnimatedVisibility to achieve something similar.

AnimatedVisibility(
    visible = visible,
) {
    BottomSheet()
}

This code is equivalent to:

AnimatedVisibility(
    visible = visible,
    enter = expandVertically(),
    exit = shrinkVertically()
) {
    BottomSheet()
}

However, I was not able to achieve the circular motion I was looking for.

2. Using GenericShape

My second attempt was to use a shape to clip my composable to. GenericShape allows me to write whatever shape I want but there’s nothing that allows me to draw a circle so I started with a rectangle.

GenericShape { size, direction ->
    val center = size.width / 2f

    this.addRect(
        Rect(
            left = center - center * it,
            top = center - center * it,
            right = center + center * it,
            bottom = size.height
        )
    )

    close()
}

I got something to animate but it still missed the circular motion I was looking for.

3. Using addArc

Given that a rectangle is not what I want I tried using addArc to draw an arc around the rectangle.

GenericShape { size, direction ->
    val centerV = size.height / 2f
    val centerH = size.width / 2f

    addArc(
        Rect(
            left = centerH - centerH * it * 2,
            top = size.height - size.height * it,
            right = centerH + centerH * it * 2,
            bottom = size.height + size.height * it
        ),
        0f,
        -180f
    )

    close()
}

The result was much better, however, I couldn’t get it to expand fully so there was always a part of the component that was cropped out.

4. Using cubicTo

What if instead of using a normal arc I used a more “complicated” one? That’s when I tried to use cubicTo, it uses a cubic bézier curve. It has 4 control points allowing me to draw something that looks like semicircle.

If I implemented a cubic bézier curve that has the same size as my composable then the top corners would still be cropped out so I needed a way to make the curve wrap my whole composable.

To do that I made the curve twice as big as the composable. For the x axis that’s done by multiplying the width by 2, for the y axis I’m using a lerp function that goes to from height to -height.

GenericShape { size, direction ->
    val centerH = size.width / 2f

    moveTo(
        x = centerH - centerH * it * 2,
        y = size.height,
    )

    val currentHeight = lerp(size.height, -size.height, it)

    cubicTo(
        x1 = centerH - centerH * it,
        y1 = currentHeight,
        x2 = centerH + centerH * it,
        y2 = currentHeight,
        x3 = centerH + centerH * it * 2,
        y3 = size.height,
    )

    close()
}

The result was pretty close to what I wanted but the shape is not growing proportionally.

I kept tweaking the code until I arrived at something that was pretty close to the animation WhatsApp is using.

GenericShape { size, _ ->
    val centerH = size.width / 2f
    val multiplierW = 1.5f + size.height / size.width

    moveTo(
        x = centerH - centerH * progress * multiplierW,
        y = size.height,
    )

    val currentWidth = (centerH * progress * multiplierW * 2.5f)

    cubicTo(
        x1 = centerH - centerH * progress * 1.5f,
        y1 = size.height - currentWidth * 0.5f,
        x2 = centerH + centerH * progress * 1.5f,
        y2 = size.height - currentWidth * 0.5f,
        x3 = centerH + centerH * progress * multiplierW,
        y3 = size.height,
    )

    close()
}

There are some magic numbers in the calculations and I’m not sure why they work, however, the end result looks pretty similar to what WhatsApp is using.

Bonus

If you take a look at the WhatsApp animation again you might notice that the items inside the card also animate a bit. Their scale at the start is probably 90%, then it goes up to 110% and finally goes down to 100%.

To implement that I used animateFloatAsState.

var scale by remember { mutableStateOf(0.9f) }
val animation = animateFloatAsState(
    targetValue = scale,
    animationSpec = FloatSpringSpec(
        dampingRatio = 0.3f,
    )
)

LaunchedEffect(Unit) {
    delay(20 + position.toLong() * 20)
    scale = 1f
}

Image(
    modifier = Modifier
       ...
        .graphicsLayer {
            scaleX = animation.value
            scaleY = animation.value
        }
)

It’s a simple float animation that goes from 0.9 to 1 and uses a spring animation spec. I’m adding a 20ms delay + another delay based on the item position so everything doesn’t animate at the same time. If you want something to appear later then just give it a higher position.

It doesn’t look exactly like the one WhatsApp uses but if you can continue tweaking the delay and the dampingRatio value until you get something you’re happy with.


If you want to check the source code, you can find it here.

If you have any comments or suggestions, please reach out to me on Twitter.

Photo by Ross Sneddon on Unsplash