Skip to content

Instantly share code, notes, and snippets.

@Kyriakos-Georgiopoulos
Created June 4, 2026 13:18
Show Gist options
  • Select an option

  • Save Kyriakos-Georgiopoulos/da544712f00ca82578f605d2dc321b25 to your computer and use it in GitHub Desktop.

Select an option

Save Kyriakos-Georgiopoulos/da544712f00ca82578f605d2dc321b25 to your computer and use it in GitHub Desktop.
/*
* Copyright 2026 Kyriakos Georgiopoulos
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.hypot
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sin
import kotlin.random.Random
// ==========================================
// 1. DATA MODELS & CONFIG
// ==========================================
/**
* Defines how a pie slice is painted. Each variant maps to a different [Brush] type
* at draw time. When no [SliceBrush] is set on a [PieEntry], the [PieDataSet.defaultBrush]
* is used as fallback.
*
* ```
* // Solid fill
* PieEntry("a", "Sales", 300f, brush = SliceBrush.Solid(Color.Red))
*
* // Diagonal linear gradient
* PieEntry("b", "Costs", 200f, brush = SliceBrush.Linear(
* colors = listOf(Color.Cyan, Color.Blue),
* angleDegrees = 135f
* ))
*
* // Or use the extension shorthand
* PieEntry("c", "Tax", 100f, brush = Color.Green.toSliceBrush())
* ```
*/
sealed interface SliceBrush {
/** Flat single-color fill. */
@Immutable
data class Solid(val color: Color) : SliceBrush
/**
* Linear gradient across the slice.
* [angleDegrees] controls the gradient axis: 0 = left-to-right,
* 90 = top-to-bottom, 45 = diagonal. Defaults to 45.
*/
@Immutable
data class Linear(
val colors: List<Color>,
val angleDegrees: Float = 45f
) : SliceBrush
/**
* Radial gradient expanding outward from the pie center.
* Works well for donut charts where the gradient follows the ring.
*/
@Immutable
data class Radial(val colors: List<Color>) : SliceBrush
/**
* Sweep (conic) gradient rotating around the pie center.
* Creates a color wheel effect when applied to all slices.
*/
@Immutable
data class Sweep(val colors: List<Color>) : SliceBrush
}
/** Shorthand to wrap a [Color] into a [SliceBrush.Solid]. */
fun Color.toSliceBrush(): SliceBrush = SliceBrush.Solid(this)
/**
* A single slice in the pie chart.
*
* @param id Stable identifier used to track this entry across data updates and drive
* animations. Must be unique within the dataset. Changing the id is treated as a
* removal + insertion, not a value morph.
* @param label Human-readable name shown in tooltips and accessibility descriptions.
* @param value The raw numeric value. Does not need to sum to 100, the chart normalizes
* internally. Must be > 0; zero or negative values are effectively invisible.
* @param brush Fill style for this slice. When null, [PieDataSet.defaultBrush] is used.
*/
@Immutable
data class PieEntry(
val id: String,
val label: String,
val value: Float,
val brush: SliceBrush? = null
)
/**
* Holds the full dataset and chart-level defaults.
*
* @param entries The slices to render. Order determines drawing order (first entry starts
* at [PieChartStyle.startAngle]).
* @param defaultBrush Fallback brush for entries that don't specify their own.
* @param contentDescription Root accessibility label for the chart.
*/
@Immutable
data class PieDataSet(
val entries: List<PieEntry>,
val defaultBrush: SliceBrush = SliceBrush.Linear(
colors = listOf(Color(0xFF818CF8), Color(0xFF4F46E5))
),
val contentDescription: String = "Pie Chart"
)
/**
* Visual tuning for the chart geometry.
*
* The chart size is resolved in this order:
* 1. If [outerRadius] is specified (not [Dp.Unspecified]), it is used as the exact outer radius.
* 2. Otherwise, the chart auto-fits to the Canvas using [fillFraction] as a 0..1 ratio
* of the available space.
*
* @param startAngle Angle in degrees where the first slice begins. -90 = 12 o'clock.
* @param donutRatio Inner hole size as a fraction of the outer radius. 0 = solid pie,
* 0.5 = half-width ring. Clamped to 0..0.99 internally.
* @param sliceSpacingAngle Gap between slices in degrees. Ignored for single-entry datasets.
* @param unselectedAlpha Alpha applied to non-selected slices when a selection is active.
* @param selectedScale Scale factor applied to the selected slice. 1.05 = 5% larger.
* @param outerRadius Explicit outer radius in Dp. When [Dp.Unspecified], the chart
* auto-sizes using [fillFraction] instead.
* @param fillFraction 0..1 ratio of the smaller Canvas dimension used as the diameter.
* Only applies when [outerRadius] is [Dp.Unspecified]. Default 0.60.
* @param minSliceAngle Minimum sweep angle in degrees. Slices smaller than this are
* bumped up so they remain visible and tappable. 0 disables the floor.
*/
@Immutable
data class PieChartStyle(
val startAngle: Float = -90f,
val donutRatio: Float = 0.45f,
val sliceSpacingAngle: Float = 2f,
val unselectedAlpha: Float = 0.3f,
val selectedScale: Float = 1.05f,
val outerRadius: Dp = Dp.Unspecified,
val fillFraction: Float = 0.60f,
val minSliceAngle: Float = 0f
)
/**
* Timing configuration for all chart animations.
*
* @param initialEntrySpec Drives the first appearance of each slice (value animates from 0).
* @param morphSpec Drives value changes on existing slices (e.g. data update).
* @param selectionSpec Drives scale and alpha changes on tap.
* @param staggerDelayMs Delay between successive slice entry animations.
* @param startDelayMs Initial delay before the first slice begins animating.
*/
@Immutable
data class PieAnimationConfig(
val initialEntrySpec: AnimationSpec<Float> = tween(
durationMillis = 1000,
easing = FastOutSlowInEasing
),
val morphSpec: AnimationSpec<Float> = spring(
dampingRatio = 0.65f,
stiffness = Spring.StiffnessLow
),
val selectionSpec: AnimationSpec<Float> = tween(durationMillis = 200, easing = LinearEasing),
val staggerDelayMs: Long = 120L,
val startDelayMs: Long = 100L
)
/**
* Accessibility text builders. Override individual lambdas to customize screen reader output
* without replacing the entire a11y strategy.
*/
@Stable
data class PieA11yConfig(
val chartDescriptionBuilder: (PieDataSet) -> String = { ds ->
"Pie Chart representing ${ds.contentDescription}"
},
val sliceDescriptionBuilder: (PieEntry, Float) -> String = { entry, percentage ->
"${entry.label}, ${entry.value.toInt()} (${percentage.toInt()}% of total)."
},
val selectedStateDescription: (PieEntry?) -> String = { entry ->
entry?.let { "Currently selected: ${it.label}." } ?: "No slice selected."
}
)
// ==========================================
// 2. SELECTION INDICATOR API
// ==========================================
/**
* Rendering strategy for the visual indicator shown when a slice is selected.
* Implement this to provide a custom selection treatment (e.g. a leader line,
* a floating card, a glow effect).
*
* The chart calls [drawSelection] inside its [DrawScope] after all slices are drawn,
* only when a selection is active.
*
* @see TooltipPieSelectionRenderer for a centered tooltip implementation.
* @see ElbowCalloutPieSelectionRenderer for a leader-line + pill implementation.
*/
@Stable
fun interface PieChartSelectionRenderer {
/**
* @param entry The currently selected slice data.
* @param pieCenter Center point of the pie in Canvas coordinates.
* @param pieRadius Outer radius of the pie in pixels.
* @param sliceCentroid A point on the slice's mid-angle at the ring's center radius.
* Useful as an anchor for lines or indicators.
* @param midAngleDegrees The visual angle bisecting the selected slice, in degrees.
* Already accounts for RTL mirroring.
* @param textMeasurer Shared [TextMeasurer] for laying out label text.
* @param tooltipCache Reusable [TextLayoutResult] cache keyed by entry id + value.
* Avoids re-measuring identical text on every frame during animations.
* @param layoutDirection Current layout direction. Use this when your renderer
* needs to flip text alignment or anchor logic for RTL.
*/
fun DrawScope.drawSelection(
entry: PieEntry,
pieCenter: Offset,
pieRadius: Float,
sliceCentroid: Offset,
midAngleDegrees: Float,
textMeasurer: TextMeasurer,
tooltipCache: MutableMap<String, TextLayoutResult>,
layoutDirection: LayoutDirection
)
}
/**
* Renders a floating rounded-rect tooltip positioned radially outside the pie.
* Pushes outward from the slice's mid-angle so it never overlaps the chart center,
* and clamps to Canvas bounds to stay fully visible.
*/
@Stable
class TooltipPieSelectionRenderer(
val backgroundColor: Color = Color(0xFF111827),
val textStyle: TextStyle = TextStyle(
color = Color.White,
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
),
val cornerRadius: Dp = 8.dp,
val horizontalPadding: Dp = 12.dp,
val verticalPadding: Dp = 8.dp,
val radialOffset: Dp = 16.dp
) : PieChartSelectionRenderer {
override fun DrawScope.drawSelection(
entry: PieEntry,
pieCenter: Offset,
pieRadius: Float,
sliceCentroid: Offset,
midAngleDegrees: Float,
textMeasurer: TextMeasurer,
tooltipCache: MutableMap<String, TextLayoutResult>,
layoutDirection: LayoutDirection
) {
val cacheKey = "${entry.id}_${entry.value.toInt()}"
val tooltipLayout = tooltipCache.getOrPut(cacheKey) {
textMeasurer.measure(text = "${entry.label}: ${entry.value.toInt()}", style = textStyle)
}
val tooltipWidth = tooltipLayout.size.width + (horizontalPadding.toPx() * 2)
val tooltipHeight = tooltipLayout.size.height + (verticalPadding.toPx() * 2)
val angleRad = Math.toRadians(midAngleDegrees.toDouble())
val cosA = cos(angleRad).toFloat()
val sinA = sin(angleRad).toFloat()
val targetRadius = pieRadius + radialOffset.toPx()
val anchorX = pieCenter.x + targetRadius * cosA
val anchorY = pieCenter.y + targetRadius * sinA
val rawLeft = anchorX - (tooltipWidth / 2f) + (cosA * tooltipWidth / 2f)
val rawTop = anchorY - (tooltipHeight / 2f) + (sinA * tooltipHeight / 2f)
val safeLeft = rawLeft.coerceIn(0f, size.width - tooltipWidth)
val safeTop = rawTop.coerceIn(0f, size.height - tooltipHeight)
drawRoundRect(
color = backgroundColor,
topLeft = Offset(safeLeft, safeTop),
size = Size(tooltipWidth, tooltipHeight),
cornerRadius = CornerRadius(cornerRadius.toPx(), cornerRadius.toPx())
)
drawText(
textLayoutResult = tooltipLayout,
color = textStyle.color,
topLeft = Offset(
x = safeLeft + (tooltipWidth - tooltipLayout.size.width) / 2,
y = safeTop + (tooltipHeight - tooltipLayout.size.height) / 2
)
)
}
}
/**
* Renders a two-segment leader line (radial + horizontal stub) ending in a bordered
* pill-shaped label. The line originates at the slice centroid, breaks at a "knee"
* point outside the pie, then runs horizontally to the text pill.
*
* Automatically flips the horizontal direction based on which side of the pie the
* slice sits on, and clamps all geometry to Canvas bounds.
*/
@Stable
class ElbowCalloutPieSelectionRenderer(
val lineColor: Color = Color(0xFF374151),
val lineWidth: Dp = 2.dp,
val radialExtension: Dp = 24.dp,
val stubLength: Dp = 16.dp,
val textStyle: TextStyle = TextStyle(
color = Color(0xFF111827),
fontSize = 13.sp,
fontWeight = FontWeight.Black
),
val pillRadius: Dp = 6.dp,
val pillPaddingX: Dp = 10.dp,
val pillPaddingY: Dp = 6.dp,
val pillBackgroundColor: Color = Color.White
) : PieChartSelectionRenderer {
private val reusablePath = Path()
override fun DrawScope.drawSelection(
entry: PieEntry,
pieCenter: Offset,
pieRadius: Float,
sliceCentroid: Offset,
midAngleDegrees: Float,
textMeasurer: TextMeasurer,
tooltipCache: MutableMap<String, TextLayoutResult>,
layoutDirection: LayoutDirection
) {
val cacheKey = "${entry.id}_${entry.value.toInt()}"
val layout = tooltipCache.getOrPut(cacheKey) {
textMeasurer.measure(text = "${entry.label}: ${entry.value.toInt()}", style = textStyle)
}
val angleRad = Math.toRadians(midAngleDegrees.toDouble())
val cosA = cos(angleRad).toFloat()
val sinA = sin(angleRad).toFloat()
val isRightSide = cosA >= 0
val dotAnchorPoint = sliceCentroid
val dotRadius = 4.dp.toPx()
val bgWidth = layout.size.width + (pillPaddingX.toPx() * 2)
val bgHeight = layout.size.height + (pillPaddingY.toPx() * 2)
val margin = 8.dp.toPx()
val targetRadius = pieRadius + radialExtension.toPx()
var kneeX = pieCenter.x + targetRadius * cosA
var kneeY = pieCenter.y + targetRadius * sinA
val minKneeY = margin + bgHeight / 2f
val maxKneeY = size.height - margin - bgHeight / 2f
kneeY = kneeY.coerceIn(minKneeY, maxKneeY)
val stubPx = stubLength.toPx()
var endX = kneeX + if (isRightSide) stubPx else -stubPx
if (isRightSide) {
val maxEndX = size.width - margin - bgWidth
endX = min(endX, maxEndX)
kneeX = min(kneeX, endX - stubPx)
if (kneeX < dotAnchorPoint.x) {
kneeX = dotAnchorPoint.x
endX = kneeX + stubPx
}
} else {
val minEndX = margin + bgWidth
endX = max(endX, minEndX)
kneeX = max(kneeX, endX + stubPx)
if (kneeX > dotAnchorPoint.x) {
kneeX = dotAnchorPoint.x
endX = kneeX - stubPx
}
}
val safeBoxLeft = if (isRightSide) endX else endX - bgWidth
val safeBoxTop = kneeY - bgHeight / 2f
val sliceColor = resolveEntryColor(entry) ?: lineColor
reusablePath.apply {
reset()
moveTo(dotAnchorPoint.x, dotAnchorPoint.y)
lineTo(kneeX, kneeY)
lineTo(endX, kneeY)
}
drawPath(path = reusablePath, color = sliceColor, style = Stroke(width = lineWidth.toPx()))
drawCircle(color = sliceColor, radius = dotRadius, center = dotAnchorPoint)
drawRoundRect(
color = pillBackgroundColor,
topLeft = Offset(safeBoxLeft, safeBoxTop),
size = Size(bgWidth, bgHeight),
cornerRadius = CornerRadius(pillRadius.toPx(), pillRadius.toPx())
)
drawRoundRect(
color = sliceColor,
topLeft = Offset(safeBoxLeft, safeBoxTop),
size = Size(bgWidth, bgHeight),
cornerRadius = CornerRadius(pillRadius.toPx(), pillRadius.toPx()),
style = Stroke(width = lineWidth.toPx())
)
drawText(
textLayoutResult = layout,
color = textStyle.color,
topLeft = Offset(safeBoxLeft + pillPaddingX.toPx(), safeBoxTop + pillPaddingY.toPx())
)
}
}
// ==========================================
// 3. INTERNAL HELPERS
// ==========================================
/**
* Resolves a [SliceBrush] into a Compose [Brush] for the given chart geometry.
*/
private fun resolveBrush(
sliceBrush: SliceBrush,
cx: Float,
cy: Float,
radius: Float
): Brush = when (sliceBrush) {
is SliceBrush.Solid -> SolidColor(sliceBrush.color)
is SliceBrush.Linear -> {
val rad = Math.toRadians(sliceBrush.angleDegrees.toDouble())
val dx = cos(rad).toFloat() * radius
val dy = sin(rad).toFloat() * radius
Brush.linearGradient(
colors = sliceBrush.colors,
start = Offset(cx - dx, cy - dy),
end = Offset(cx + dx, cy + dy)
)
}
is SliceBrush.Radial -> Brush.radialGradient(
colors = sliceBrush.colors,
center = Offset(cx, cy),
radius = radius
)
is SliceBrush.Sweep -> Brush.sweepGradient(
colors = sliceBrush.colors,
center = Offset(cx, cy)
)
}
/**
* Extracts the first meaningful color from a [PieEntry]'s brush.
* Used by renderers (e.g. callout line color) to match the slice visually.
* Returns null if the entry has no brush set.
*/
private fun resolveEntryColor(entry: PieEntry): Color? = when (val b = entry.brush) {
is SliceBrush.Solid -> b.color
is SliceBrush.Linear -> b.colors.firstOrNull()
is SliceBrush.Radial -> b.colors.firstOrNull()
is SliceBrush.Sweep -> b.colors.firstOrNull()
null -> null
}
/**
* Resolves the outer radius in pixels based on [PieChartStyle] configuration.
* If [PieChartStyle.outerRadius] is specified, converts it to px.
* Otherwise, computes from the canvas dimensions and [PieChartStyle.fillFraction].
*/
private fun resolveOuterRadius(
style: PieChartStyle,
canvasWidth: Float,
canvasHeight: Float,
density: androidx.compose.ui.unit.Density
): Float {
return if (style.outerRadius != Dp.Unspecified) {
with(density) { style.outerRadius.toPx() }
} else {
(min(canvasWidth, canvasHeight) / 2f) * style.fillFraction.coerceIn(0.1f, 1f)
}
}
/**
* Computes the normalized sweep angle for a single entry, applying the minSliceAngle
* floor and the pre-computed normalizer. Pure function, no allocations.
*/
private inline fun computeNormalizedSweep(
animatedValue: Float,
totalValue: Float,
minSliceAngle: Float,
normalizer: Float
): Float {
var sweep = (animatedValue / totalValue) * 360f
if (minSliceAngle > 0f && sweep > 0f) {
sweep = sweep.coerceAtLeast(minSliceAngle)
}
return sweep * normalizer
}
// ==========================================
// 4. ANIMATION ENGINE
// ==========================================
/**
* Manages per-slice [Animatable] instances for value, scale, and alpha.
*
* Designed to be [remember]ed inside [PieChart]. All methods must run on the
* main thread, which is guaranteed by [SideEffect] and [LaunchedEffect] scopes.
*
* The lifecycle is split into two phases:
* - [syncAnimatables]: synchronous map housekeeping, called via [SideEffect]
* so maps are ready before the first draw of each frame.
* - [launchEntryAnimations] / [launchSelectionAnimations]: async coroutine
* launchers, called inside [LaunchedEffect] scopes that cancel correctly
* when keys change.
*/
@Stable
class PieChartAnimationEngine {
internal val valueAnimatables = mutableMapOf<String, Animatable<Float, AnimationVector1D>>()
internal val scaleAnimatables = mutableMapOf<String, Animatable<Float, AnimationVector1D>>()
internal val alphaAnimatables = mutableMapOf<String, Animatable<Float, AnimationVector1D>>()
private val initializedIds = mutableSetOf<String>()
/**
* Ensures Animatable instances exist for all current [entries] and removes stale
* ones from previous datasets. Synchronous and idempotent. Safe to call on every
* composition via [SideEffect] because it only performs map get-or-put operations
* and a single removeAll pass.
*/
fun syncAnimatables(entries: List<PieEntry>) {
val currentIds = entries.mapTo(mutableSetOf()) { it.id }
valueAnimatables.keys.removeAll { it !in currentIds }
scaleAnimatables.keys.removeAll { it !in currentIds }
alphaAnimatables.keys.removeAll { it !in currentIds }
initializedIds.removeAll { it !in currentIds }
entries.forEach { entry ->
valueAnimatables.getOrPut(entry.id) { Animatable(0f) }
scaleAnimatables.getOrPut(entry.id) { Animatable(1f) }
alphaAnimatables.getOrPut(entry.id) { Animatable(1f) }
}
}
/**
* Launches staggered value animations for each entry. New entries animate from 0
* with a stagger delay; existing entries morph to their new target value.
*
* Must be called inside a [LaunchedEffect] keyed on [entries] so that coroutines
* are cancelled and re-launched when data changes.
*/
fun launchEntryAnimations(
entries: List<PieEntry>,
config: PieAnimationConfig,
scope: CoroutineScope
) {
entries.forEachIndexed { index, entry ->
val valueAnim = valueAnimatables[entry.id] ?: return@forEachIndexed
val isInitialLoad = initializedIds.add(entry.id)
scope.launch {
if (isInitialLoad) {
delay(config.startDelayMs + (index * config.staggerDelayMs))
valueAnim.animateTo(entry.value, config.initialEntrySpec)
} else if (valueAnim.targetValue != entry.value) {
valueAnim.animateTo(entry.value, config.morphSpec)
}
}
}
}
/**
* Launches scale and alpha animations to reflect the current selection state.
* The selected slice scales up; all others dim. When [selectedEntry] is null,
* everything returns to default.
*
* Must be called inside a [LaunchedEffect] whose key includes [selectedEntry]
* so that a selection change cancels in-flight animations and starts fresh.
* The [entries] key should also be included so that new entries pick up the
* current selection state.
*/
fun launchSelectionAnimations(
entries: List<PieEntry>,
selectedEntry: PieEntry?,
style: PieChartStyle,
config: PieAnimationConfig,
scope: CoroutineScope
) {
entries.forEach { entry ->
val scaleAnim = scaleAnimatables[entry.id] ?: return@forEach
val alphaAnim = alphaAnimatables[entry.id] ?: return@forEach
val isSelected = selectedEntry?.id == entry.id
val targetScale = if (isSelected) style.selectedScale else 1f
val targetAlpha = if (selectedEntry != null && !isSelected) style.unselectedAlpha else 1f
if (scaleAnim.targetValue != targetScale) {
scope.launch { scaleAnim.animateTo(targetScale, config.selectionSpec) }
}
if (alphaAnim.targetValue != targetAlpha) {
scope.launch { alphaAnim.animateTo(targetAlpha, config.selectionSpec) }
}
}
}
}
// ==========================================
// 5. THE CHART COMPONENT
// ==========================================
/**
* A composable pie/donut chart with animated transitions, tap selection, gradient support,
* RTL mirroring, and a pluggable selection indicator.
*
* Usage:
* ```
* val data = PieDataSet(
* entries = listOf(
* PieEntry("a", "Mobile", 620f, brush = SliceBrush.Linear(listOf(cyan, blue))),
* PieEntry("b", "Desktop", 380f, brush = Color.Red.toSliceBrush()),
* )
* )
*
* var selected by remember { mutableStateOf<PieEntry?>(null) }
*
* PieChart(
* dataSet = data,
* modifier = Modifier.fillMaxWidth().height(300.dp),
* style = PieChartStyle(donutRatio = 0.5f),
* selectedEntry = selected,
* onSliceSelected = { selected = it },
* centerContent = {
* Text("Total\n${data.entries.sumOf { it.value.toInt() }}")
* }
* )
* ```
*
* @param dataSet The entries and default brush to render.
* @param style Geometry and visual configuration.
* @param animationConfig Timing for entry, morph, and selection animations.
* @param a11yConfig Accessibility label builders.
* @param selectionRenderer Strategy for drawing the selection indicator. Swap between
* [TooltipPieSelectionRenderer] and [ElbowCalloutPieSelectionRenderer], or provide
* your own [PieChartSelectionRenderer] implementation.
* @param selectedEntry The currently selected slice, or null for no selection.
* Hoist this into the parent to control selection externally.
* @param onSliceSelected Called when the user taps a slice (with the entry) or taps
* outside / re-taps the same slice (with null).
* @param centerContent Optional composable rendered at the center of a donut chart.
* Only visible when [PieChartStyle.donutRatio] > 0. Receives no parameters;
* size yourself relative to the donut hole or use fixed dimensions.
*/
@Composable
fun PieChart(
dataSet: PieDataSet,
modifier: Modifier = Modifier,
style: PieChartStyle = PieChartStyle(),
animationConfig: PieAnimationConfig = PieAnimationConfig(),
a11yConfig: PieA11yConfig = PieA11yConfig(),
selectionRenderer: PieChartSelectionRenderer = remember { TooltipPieSelectionRenderer() },
selectedEntry: PieEntry? = null,
onSliceSelected: (PieEntry?) -> Unit = {},
centerContent: @Composable (() -> Unit)? = null
) {
val textMeasurer = rememberTextMeasurer()
val entries = dataSet.entries
val animationEngine = remember { PieChartAnimationEngine() }
val density = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current
val isRtl = layoutDirection == LayoutDirection.Rtl
// ── Stable state refs for long-lived lambdas (pointerInput, derivedStateOf) ──
// These allow the pointer input coroutine and derived state lambda to always
// read the latest values without restarting or re-creating.
val currentSelectedEntry by rememberUpdatedState(selectedEntry)
val currentOnSliceSelected by rememberUpdatedState(onSliceSelected)
val currentStyle by rememberUpdatedState(style)
val currentIsRtl by rememberUpdatedState(isRtl)
val currentDensity by rememberUpdatedState(density)
val currentEntries by rememberUpdatedState(entries)
val selectionCache = remember { mutableMapOf<String, TextLayoutResult>() }
val targetTotalValue = remember(entries) {
entries.sumOf { it.value.toDouble().coerceAtLeast(0.0) }.toFloat()
}
val currentTargetTotal by rememberUpdatedState(targetTotalValue)
val chartDescription = remember(dataSet, selectedEntry, targetTotalValue, a11yConfig) {
buildString {
append(a11yConfig.chartDescriptionBuilder(dataSet)).append(". ")
if (targetTotalValue > 0f) {
entries.forEach { entry ->
val percentage = (entry.value / targetTotalValue) * 100
append(a11yConfig.sliceDescriptionBuilder(entry, percentage)).append(". ")
}
}
append(a11yConfig.selectedStateDescription(selectedEntry))
}
}
// ── Touch bounds computed reactively via derivedStateOf ──
// Reads animatable snapshot state + rememberUpdatedState refs.
// Evaluated lazily only when the pointer input handler accesses it on tap,
// not on every animation frame. Zero cost during idle and animation.
val sliceTouchBounds by remember {
derivedStateOf {
val ents = currentEntries
val total = currentTargetTotal
val startAngle = currentStyle.startAngle
val minAngle = currentStyle.minSliceAngle
if (ents.isEmpty() || total <= 0f) return@derivedStateOf emptyList()
// First pass: compute floor-adjusted sum for normalization (no allocation)
val hasMinAngle = minAngle > 0f
var rawSweepSum = 0f
ents.forEach { entry ->
val v = animationEngine.valueAnimatables[entry.id]?.value ?: 0f
var s = (v / total) * 360f
if (hasMinAngle && s > 0f) s = s.coerceAtLeast(minAngle)
rawSweepSum += s
}
val normalizer = if (rawSweepSum > 0f) 360f / rawSweepSum else 1f
// Second pass: build bounds list
val bounds = mutableListOf<Pair<PieEntry, ClosedFloatingPointRange<Float>>>()
var logicalStart = startAngle
ents.forEach { entry ->
val v = animationEngine.valueAnimatables[entry.id]?.value ?: 0f
val sweep = computeNormalizedSweep(v, total, minAngle, normalizer)
val normStart = (logicalStart - startAngle) % 360f
val normEnd = normStart + sweep
if (normStart > normEnd) {
bounds.add(entry to (normStart..360f))
bounds.add(entry to (0f..normEnd))
} else {
bounds.add(entry to (normStart..normEnd))
}
logicalStart += sweep
}
bounds
}
}
// ── Animatable map housekeeping (synchronous, runs before draw) ──
// SideEffect runs after every committed composition, before layout and draw.
// This ensures animatable maps are in sync with the latest entries before the
// Canvas reads them, eliminating the one-frame gap that LaunchedEffect would have.
SideEffect {
animationEngine.syncAnimatables(entries)
}
// ── Entry value animations ──
// Scoped to entries: when data changes, old stagger coroutines are cancelled
// and new ones start. No leaked animation coroutines across data updates.
LaunchedEffect(entries) {
selectionCache.clear()
if (currentSelectedEntry != null && entries.none { it.id == currentSelectedEntry?.id }) {
currentOnSliceSelected(null)
}
animationEngine.launchEntryAnimations(entries, animationConfig, this)
}
// ── Selection animations ──
// Keyed on both entries AND selectedEntry. When selection changes, old scale/alpha
// animations cancel and new ones start from the current animated value (no jump).
// When entries change, new entries pick up the current selection state.
LaunchedEffect(entries, selectedEntry) {
animationEngine.launchSelectionAnimations(
entries, selectedEntry, style, animationConfig, this
)
}
Box(
modifier = modifier,
contentAlignment = Alignment.Center
) {
Canvas(
modifier = Modifier
.fillMaxSize()
.semantics(mergeDescendants = true) {
contentDescription = chartDescription
}
// ── pointerInput(Unit): never restarts ──
// All external values are read through rememberUpdatedState refs,
// so the gesture coroutine survives data/style/selection changes
// without dropping touches during a restart window.
.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown()
val touchPos = down.position
val activeStyle = currentStyle
val activeIsRtl = currentIsRtl
val activeDensity = currentDensity
val cx = size.width.toFloat() / 2f
val cy = size.height.toFloat() / 2f
val effectiveX = if (activeIsRtl) size.width.toFloat() - touchPos.x else touchPos.x
val dx = effectiveX - cx
val dy = touchPos.y - cy
val touchRadius = hypot(dx.toDouble(), dy.toDouble()).toFloat()
val maxRadius = resolveOuterRadius(
activeStyle, size.width.toFloat(), size.height.toFloat(), activeDensity
)
val minRadius = maxRadius * activeStyle.donutRatio.coerceIn(0f, 0.9f)
if (touchRadius !in minRadius..maxRadius) {
if (currentSelectedEntry != null) currentOnSliceSelected(null)
return@awaitEachGesture
}
var touchAngle = Math.toDegrees(
atan2(dy.toDouble(), dx.toDouble())
).toFloat()
touchAngle = (touchAngle - activeStyle.startAngle) % 360f
if (touchAngle < 0f) touchAngle += 360f
// derivedStateOf evaluates here, on-demand at touch time
val tappedSlice = sliceTouchBounds.find { (_, bounds) ->
touchAngle in bounds
}?.first
if (tappedSlice != null) {
if (currentSelectedEntry?.id == tappedSlice.id) {
currentOnSliceSelected(null)
} else {
currentOnSliceSelected(tappedSlice)
}
} else {
currentOnSliceSelected(null)
}
}
}
) {
// ── Pure draw lambda: no state mutations ──
if (entries.isEmpty() || targetTotalValue <= 0f) return@Canvas
val canvasRadius = resolveOuterRadius(style, size.width, size.height, density)
val cx = size.width / 2f
val cy = size.height / 2f
val safeDonutRatio = style.donutRatio.coerceIn(0f, 0.99f)
val strokeWidth = canvasRadius * (1f - safeDonutRatio)
val drawRadius = canvasRadius - (strokeWidth / 2f)
val directionMultiplier = if (isRtl) -1f else 1f
// Two-pass min-angle normalization without list allocation.
// First pass: accumulate the floor-adjusted sweep sum.
val minAngle = style.minSliceAngle
val hasMinAngle = minAngle > 0f
var rawSweepSum = 0f
if (hasMinAngle) {
entries.forEach { entry ->
val v = animationEngine.valueAnimatables[entry.id]?.value ?: 0f
var s = (v / targetTotalValue) * 360f
if (s > 0f) s = s.coerceAtLeast(minAngle)
rawSweepSum += s
}
}
val normalizer = if (hasMinAngle && rawSweepSum > 0f) 360f / rawSweepSum else 1f
// Second pass: draw arcs.
var drawnStartAngle = style.startAngle
entries.forEach { entry ->
val animatedValue = animationEngine.valueAnimatables[entry.id]?.value ?: 0f
val sweepAngle = computeNormalizedSweep(
animatedValue, targetTotalValue, minAngle, normalizer
)
val currentScale = animationEngine.scaleAnimatables[entry.id]?.value ?: 1f
val currentAlpha = animationEngine.alphaAnimatables[entry.id]?.value ?: 1f
val scaledDrawRadius = drawRadius * currentScale
val scaledStrokeWidth = strokeWidth * currentScale
if (sweepAngle > 0f) {
val spacing = if (entries.size > 1 && sweepAngle > style.sliceSpacingAngle) {
style.sliceSpacingAngle
} else 0f
val finalSweepAngle = (sweepAngle - spacing).coerceAtLeast(0f)
val brush = resolveBrush(
sliceBrush = entry.brush ?: dataSet.defaultBrush,
cx = cx,
cy = cy,
radius = canvasRadius
)
drawArc(
brush = brush,
startAngle = drawnStartAngle,
sweepAngle = finalSweepAngle * directionMultiplier,
useCenter = false,
topLeft = Offset(cx - scaledDrawRadius, cy - scaledDrawRadius),
size = Size(scaledDrawRadius * 2, scaledDrawRadius * 2),
style = Stroke(width = scaledStrokeWidth),
alpha = currentAlpha
)
}
drawnStartAngle += (sweepAngle * directionMultiplier)
}
// Draw selection indicator
selectedEntry?.let { entry ->
val animatedValue = animationEngine.valueAnimatables[entry.id]?.value ?: 0f
if (animatedValue > 0f) {
var targetStartAngle = style.startAngle
for (e in entries) {
if (e.id == entry.id) break
val valAnim = animationEngine.valueAnimatables[e.id]?.value ?: 0f
targetStartAngle += (valAnim / targetTotalValue) * 360f * directionMultiplier
}
val sweepAngle = (animatedValue / targetTotalValue) * 360f * directionMultiplier
val midAngle = targetStartAngle + (sweepAngle / 2f)
val midAngleRad = Math.toRadians(midAngle.toDouble())
val centroidRadius = canvasRadius - (strokeWidth / 2f)
val centroidX = cx + (centroidRadius * cos(midAngleRad)).toFloat()
val centroidY = cy + (centroidRadius * sin(midAngleRad)).toFloat()
with(selectionRenderer) {
drawSelection(
entry = entry,
pieCenter = Offset(cx, cy),
pieRadius = canvasRadius,
sliceCentroid = Offset(centroidX, centroidY),
midAngleDegrees = midAngle,
textMeasurer = textMeasurer,
tooltipCache = selectionCache,
layoutDirection = layoutDirection
)
}
}
}
}
// Donut center content slot
if (centerContent != null && style.donutRatio > 0f) {
centerContent()
}
}
}
// ==========================================
// 6. DEMO IMPLEMENTATION
// ==========================================
@Composable
fun PieChartDemoScreen() {
val oceanBrush = SliceBrush.Linear(listOf(Color(0xFF00C9FF), Color(0xFF92FE9D)))
val emeraldBrush = SliceBrush.Radial(listOf(Color(0xFF11998E), Color(0xFF38EF7D)))
val sunsetBrush = SliceBrush.Linear(
colors = listOf(Color(0xFFFF512F), Color(0xFFF09819), Color(0xFFFFB75E)),
angleDegrees = 90f
)
val amethystBrush = SliceBrush.Sweep(
listOf(Color(0xFF8A2387), Color(0xFFE94057), Color(0xFFF27121))
)
val royalBrush = SliceBrush.Linear(listOf(Color(0xFF536976), Color(0xFF292E49)), angleDegrees = 135f)
var dataSet by remember {
mutableStateOf(
PieDataSet(
entries = listOf(
PieEntry("A", "Product A", 300f, brush = oceanBrush),
PieEntry("B", "Product B", 250f, brush = sunsetBrush),
PieEntry("C", "Product C", 400f, brush = amethystBrush),
PieEntry("D", "Product D", 150f, brush = emeraldBrush),
PieEntry("E", "Product E", 200f, brush = royalBrush)
),
contentDescription = "Market Share Distribution"
)
)
}
var selectedSliceId by rememberSaveable { mutableStateOf<String?>(null) }
val selectedSliceData by remember {
derivedStateOf { dataSet.entries.find { it.id == selectedSliceId } }
}
var isDonut by remember { mutableStateOf(true) }
var useCalloutRenderer by remember { mutableStateOf(true) }
val activeRenderer = remember(useCalloutRenderer) {
if (useCalloutRenderer) ElbowCalloutPieSelectionRenderer() else TooltipPieSelectionRenderer()
}
val total = remember(dataSet) { dataSet.entries.sumOf { it.value.toInt() } }
Column(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFFF3F4F6))
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(480.dp)
.background(Color.White, shape = RoundedCornerShape(24.dp))
.padding(32.dp)
) {
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 24.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
"Market Share",
fontSize = 20.sp,
fontWeight = FontWeight.ExtraBold,
color = Color(0xFF111827)
)
Text(
text = if (selectedSliceData != null) {
"Viewing metrics for ${selectedSliceData?.label}"
} else "Tap a slice to inspect.",
fontSize = 13.sp,
color = Color(0xFF6B7280),
modifier = Modifier.padding(top = 4.dp)
)
}
}
Box(
modifier = Modifier
.fillMaxSize()
.weight(1f),
contentAlignment = Alignment.Center
) {
PieChart(
dataSet = dataSet,
modifier = Modifier.fillMaxSize(),
style = PieChartStyle(
donutRatio = if (isDonut) 0.5f else 0f,
selectedScale = 1.05f,
fillFraction = 0.60f
),
selectionRenderer = activeRenderer,
selectedEntry = selectedSliceData,
onSliceSelected = { entry -> selectedSliceId = entry?.id },
centerContent = if (isDonut) {
{
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "$total",
fontSize = 22.sp,
fontWeight = FontWeight.ExtraBold,
color = Color(0xFF111827)
)
Text(
text = "Total",
fontSize = 12.sp,
color = Color(0xFF6B7280)
)
}
}
} else null
)
}
}
}
Spacer(modifier = Modifier.height(20.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Button(
onClick = { isDonut = !isDonut },
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF4F46E5)),
shape = RoundedCornerShape(12.dp),
contentPadding = PaddingValues(horizontal = 4.dp),
modifier = Modifier
.weight(1f)
.height(50.dp)
) {
Text(
text = if (isDonut) "Pie Style" else "Donut Style",
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Button(
onClick = { useCalloutRenderer = !useCalloutRenderer },
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF6366F1)),
shape = RoundedCornerShape(12.dp),
contentPadding = PaddingValues(horizontal = 4.dp),
modifier = Modifier
.weight(1f)
.height(50.dp)
) {
Text(
text = if (useCalloutRenderer) "Pill Text" else "Callout Line",
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Button(
onClick = {
val newEntries = dataSet.entries.map { entry ->
entry.copy(value = Random.nextInt(50, 500).toFloat())
}
dataSet = dataSet.copy(entries = newEntries)
},
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF111827)),
shape = RoundedCornerShape(12.dp),
contentPadding = PaddingValues(horizontal = 4.dp),
modifier = Modifier
.weight(1f)
.height(50.dp)
) {
Text(
text = "Update",
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment