What You Will Build

Three concentric animated ring arcs (Move, Exercise, Stand) that fill to their respective percentages with spring animations, mimicking the Apple Watch Activity Rings. Each ring has a background track, a colored progress arc, and a label below showing the current percentage.

Why This Pattern Matters

Circular progress indicators are everywhere in fitness, health, and productivity apps. Drawing arcs on Canvas with animated sweep angles teaches you the drawArc API, stroke styling with StrokeCap.Round, and spring-based value animation.

The Activity Rings

@Composable
fun ActivityRingsScreen() {
    var move by remember { mutableFloatStateOf(0.85f) }
    var exercise by remember { mutableFloatStateOf(0.65f) }
    var stand by remember { mutableFloatStateOf(0.9f) }
    val animMove by animateFloatAsState(
        move, spring(dampingRatio = 0.6f))
    val animExercise by animateFloatAsState(
        exercise, spring(dampingRatio = 0.6f))
    val animStand by animateFloatAsState(
        stand, spring(dampingRatio = 0.6f))

    Column(
        modifier = Modifier.fillMaxSize().padding(24.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Box(modifier = Modifier.size(220.dp),
            contentAlignment = Alignment.Center) {
            Canvas(Modifier.fillMaxSize()) {
                val rings = listOf(
                    Triple(Color.Red, animMove, 0.45f),
                    Triple(Color(0xFF34C759), animExercise, 0.33f),
                    Triple(Color(0xFF00BCD4), animStand, 0.21f)
                )
                rings.forEach { (color, value, radiusFrac) ->
                    val stroke = Stroke(18f, cap = StrokeCap.Round)
                    val r = size.minDimension * radiusFrac
                    val tl = Offset(center.x - r, center.y - r)
                    val sz = Size(r * 2, r * 2)
                    // Background track
                    drawArc(color.copy(0.2f), 0f, 360f, false,
                        tl, sz, style = stroke)
                    // Progress arc
                    drawArc(color, -90f, 360f * value, false,
                        tl, sz, style = stroke)
                }
            }
        }

        Spacer(Modifier.height(24.dp))
        Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
            RingLabel("Move", "${'$'}{(animMove * 100).toInt()}%", Color.Red)
            RingLabel("Exercise",
                "${'$'}{(animExercise * 100).toInt()}%", Color(0xFF34C759))
            RingLabel("Stand",
                "${'$'}{(animStand * 100).toInt()}%", Color(0xFF00BCD4))
        }

        Spacer(Modifier.height(24.dp))
        Button(onClick = {
            move = Random.nextFloat()
            exercise = Random.nextFloat()
            stand = Random.nextFloat()
        }) { Text("Randomize") }
    }
}

@Composable
fun RingLabel(label: String, value: String, color: Color) {
    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        Text(value, fontWeight = FontWeight.Bold, color = color)
        Text(label, fontSize = 12.sp, color = Color.Gray)
    }
}

Tips and Pitfalls

  • StrokeCap.Round gives the arcs smooth rounded endpoints. Without it, the ends are flat and look unfinished.
  • Start angle of -90f positions the arc start at the 12 o'clock position (top center).
  • Background track at 0.2f alpha shows the full ring outline for context.
  • Spring damping of 0.6f makes the rings overshoot slightly before settling, adding liveliness.

Get New Tutorials by Email

No spam. Just clear, practical breakdowns you can apply right away.

Enjoy this tutorial?

Get new practical tech tutorials in your inbox.