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.