Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to check which arc or pie chart segment clicked in Jetpack Compose?

This is a share your knowledge, Q&A-style question inspired by this question to detect which section of arc segment or degree of touch inside a circle or semi-circle as in gif and image below. Also how stroke width changes are set by default inwards or outwards a Canvas or Composable with draw Modifier.

enter image description here

enter image description here

like image 865
Thracian Avatar asked Nov 14 '25 21:11

Thracian


1 Answers

By default half of the stroke is drawn inside selected position while the other half of it being is drawn out.

enter image description here

@Composable
private fun CanvasDefaultStroke() {

    var target by remember {
        mutableStateOf(1f)
    }
    val scale by animateFloatAsState(targetValue = target)

    Box(
        modifier = Modifier
            .fillMaxSize()
            .pointerInput(Unit) {
                detectTapGestures {
                    target = if (target == 1f) 1.3f else 1f
                }
            }
            .padding(40.dp),
        contentAlignment = Alignment.Center
    ) {
        Canvas(
            modifier = Modifier
                .fillMaxWidth()
                .aspectRatio(1f)
                .border(2.dp, Color.Red),
        ) {

            val radius = size.width / 2f * .8f
            val strokeWidth = (size.width - 2 * radius) / 2
            val newStrokeWidth = strokeWidth * scale
            drawRect(
                color = Color.Green,
                style = Stroke(width = newStrokeWidth)
            )
        }
    }
}

By changing topLeft and Size of the Rect arc is drawn into it's possible to create Arc that grows outwards when clicked or can be animated via an actions. In the image below radius of inner section of arc doesn't change which in the example below green rectangle never touches blue circle.

enter image description here

@Composable
private fun CanvasStrokeOutside() {

    var target by remember {
        mutableStateOf(1f)
    }
    val scale by animateFloatAsState(targetValue = target)

    Box(
        modifier = Modifier
            .fillMaxSize()
            .pointerInput(Unit) {
                detectTapGestures {
                    target = if (target == 1f) 1.3f else 1f
                }
            }
            .padding(40.dp),
        contentAlignment = Alignment.Center
    ) {
        Canvas(
            modifier = Modifier
                .fillMaxWidth()
                .aspectRatio(1f)
                .border(2.dp, Color.Red),
        ) {

            val radius = size.width / 2f * .8f
            val strokeWidth = (size.width - 2 * radius) / 2
            val newStrokeWidth = strokeWidth * scale
            drawRect(
                color = Color.Green,
                style = Stroke(width = newStrokeWidth),
                topLeft = Offset(
                    (size.width - 2 * radius - newStrokeWidth) / 2,
                    (size.width - 2 * radius - newStrokeWidth) / 2
                ),
                size = Size(2 * radius + newStrokeWidth, 2 * radius + newStrokeWidth)
            )

            drawCircle(color = Color.Blue, radius = radius)
        }
    }
}

enter image description here

When drawing a donut chart we need to have an outer radius which is represented with red circle, stroke width and inner radius which is represented with blue circle. I also used inner stroke width to give some depth to donut chart.

To calculate which section of a chart or circle we touch first we need to find out if we touch the section inside the arc by measuring distance from center of arc/circle to touch position since distance should be between inner radius and outer radius to be able to know that we touch the desired region.

val xPos = size.center.x - position.x
val yPos = size.center.y - position.y
val length = sqrt(xPos * xPos + yPos * yPos)
val isTouched = length in innerRadius - innerStrokeWidthPx..radius

If the touch position is inside the region that is desired we can get the angle using arctangent function which gives angle in radians.

https://en.wikipedia.org/wiki/Inverse_trigonometric_functions

if (isTouched) {
    var touchAngle =
        (-chartStartAngle + 180f + atan2(
            yPos,
            xPos
        ) * 180 / Math.PI) % 360f

    if (touchAngle < 0) {
        touchAngle += 360f
    }

After getting angle between center and touch position need to check out which segment this angle is in. I mapped angles in image to data as start and end angles

    chartDataList.forEachIndexed { index, chartData ->
        val range = chartData.range

        val isTouchInArcSegment = touchAngle in range
        if (chartData.isSelected) {
            chartData.isSelected = false
        } else {
            chartData.isSelected = isTouchInArcSegment
            if (isTouchInArcSegment) {
                onClick?.invoke(
                    ChartData(
                        color = chartData.color,
                        data = chartData.data
                    ), index
                )
            }
        }
    }
}

Mapping is done using start angle top start is -90 degrees in draw coordinate system

// Start angle of chart. Top center is -90, right center 0,
// bottom center 90, left center 180
val chartStartAngle = startAngle

val chartEndAngle = 360f + chartStartAngle

val sum = data.sumOf {
    it.data.toDouble()
}.toFloat()

val coEfficient = 360f / sum
var currentAngle = 0f
val currentSweepAngle = animatableInitialSweepAngle.value

val chartDataList = remember(data) {
    data.map {

        val chartData = it.data
        val range = currentAngle..currentAngle + chartData * coEfficient
        currentAngle += chartData * coEfficient

        AnimatedChartData(
            color = it.color,
            data = it.data,
            selected = false,
            range = range
        )
    }
}

Also for darken color based on colors passed i used

val colorInner =
   Color(
       ColorUtils
           .blendARGB(animatedColor.toArgb(), Color.Black.toArgb(), 0.1f)
   )

And to animate color between unselected color to selected color used lerp function which is the most convenient way to animate color between one to other

val animatedColor = androidx.compose.ui.graphics.lerp(
    color,
    color.copy(alpha = .8f),
    fraction
)

Full implementation

@Preview
@Composable
private fun PieChartPreview() {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .verticalScroll(rememberScrollState())
    ) {
        val data = remember {
            listOf(
                ChartData(Pink400, 10f),
                ChartData(Orange400, 20f),
                ChartData(Yellow400, 15f),
                ChartData(Green400, 5f),
                ChartData(Red400, 35f),
                ChartData(Blue400, 15f)
            )
        }

        PieChart(
            modifier = Modifier.fillMaxSize(),
            data = data,
            outerRingPercent = 35,
            innerRingPercent = 10,
            dividerStrokeWidth = 3.dp
        )

        PieChart(
            modifier = Modifier.fillMaxSize(),
            data = data,
            outerRingPercent = 100,
            innerRingPercent = 0,
            startAngle = -90f,
            drawText = false,
            dividerStrokeWidth = 0.dp
        )

        PieChart(
            modifier = Modifier.fillMaxSize(),
            data = data,
            outerRingPercent = 25,
            innerRingPercent = 0,
            dividerStrokeWidth = 2.dp
        )
    }
}

@Composable
fun PieChart(
    modifier: Modifier,
    data: List<ChartData>,
    startAngle: Float = 0f,
    outerRingPercent: Int = 35,
    innerRingPercent: Int = 10,
    dividerStrokeWidth: Dp = 0.dp,
    drawText: Boolean = true,
    onClick: ((data: ChartData, index: Int) -> Unit)? = null
) {

    BoxWithConstraints(
        modifier = modifier,
        contentAlignment = Alignment.Center
    ) {

        val density = LocalDensity.current

        val width = constraints.maxWidth.toFloat()

        // Outer radius of chart. This is edge of stroke width as
        val radius = (width / 2f) * .9f
        val outerStrokeWidthPx =
            (radius * outerRingPercent / 100f).coerceIn(0f, radius)

        // Inner radius of chart. Semi transparent inner ring
        val innerRadius = (radius - outerStrokeWidthPx).coerceIn(0f, radius)
        val innerStrokeWidthPx =
            (radius * innerRingPercent / 100f).coerceIn(0f, radius)

        val lineStrokeWidth = with(density) { dividerStrokeWidth.toPx() }

        // Start angle of chart. Top center is -90, right center 0,
        // bottom center 90, left center 180
        val chartStartAngle = startAngle
        val animatableInitialSweepAngle = remember {
            Animatable(chartStartAngle)
        }

        val chartEndAngle = 360f + chartStartAngle

        val sum = data.sumOf {
            it.data.toDouble()
        }.toFloat()

        val coEfficient = 360f / sum
        var currentAngle = 0f
        val currentSweepAngle = animatableInitialSweepAngle.value

        val chartDataList = remember(data) {
            data.map {

                val chartData = it.data
                val range = currentAngle..currentAngle + chartData * coEfficient
                currentAngle += chartData * coEfficient

                AnimatedChartData(
                    color = it.color,
                    data = it.data,
                    selected = false,
                    range = range
                )
            }
        }

        chartDataList.forEach {
            LaunchedEffect(key1 = it.isSelected) {
                // This is for scaling radius
                val targetValue = (if (it.isSelected) width / 2 else radius) / radius

                // This is for increasing outer ring
//                val targetValue = if (it.isSelected) outerStrokeWidthPx + width / 2 - radius
//                else outerStrokeWidthPx
                it.animatable.animateTo(targetValue, animationSpec = tween(500))
            }
        }

        LaunchedEffect(key1 = animatableInitialSweepAngle) {
            animatableInitialSweepAngle.animateTo(
                targetValue = chartEndAngle,
                animationSpec = tween(
                    delayMillis = 1000,
                    durationMillis = 1500
                )
            )
        }

        val textMeasurer = rememberTextMeasurer()
        val textMeasureResults: List<TextLayoutResult> = remember(chartDataList) {
            chartDataList.map {
                textMeasurer.measure(
                    text = "%${it.data.toInt()}",
                    style = TextStyle(
                        fontSize = 16.sp,
                        fontWeight = FontWeight.Bold
                    )
                )
            }
        }

        val chartModifier = Modifier
            .fillMaxWidth()
            .aspectRatio(1f)
            .pointerInput(Unit) {
                detectTapGestures(
                    onTap = { position: Offset ->
                        val xPos = size.center.x - position.x
                        val yPos = size.center.y - position.y
                        val length = sqrt(xPos * xPos + yPos * yPos)
                        val isTouched = length in innerRadius - innerStrokeWidthPx..radius

                        if (isTouched) {
                            var touchAngle =
                                (-chartStartAngle + 180f + atan2(
                                    yPos,
                                    xPos
                                ) * 180 / Math.PI) % 360f

                            if (touchAngle < 0) {
                                touchAngle += 360f
                            }


                            chartDataList.forEachIndexed { index, chartData ->
                                val range = chartData.range

                                val isTouchInArcSegment = touchAngle in range
                                if (chartData.isSelected) {
                                    chartData.isSelected = false
                                } else {
                                    chartData.isSelected = isTouchInArcSegment
                                    if (isTouchInArcSegment) {
                                        onClick?.invoke(
                                            ChartData(
                                                color = chartData.color,
                                                data = chartData.data
                                            ), index
                                        )
                                    }
                                }
                            }
                        }
                    }
                )
            }

        PieChartImpl(
            modifier = chartModifier,
            chartDataList = chartDataList,
            textMeasureResults = textMeasureResults,
            currentSweepAngle = currentSweepAngle,
            chartStartAngle = chartStartAngle,
            chartEndAngle = chartEndAngle,
            outerRadius = radius,
            outerStrokeWidth = outerStrokeWidthPx,
            innerRadius = innerRadius,
            innerStrokeWidth = innerStrokeWidthPx,
            lineStrokeWidth = lineStrokeWidth,
            drawText = drawText
        )

    }
}

@Composable
private fun PieChartImpl(
    modifier: Modifier = Modifier,
    chartDataList: List<AnimatedChartData>,
    textMeasureResults: List<TextLayoutResult>,
    currentSweepAngle: Float,
    chartStartAngle: Float,
    chartEndAngle: Float,
    outerRadius: Float,
    outerStrokeWidth: Float,
    innerRadius: Float,
    innerStrokeWidth: Float,
    lineStrokeWidth: Float,
    drawText: Boolean
) {
    Canvas(modifier = modifier) {

        val width = size.width
        var startAngle = chartStartAngle

        for (index in 0..chartDataList.lastIndex) {

            val chartData = chartDataList[index]
            val range = chartData.range
            val sweepAngle = range.endInclusive - range.start
            val angleInRadians = (startAngle + sweepAngle / 2).degreeToRadian
            val textMeasureResult = textMeasureResults[index]
            val textSize = textMeasureResult.size

            val currentStrokeWidth = outerStrokeWidth
            // This is for increasing stroke width without scaling
//            val currentStrokeWidth = chartData.animatable.value

            withTransform(
                {
                    val scale = chartData.animatable.value
                    scale(
                        scaleX = scale,
                        scaleY = scale
                    )
                }
            ) {

                if (startAngle <= currentSweepAngle) {

                    val color = chartData.color
                    val diff = (width / 2 - outerRadius) / outerRadius
                    val fraction = (chartData.animatable.value - 1f) / diff

                    val animatedColor = androidx.compose.ui.graphics.lerp(
                        color,
                        color.copy(alpha = .8f),
                        fraction
                    )

                    val colorInner =
                        Color(
                            ColorUtils
                                .blendARGB(animatedColor.toArgb(), Color.Black.toArgb(), 0.1f)
                        )


                    // Outer Arc Segment
                    drawArc(
                        color = animatedColor,
                        startAngle = startAngle,
                        sweepAngle = sweepAngle.coerceAtMost(
                            currentSweepAngle - startAngle
                        ),
                        useCenter = false,
                        topLeft = Offset(
                            (width - 2 * innerRadius - currentStrokeWidth) / 2,
                            (width - 2 * innerRadius - currentStrokeWidth) / 2
                        ),
                        size = Size(
                            innerRadius * 2 + currentStrokeWidth,
                            innerRadius * 2 + currentStrokeWidth
                        ),
                        style = Stroke(currentStrokeWidth)
                    )


                    // Inner Arc Segment
                    drawArc(
                        color = colorInner,
                        startAngle = startAngle,
                        sweepAngle = sweepAngle.coerceAtMost(
                            currentSweepAngle - startAngle
                        ),
                        useCenter = false,
                        topLeft = Offset(
                            (width - 2 * innerRadius) / 2 + innerStrokeWidth / 2,
                            (width - 2 * innerRadius) / 2 + innerStrokeWidth / 2
                        ),
                        size = Size(
                            2 * innerRadius - innerStrokeWidth,
                            2 * innerRadius - innerStrokeWidth
                        ),
                        style = Stroke(innerStrokeWidth)
                    )
                }

                val textCenter = textSize.center

                if (drawText && currentSweepAngle == chartEndAngle) {
                    drawText(
                        textLayoutResult = textMeasureResult,
                        color = Color.Black,
                        topLeft = Offset(
                            -textCenter.x + center.x
                                    + (innerRadius + currentStrokeWidth / 2) * cos(angleInRadians),
                            -textCenter.y + center.y
                                    + (innerRadius + currentStrokeWidth / 2) * sin(angleInRadians)
                        )
                    )
                }
            }

            startAngle += sweepAngle
        }

        for (index in 0..chartDataList.lastIndex) {

            val chartData = chartDataList[index]
            val range = chartData.range
            val sweepAngle = range.endInclusive - range.start

            // Divider
            rotate(
                90f + startAngle
            ) {
                drawLine(
                    color = Color.White,
                    start = Offset(
                        center.x,
                        (width / 2 - innerRadius + innerStrokeWidth)
                            .coerceAtMost(width / 2)
                    ),
                    end = Offset(center.x, 0f),
                    strokeWidth = lineStrokeWidth
                )
            }

            startAngle += sweepAngle
        }

    }
}


@Immutable
data class ChartData(val color: Color, val data: Float)

@Immutable
internal class AnimatedChartData(
    val color: Color,
    val data: Float,
    selected: Boolean = false,
    val range: ClosedFloatingPointRange<Float>,
    val animatable: Animatable<Float, AnimationVector1D> = Animatable(1f)
) {
    var isSelected by mutableStateOf(selected)
}
like image 168
Thracian Avatar answered Nov 17 '25 12:11

Thracian



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!