Blog Infos
Author
Published
Topics
Published

Photo by Aron Visuals on Unsplash

 

Are you struggling with implementing Animated Circular Progress Indicator (ACPI) using Jetpack Compose?

In this article, we will see how to implement a Circular Progress Indicator with animated progress completion status based on provided current value and the maximum value of the progress.

What it (ACPI) looks like?

A gif containing Animated Circular Progress Indicator built with Jetpack ComposeAnimated Circular Progress Indicator — in action

 

How simple is the API?

You just have to pass a few parameters to the Composable and we are good to go with the animated Circular Progress Indicator

AnimatedCircularProgressIndicator(
currentValue = 17,
maxValue = 20,
progressBackgroundColor = Purple80,
progressIndicatorColor = PurpleGrey40,
completedColor = Purple40
)
Different states

There are three different states of the ACPI.

  1. Initial state — showing 0% progress (0/20 in the picture below)
  2. progress state < 100% — showing progress based on current value (15/20 in the picture below)
  3. Max or Completed state — showing 100% progress — (20/20 in the picture below)

 

Initial state with 0% progress (Left) — 75 % Progress(Center) — 100% Completed(Right)

 

Deep dive into ACPI Composable
How do we show the progress status?

Here is the progress status composable to show progress in text form. For example, we are showing 15/20 in the centre with different styles and colours based on typography.

@Composable
private fun ProgressStatus(
currentValue: Int,
maxValue: Int,
progressBackgroundColor: Color,
progressIndicatorColor: Color,
completedColor: Color, modifier: Modifier = Modifier
) {
Text(modifier = modifier, text = buildAnnotatedString {
val emphasisSpan =
Typography.titleLarge.copy(color = if (currentValue == maxValue) completedColor else progressIndicatorColor)
.toSpanStyle()
val defaultSpan =
Typography.bodyMedium.copy(color = progressBackgroundColor).toSpanStyle()
append(AnnotatedString("$currentValue", spanStyle = emphasisSpan))
append(AnnotatedString(text = "/", spanStyle = defaultSpan))
append(AnnotatedString(text = "$maxValue", spanStyle = defaultSpan))
}
)
}

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

, ,

Cutting-Edge-to-Edge in Android 15: Using Previews and Testing in Jetpack Compose to Manage Insets.

With the advent of Android 15, edge-to-edge design has become the default configuration. Consequently, applications must be capable of accommodating window insets, including the system status bar and navigation bar, as well as supporting drawing…
Watch Video

Cutting-Edge-to-Edge in Android 15: Using Previews and Testing in Jetpack Compose to Manage Insets.

Timo Drick
Lead Android developer
Seven Principles Mobility GmbH

Cutting-Edge-to-Edge in Android 15: Using Previews and Testing in Jetpack Compose to Manage Insets.

Timo Drick
Lead Android develop ...
Seven Principles Mob ...

Cutting-Edge-to-Edge in Android 15: Using Previews and Testing in Jetpack Compose to Manage Insets.

Timo Drick
Lead Android developer
Seven Principles Mobility ...

Jobs

How do we draw an arc for Circular Progress Indicator?

We have made an extension function to draw a circular progress indicator by drawing an arc on the canvas. We will see Canvas in a while

private fun DrawScope.drawCircularProgressIndicator(
startAngle: Float,
sweep: Float,
color: Color,
stroke: Stroke
) {
// To draw this circle we need a rect with edges that line up with the midpoint of the stroke.
// To do this we need to remove half the stroke width from the total diameter for both sides.
val diameterOffset = stroke.width / 2
val arcDimen = size.width - 2 * diameterOffset
drawArc(
color = color,
startAngle = startAngle,
sweepAngle = sweep,
useCenter = false,
topLeft = Offset(diameterOffset, diameterOffset),
size = Size(arcDimen, arcDimen),
style = stroke
)
}
How do we draw it on Canvas?

Here is how we draw different states of the Circular Progress Indicator on the Canvas

Canvas(
Modifier
.progressSemantics(currentValue / maxValue.toFloat())
.size(CircularIndicatorDiameter)
) {
// Start at 12 O'clock
val startAngle = 270f
val sweep: Float = animateFloat.value * 360f
val diameterOffset = stroke.width / 2
drawCircle(
color = progressBackgroundColor,
style = stroke,
radius = size.minDimension / 2.0f - diameterOffset
)
drawCircularProgressIndicator(startAngle, sweep, progressIndicatorColor, stroke)
if (currentValue == maxValue) {
drawCircle(
color = completedColor,
style = stroke,
radius = size.minDimension / 2.0f - diameterOffset
)
}
}
view raw DrawOnCanvas.kt hosted with ❤ by GitHub
How do we animate this?

We are using Jetpack compose Animatable to animate this Circular Progress Indicator and calculating the target value from the currentValue and maxValue provided.

val animateFloat = remember { Animatable(0f) }
LaunchedEffect(animateFloat) {
animateFloat.animateTo(
targetValue = currentValue / maxValue.toFloat(),
animationSpec = tween(durationMillis = 2000, easing = FastOutSlowInEasing)
)
}
view raw Animate.kt hosted with ❤ by GitHub
Let’s join the different parts of this PUZZLE 🤔

Here is the full Composable which is drawing this Animated Circular Progress Indicator and VOILA 🚀

import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.progressSemantics
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.unit.dp
import com.example.circularprogressindicator.ui.theme.Typography
@Composable
fun AnimatedCircularProgressIndicator(
currentValue: Int,
maxValue: Int,
progressBackgroundColor: Color,
progressIndicatorColor: Color,
completedColor: Color,
modifier: Modifier = Modifier
) {
val stroke = with(LocalDensity.current) {
Stroke(width = 6.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round)
}
Box(modifier = modifier, contentAlignment = Alignment.Center) {
ProgressStatus(
currentValue = currentValue,
maxValue = maxValue,
progressBackgroundColor = progressBackgroundColor,
progressIndicatorColor = progressIndicatorColor,
completedColor = completedColor
)
val animateFloat = remember { Animatable(0f) }
LaunchedEffect(animateFloat) {
animateFloat.animateTo(
targetValue = currentValue / maxValue.toFloat(),
animationSpec = tween(durationMillis = 2000, easing = FastOutSlowInEasing)
)
}
Canvas(
Modifier
.progressSemantics(currentValue / maxValue.toFloat())
.size(CircularIndicatorDiameter)
) {
// Start at 12 O'clock
val startAngle = 270f
val sweep: Float = animateFloat.value * 360f
val diameterOffset = stroke.width / 2
drawCircle(
color = progressBackgroundColor,
style = stroke,
radius = size.minDimension / 2.0f - diameterOffset
)
drawCircularProgressIndicator(startAngle, sweep, progressIndicatorColor, stroke)
if (currentValue == maxValue) {
drawCircle(
color = completedColor,
style = stroke,
radius = size.minDimension / 2.0f - diameterOffset
)
}
}
}
}
@Composable
private fun ProgressStatus(
currentValue: Int,
maxValue: Int,
progressBackgroundColor: Color,
progressIndicatorColor: Color,
completedColor: Color, modifier: Modifier = Modifier
) {
Text(modifier = modifier, text = buildAnnotatedString {
val emphasisSpan =
Typography.titleLarge.copy(color = if (currentValue == maxValue) completedColor else progressIndicatorColor)
.toSpanStyle()
val defaultSpan =
Typography.bodyMedium.copy(color = progressBackgroundColor).toSpanStyle()
append(AnnotatedString("$currentValue", spanStyle = emphasisSpan))
append(AnnotatedString(text = "/", spanStyle = defaultSpan))
append(AnnotatedString(text = "$maxValue", spanStyle = defaultSpan))
}
)
}
private fun DrawScope.drawCircularProgressIndicator(
startAngle: Float,
sweep: Float,
color: Color,
stroke: Stroke
) {
// To draw this circle we need a rect with edges that line up with the midpoint of the stroke.
// To do this we need to remove half the stroke width from the total diameter for both sides.
val diameterOffset = stroke.width / 2
val arcDimen = size.width - 2 * diameterOffset
drawArc(
color = color,
startAngle = startAngle,
sweepAngle = sweep,
useCenter = false,
topLeft = Offset(diameterOffset, diameterOffset),
size = Size(arcDimen, arcDimen),
style = stroke
)
}
// Diameter of the indicator circle
private val CircularIndicatorDiameter = 84.dp

For more detail, go through these references

Jetpack Composeanimations in Jetpack ComposeLayouts in Jetpack Compose

I hope you learned something new today and thanks for reading this far!

This article was previously published on proandroiddev.com

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog
In the world of Jetpack Compose, where designing reusable and customizable UI components is…
READ MORE
blog

How to animate BottomSheet content using Jetpack Compose

Early this year I started a new pet project for listening to random radio…
READ MORE
Menu