Hey people ! Welcome to my blog on animating custom shapes, pulsating circles, draw shapes on canvas and animate them with compose.

This is the preview of what we will build. Let’s get started.

You can take reference of the source code from GitHub repository and above preview image is for the app which I will take as an example.

Dependency

Right now I’m using compose version 1.2.0-beta02 which is latest release.

implementation "androidx.compose.ui:ui:1.2.0-beta02"
implementation "androidx.compose.runtime:runtime-livedata:1.2.0-beta02"

1. Animate circle on canvas

First we will animate circle on canvas using drawArc().
This is how it should look.

We have to remember the initial float value in Animatable function that automatically animates its value.

val animateCircle = remember { Animatable(0f) }

In composable we launch coroutine with LaunchedEffect and animate the shape.
animateTo will starts an animation to animate from initialValue to the provided targetValue.

LaunchedEffect(animateCircle) {
    animateCircle.animateTo(
        targetValue = 1f,
        animationSpec = infiniteRepeatable(
            animation = tween(
                durationMillis = 1000,
                easing = LinearEasing
            ),
            repeatMode = RepeatMode.Restart
        )
    )
}

Animation properties

  • animateValue – Animate from initial (0f) to target (1f) value.
  • infiniteRepeatable – Creates duration based animation for infinite amount of times.
  • tween – Animation type, alternative is spring, keyframes.
  • durationMillis – Time taken for one animation cycle to complete.
  • easing – Default is FastOutSlowInEasing, easing is just interpolation.
  • repeatMode – Default is Restart, whether animation should begin from end or beginning(Reverse)

Now lets use drawArc() to draw animated circle on canvas.

Canvas(
    modifier = Modifier
) {
    drawArc(
        color = Color(0xFF302522),
        startAngle = 45f,
        sweepAngle = 360f * animateCircle.value,
        useCenter = false,
        size = Size(80f, 80f),
        style = Stroke(16f, cap = StrokeCap.Round)
    )
}

drawArc( ) properties

  • drawArc – Draws an arc scaled to fit inside the given rectangle.
  • color – Color to be applied to the arc
  • startAngle – 0 represents 3 o’clock. We will start at 45 -> 4:30.
  • sweepAngle – We provide 360f to form full circle.
  • useCenter – I kept it false because I don’t want a sector to form.
  • size – Size taken by arc in canvas.
  • style – DrawStyle default is Fill, I kept it stroke with 16 width and rounded.

At this a circle with border will be drawn on canvas, but we want its path to be animated from initial to target value, for that we have to pass the initial value to the drawArc() while it starts laying on Canvas.

We can multiply the initial float value with sweepAngle while it draws the arc. This will lay the arc with the progressive looking circle in canvas or like a loading circular progress bar.

drawArc(
    sweepAngle = 360f * animateCircle.value
)

Here’s the combined code changes from above to animate circle.

@Composable
fun AnimateCircle() {

    // Initial float value for animation start.
    val animateCircle = remember { Animatable(0f) }

    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        // Animation properties
        LaunchedEffect(animateCircle) {
            animateCircle.animateTo(
                targetValue = 1f,
                animationSpec = infiniteRepeatable(
                    animation = tween(
                        durationMillis = 1000,
                        easing = LinearEasing
                    ),
                    repeatMode = RepeatMode.Restart
                )
            )
        }

        // Draw arc in canvas which forms animated circle, repeatable.
        Canvas(
            modifier = Modifier
        ) {
            drawArc(
                color = Color(0xFF302522),
                startAngle = 45f,
                sweepAngle = 360f * animateCircle.value,
                useCenter = false,
                size = Size(80f, 80f),
                style = Stroke(16f, cap = StrokeCap.Round)
            )
        }
    }
}

Check whether your circle is animating as expected compared to my above preview. Now let’s move to animating a simple line.

2. Animate line on canvas

This is what we should try to animate, line expands and moves to target float.

Nothing changes much as compared to previous.

  1. Need a new Animatable property with different initial float value.
  2. Properties for animation remains same except initial float value.
  3. To animate line we will use drawLine().

We have to remember the initial float value in Animatable function that automatically animates its value.

val animateLine = remember { Animatable(0.6f) }

Launch coroutine and animate line with LaunchedEffect.

LaunchedEffect(animateLine) {
    animateLine.animateTo(
        targetValue = 1f,
        animationSpec = infiniteRepeatable(
            animation = tween(
                durationMillis = 1000,
                easing = LinearEasing
            ),
            repeatMode = RepeatMode.Restart
        )
    )
}

I’m using initial value 0.6f to reduce floating time of line to reach it’s final state. Setting it to 0f will make final animation output to look kind of aggressive. Change its value for your convenience.

Now draw a line on Canvas with drawLine().

Line has start and end offset points. So give (x, y) start and end offsets to line. Also multiply those offsets x, y with initial float value for animation to start.

drawLine(
    color = Color(0xFF302522),
    strokeWidth = 16f,
    cap = StrokeCap.Round,
    start = Offset(
        80f * animateLine.value,
        80f * animateLine.value
    ),
    end = Offset(
        110f * animateLine.value,
        110f * animateLine.value
    )
)

Combine circle and line

Brining all the changes in single composable, since we are using same animationSpec properties, we can reuse it by moving to separate composable.

fun AnimateShapeInfinitely(
    animatableShape: Animatable<Float, AnimationVector1D>
) {
    LaunchedEffect(animatableShape) {
        animatableShape.animateTo(
            targetValue = 1f,
            animationSpec = infiniteRepeatable(
                animation = tween(
                    durationMillis = 1000,
                    easing = LinearEasing
                ),
                repeatMode = RepeatMode.Restart
            )
        )
    }
}

Finally this composable function draws circle and line in single canvas.

At this point you should be able to see both line and circle border animation.

@Composable
fun AnimateSearch() {

    // Initial float value for circle.
    val animateCircle = remember { Animatable(0f) }.apply {
        AnimateShapeInfinitely(this)
    }

    // Initial float value for line.
    val animateLine = remember { Animatable(0.6f) }.apply {
        AnimateShapeInfinitely(this)
    }

    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        // Draw arc in canvas which forms animated circle, repeatable.
        Canvas(
            modifier = Modifier
        ) {
            drawArc(
                color = Color(0xFF302522),
                startAngle = 45f,
                sweepAngle = 360f * animateCircle.value,
                useCenter = false,
                size = Size(80f, 80f),
                style = Stroke(16f, cap = StrokeCap.Round)
            )

            // Draw diagonal line in canvas.
            drawLine(
                color = Color(0xFF302522),
                strokeWidth = 16f,
                cap = StrokeCap.Round,
                start = Offset(
                    animateLine.value * 80f,
                    animateLine.value * 80f
                ),
                end = Offset(
                    animateLine.value * 110f,
                    animateLine.value * 110f
                )
            )
        }
    }
}

We have completed animating search look-a-like shape.

3. Pulsating circle animation

This is what we want to create.

Let’s start by drawing a circle in canvas.

Canvas(
    modifier = Modifier.fillMaxSize()
) {
    val canvasWidth = size.width
    val canvasHeight = size.height
    drawCircle(
        color = Color(0xFFffb59c),
        center = Offset(
            x = canvasWidth / 2,
            y = canvasHeight / 2
        ),
        radius = size.minDimension / 4f,
    )
}

GraphicsLayer

From Android Documentation
Can be used to apply effects to content, such as scaling, rotation, opacity, shadow, and clipping. Prefer this version when you have layer properties or an animated value.

Canvas modifier allows us to apply graphics scale properties. But we also want to animate the scaling of the circle on canvas between initial and target float values. So we should use animateFloat and create a infinite transition.

Create a InfiniteTransition that runs infinite child animations. Child animations will start running as soon as they enter the composition, and will not stop until they are removed from the composition.

val infiniteTransition = rememberInfiniteTransition()

With infinite transition you can animate float changes.

val scale by infiniteTransition.animateFloat(
    initialValue = 0f,
    targetValue = 1.2f,
    animationSpec = infiniteRepeatable(
        animation = tween(
            durationMillis = 600,
            easing = LinearEasing
        ),
        repeatMode = RepeatMode.Reverse
    )
)
  • initialValue – Float value where the animations starts.
  • targetValue – Final float value where animation ends.
  • infiniteRepeatable – Animations that plays infinite amount of times.

Now apply that scale float value graphics layer of canvas.

Canvas(
    modifier = Modifier
        .fillMaxSize()
        .graphicsLayer {
            scaleX = scale
            scaleY = scale
        }
)

These are the final changes for single pulsating circle on canvas.

@Composable
fun PulsatingCircle() {
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {

        val infiniteTransition = rememberInfiniteTransition()

        val scale by infiniteTransition.animateFloat(
            initialValue = 0f,
            targetValue = 1.2f,
            animationSpec = infiniteRepeatable(
                animation = tween(
                    durationMillis = 600,
                    easing = LinearEasing
                ),
                repeatMode = RepeatMode.Reverse
            )
        )

        Canvas(
            modifier = Modifier
                .fillMaxSize()
                .graphicsLayer {
                    scaleX = scale
                    scaleY = scale
                }
        ) {
            val canvasWidth = size.width
            val canvasHeight = size.height
            drawCircle(
                color = Color(0xFFffb59c),
                center = Offset(
                    x = canvasWidth / 2,
                    y = canvasHeight / 2
                ),
                radius = size.minDimension / 4f,
            )
        }
    }
}

Now let’s add 2 more circles with canvas.

4. Infinite repeatable pulsating animation

As shown in preview, we need 3 circles total with different animationSpec properties. This way we can make sure that the 3 circles won’t look same. So we will pass different values for below properties.

  • Target float value
  • Circle background color
  • Animation duration
  • Radius ratio

Let us create a reusable composable for 3 circles for infinite transition with function name scaleInfiniteTransition.

I have kept all initial states of 3 circles to 0f to make end result much smoother. Random or uneven gaps between initial, target, durationMillis will make end animation look more abrupt and aggressively pushing it’s bounds.

fun scaleInfiniteTransition(
    initialValue: Float = 0f,
    targetValue: Float,
    durationMillis: Int,
): Float {
    val infiniteTransition = rememberInfiniteTransition()
    val scale: Float by infiniteTransition.animateFloat(
        initialValue = initialValue,
        targetValue = targetValue,
        animationSpec = infiniteRepeatable(
            animation = tween(
                durationMillis = durationMillis,
                easing = LinearEasing
            ),
            repeatMode = RepeatMode.Reverse
        )
    )
    return scale
}

Let’s create one more reusable composable which draws single circle on canvas every time its called.
The function parameters allows us to pass different scale, color and radius ratio for us.

fun DrawCircleOnCanvas(
    scale: Float,
    color: Color,
    radiusRatio: Float
) {
    Canvas(
        modifier = Modifier
            .fillMaxSize()
            .graphicsLayer {
                scaleX = scale
                scaleY = scale
            }
    ) {
        drawCircle(
            color = color,
            center = Offset(
                x = size.width / 2,
                y = size.height / 2
            ),
            radius = size.minDimension / radiusRatio
        )
    }
}

Now we need 3 different circle background color. But instead use only one color with variable alpha to make animation look smoother and with less resources.

val circleColor = Color(0xFFffb59c)
val frontCircle = circleColor.copy(0.75f)
val midCircle = circleColor.copy(0.50f)
val backCircle = circleColor.copy(0.25f)

I have given different names for each variant colors, making easy to identify circles drawn on top of each other.

Now invoke the function DrawCircleOnCanvas( ) three times with different animation properties each time.

DrawCircleOnCanvas(
    scale = scaleInfiniteTransition(targetValue = 2f, durationMillis = 600),
    color = backCircle,
    radiusRatio = 4f
)

DrawCircleOnCanvas(
    scale = scaleInfiniteTransition(targetValue = 2.5f, durationMillis = 800),
    color = midCircle,
    radiusRatio = 6f
)

DrawCircleOnCanvas(
    scale = scaleInfiniteTransition(targetValue = 3f, durationMillis = 1000),
    color = frontCircle,
    radiusRatio = 12f
)

Combining all the above code, this is the final code changes.

@Composable
fun InfinitelyFlowingCircles() {
    val circleColor = Color(0xFFffb59c)
    val frontCircle = circleColor.copy(0.75f)
    val midCircle = circleColor.copy(0.50f)
    val backCircle = circleColor.copy(0.25f)

    // back circle
    DrawCircleOnCanvas()
    // middle circle
    DrawCircleOnCanvas()
    // front circle
    DrawCircleOnCanvas()
}

fun DrawCircleOnCanvas(
    scale: Float,
    color: Color,
    radiusRatio: Float
) {
    Canvas() { drawCircle() }
}

fun scaleInfiniteTransition(
    initialValue: Float = 0f,
    targetValue: Float,
    durationMillis: Int,
): Float {
    val infiniteTransition = rememberInfiniteTransition()
    val scale: Float by infiniteTransition.animateFloat()
    return scale
}

Final changes

Combine search animation and circles in one composable.

We have earlier completed animating search in composable function AnimateSearch.
Now 3 pulsating circles are in other composable function InfinitelyFlowingCircles.
Bring both in same composable and center align them to achieve the final state of animation.

@Composable
fun AnimatedCirclesAndSearch() {
    // Pulsating circles
    InfinitelyFlowingCircles()
    // Search look-a-like animation
    Box(
        modifier = Modifier
            .fillMaxSize()
            .padding(bottom = 28.dp, end = 28.dp),
        contentAlignment = Alignment.Center
    ) {
        AnimateSearch()
    }
}

I have added extra Box and padding to AnimateSearch( ) so that I can make adjustments to center align them.

That’s it !
Take a look at GitHub repository which I have linked below has complete code example on building advanced layouts, fetching data from api, animations, draw on canvas, search functionality, loader, UI state handling, Image loading, light/dark theme with MD3, palette usage etc.

Read all the detailed guidelines from Android documentation, it covers everything in much depth.

5. Project code and Resources

Android Documentation on Architecture.

1. Animations with jetpack compose
2. Codelab on compose animations
3. Core compose animations android

Here We Go Again : (

if (article == helpful) {
    println("Like and subscribe to blog newsletter.")
} else {
    println("Let me know what i should blog on.")
}

This site uses Akismet to reduce spam. Learn how your comment data is processed.