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?
Animated 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.
- Initial state — showing 0% progress (0/20 in the picture below)
- progress state < 100% — showing progress based on current value (15/20 in the picture below)
- 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
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 | |
) | |
} | |
} |
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) | |
) | |
} |
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 Compose, animations in Jetpack Compose, Layouts in Jetpack Compose
I hope you learned something new today and thanks for reading this far!
This article was previously published on proandroiddev.com