What You Will Build

An animated donut chart with multiple colored segments, a center label showing the selected category or total, and a tappable legend below. The chart animates in from zero on first load, and tapping a legend item highlights that segment. This is the kind of chart used in budget tracking, analytics dashboards, and health apps.

Why This Pattern Matters

Custom charts give you full control over styling that third-party charting libraries cannot match. Drawing segmented arcs on Canvas with animated progress teaches you proportional angle math and interactive selection state management.

Step 1: Define the Segment Model

data class DonutSegment(
    val color: Color,
    val amount: Double,
    val label: String
)

Step 2: Build the Donut Chart

@Composable
fun CustomDonutChartScreen() {
    val segments = listOf(
        DonutSegment(Color(0xFFAF52DE), 500.0, "Shopping"),
        DonutSegment(Color(0xFF007AFF), 350.0, "Transport"),
        DonutSegment(Color(0xFFFF3B30), 250.0, "Health"),
        DonutSegment(Color(0xFFFFCC00), 400.0, "Travel"),
        DonutSegment(Color(0xFF34C759), 300.0, "Entertainment")
    )
    val total = segments.sumOf { it.amount }
    var selectedIndex by remember { mutableIntStateOf(-1) }
    val animatedProgress = remember { Animatable(0f) }

    LaunchedEffect(Unit) {
        animatedProgress.animateTo(1f, tween(1000))
    }

    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        Box(modifier = Modifier.size(220.dp),
            contentAlignment = Alignment.Center) {
            Canvas(modifier = Modifier.fillMaxSize()) {
                val strokeWidth = 40f
                val radius = (size.minDimension - strokeWidth) / 2
                val topLeft = Offset(
                    (size.width - radius * 2) / 2,
                    (size.height - radius * 2) / 2
                )
                val arcSize = Size(radius * 2, radius * 2)
                var startAngle = -90f

                segments.forEachIndexed { index, seg ->
                    val sweep = (seg.amount / total * 360 * 0.9 *
                        animatedProgress.value).toFloat()
                    val scale =
                        if (selectedIndex == index) 1.05f else 1f
                    drawArc(
                        seg.color, startAngle, sweep, false,
                        topLeft = topLeft, size = arcSize,
                        style = Stroke(
                            strokeWidth * scale,
                            cap = StrokeCap.Round
                        )
                    )
                    startAngle += (seg.amount / total * 360).toFloat()
                }
            }

            // Center label
            Column(horizontalAlignment = Alignment.CenterHorizontally) {
                Text(
                    if (selectedIndex >= 0)
                        segments[selectedIndex].label else "Total",
                    fontSize = 12.sp, color = Color.Gray
                )
                Text(
                    "${'$'}${'$'}{if (selectedIndex >= 0)
                        segments[selectedIndex].amount.toInt()
                    else total.toInt()}",
                    fontSize = 24.sp, fontWeight = FontWeight.Bold
                )
            }
        }

        // Legend
        segments.forEachIndexed { index, seg ->
            Row(
                modifier = Modifier
                    .fillMaxWidth()
                    .clickable {
                        selectedIndex =
                            if (selectedIndex == index) -1 else index
                    }
                    .padding(vertical = 6.dp, horizontal = 16.dp),
                verticalAlignment = Alignment.CenterVertically
            ) {
                Box(Modifier.size(12.dp).clip(CircleShape)
                    .background(seg.color))
                Spacer(Modifier.width(12.dp))
                Text(seg.label, modifier = Modifier.weight(1f))
                Text("${'$'}${'$'}{seg.amount.toInt()}",
                    fontWeight = FontWeight.Bold)
            }
        }
    }
}

Tips and Pitfalls

  • The 0.9 multiplier creates small gaps between segments. Without it, all arcs would touch and look like a single ring.
  • Use Animatable with LaunchedEffect for the initial load animation. This is more idiomatic than animateFloatAsState for one-shot animations.
  • Stroke width scales by 1.05x on the selected segment to create a subtle highlight effect.
  • Toggle selection off by checking if the tapped index equals the current selectedIndex.

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.