Building Custom Circle Loader in Jetpack Compose: Exploring Android Canvas and Animations

Kappdev
5 min readMar 26, 2024

--

Welcome 👋

In the world of modern app development, providing smooth and visually appealing loading animations is crucial for user experience. With its declarative UI approach, Jetpack Compose offers powerful tools for crafting such animations.

In this article, we’ll create a Custom Circle Loader component using Jetpack Compose. Let’s dive in together and uncover the possibilities🚀

Setting Up

Before building the loader, let’s create a new data class StrokeStyle to handle the stroke style of the loader.

data class StrokeStyle(
val width: Dp = 4.dp,
val strokeCap: StrokeCap = StrokeCap.Round,
val glowRadius: Dp? = 4.dp
)

Crafting The Function

In this part, we’ll define and implement the CircleLoader composable function. Let’s get started!

Function signature

@Composable
fun CircleLoader(
modifier: Modifier,
isVisible: Boolean,
color: Color,
secondColor: Color? = color,
tailLength: Float = 140f,
smoothTransition: Boolean = true,
strokeStyle: StrokeStyle = StrokeStyle(),
cycleDuration: Int = 1400,
)

Parameters breakdown

  1. modifier ➜ The Modifier to be applied to the Canvas composable. It allows you to customize the layout and appearance.
  2. isVisible ➜ Determines the visibility of the loader animation.
  3. color ➜ Defines the primary color of the loader.
  4. secondColor ➜ Defines an optional secondary color for the loader.
  5. tailLength ➜ Specifies the length of the loader’s tail in degrees.
  6. smoothTransition ➜ Determines whether the transition between the visibility states of the loader should be smooth or abrupt.
  7. strokeStyle ➜ Specifies the style of the stroke used in drawing the loader.
  8. cycleDuration ➜ Defines the loader’s animation cycle duration, measured in milliseconds.

Implementation

Now that we have defined the function, let’s move forward with the implementation phase to bring our animation to life ✨

Crafting Paint🎨

The setupPaint function configures the paint object used for drawing. It applies stroke style and brush configurations.

fun DrawScope.setupPaint(style: StrokeStyle, brush: Brush): Paint {
val paint = Paint().apply paint@{
this@paint.isAntiAlias = true
this@paint.style = PaintingStyle.Stroke
this@paint.strokeWidth = style.width.toPx()
this@paint.strokeCap = style.strokeCap

brush.applyTo(size, this@paint, 1f)
}

style.glowRadius?.let { radius ->
paint.asFrameworkPaint().setShadowLayer(
/* radius = */ radius.toPx(),
/* dx = */ 0f,
/* dy = */ 0f,
/* shadowColor = */ android.graphics.Color.WHITE
)
}

return paint
}

Animation Magic🪄

The loader features two animations:

Rotation Animation: This animation utilizes rememberInfiniteTransition to create an infinite rotation effect for the loader.

val transition = rememberInfiniteTransition()
val spinAngel by transition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = cycleDuration,
easing = LinearEasing
),
repeatMode = RepeatMode.Restart
)
)

State Transition Animation: This animation utilizes Animatable to transition the tail length smoothly, simulating a smooth appearing and disappearing effect. It is triggered when the visibility state changes and smoothTransition is set to true; otherwise, it snaps the value.

val tailToDisplay = remember { Animatable(0f) }

LaunchedEffect(isVisible) {
val targetTail = if (isVisible) tailLength else 0f
when {
smoothTransition -> smoothTransition -> tailToDisplay.animateTo(
targetValue = targetTail,
animationSpec = tween(cycleDuration, easing = LinearEasing)
)
else -> tailToDisplay.snapTo(targetTail)
}
}

Canvas Drawing👨🏻‍🎨

Now, let’s use what we’ve built to draw the loader. We’ll utilize the drawArc method to draw the loader's tail.

Canvas(
modifier
// Apply rotation animation
.rotate(spinAngel)
// Ensure the CircleLoader maintains a square aspect ratio
.aspectRatio(1f)
) {
// Iterate over non-null colors
listOfNotNull(color, secondColor).forEachIndexed { index, color ->
// If it's not a primary color we rotate the canvas for 180 degrees
rotate(if (index == 0) 0f else 180f) {
// Create a sweep gradient brush for the loader
val brush = Brush.sweepGradient(
0f to Color.Transparent,
tailToDisplay.value / 360f to color,
1f to Color.Transparent
)
// Set up paint object
val paint = setupPaint(strokeStyle, brush)

// Draw the loader tail
drawIntoCanvas { canvas ->
canvas.drawArc(
rect = size.toRect(),
startAngle = 0f,
sweepAngle = tailToDisplay.value,
useCenter = false,
paint = paint
)
}
}
}
}

Congratulations🥳! We’ve successfully built it👏. For the complete code implementation, you can access it on GitHub Gist🧑‍💻. Now, let’s explore how we can put it to use.

Advertisement

Are you learning a foreign language and struggling with new vocabulary? Then, I strongly recommend you check out this words-learning app, which will make your journey easy and convenient!

WordBook

Usage

💡By manipulating a primary and a secondary color, we can create three distinct appearances for the animation. Now, let’s explore how to achieve this.

For all examples, we’ll use a button and a state to toggle the animation:

var isLoading by remember { mutableStateOf(false) }

/* CircleLoader code here... */

Button(
onClick = { isLoading = !isLoading }
) {
Text(text = if (isLoading) "Stop" else "Start")
}

1️⃣Double-tailed with a solid color

To get this effect we have to put the primary color and the secondary by default will be the same.

CircleLoader(
color = Color(0xFF1F79FF),
modifier = Modifier.size(100.dp),
isVisible = isLoading
)

Output:

2️⃣Double-tailed with different colors

To achieve this effect, we need to specify the second color.

CircleLoader(
color = Color(0xFF1F79FF),
secondColor = Color(0xFFFFE91F),
modifier = Modifier.size(100.dp),
isVisible = isLoading
)

Output:

3️⃣Single-tailed

For the single-tail effect, simply set the second color to null. Additionally, consider increasing the tail length if desired.

CircleLoader(
color = Color(0xFF1F79FF),
secondColor = null,
tailLength = 280f,
modifier = Modifier.size(100.dp),
isVisible = isLoading
)

Output:

You might also like 👇

Thank you for reading this article!❤️ I hope you’ve found it enjoyable and valuable. Feel free to show your appreciation by hitting the clap👏 if you liked it or follow Kappdev for more exciting articles😊

Happy coding!

--

--

Kappdev

💡 Curious Explorer 🧭 Kotlin and Compose enthusiast 👨‍💻 Passionate about self-development and growth ❤️‍🔥 Push your boundaries 🚀