androidandroid-jetpack-composeandroid-jetpack-compose-canvasjetpack-compose-animationjetpack-compose-drawscope

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


Solution

  • 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)
    }