android-jetpack-composeandroid-canvaspie-chartdrawtextandroid-jetpack-compose-canvas

Jetpack Compose. How to draw pie chart with labels?


I'm trying to draw pie chart with sections and show labels for each section. The label should be shown at the beginning of each section with a padding of 10dp from the pie chart.

example of expecting pie chart

Here is my code:

@Composable
fun PieChartLocal(
    modifier: Modifier = Modifier,
    width: Float,
    thickness: Float,
    duration: Int,
    sections: List<Section>,
) {
    val sweepAngles = remember(sections) { findSweepAngles(sections) }
    val animateFloat = remember { Animatable(0f) }

    val density = LocalDensity.current
    val paddingDp = 18.dp
    val textSizeDp = 10.sp
    val paddingPx = with(density) { paddingDp.toPx() }

    val textSizePx = with(density) { textSizeDp.toPx() }

    LaunchedEffect(animateFloat) {
        animateFloat.animateTo(
            targetValue = 1f,
            animationSpec = tween(durationMillis = duration, easing = LinearEasing)
        )
    }

    Canvas(
        modifier = modifier
            .size((width + 2 * (paddingDp.value + textSizeDp.value)).dp)
    ) {
        var startAngle = 0f
        val radius =
            (size.minDimension - 2 * (paddingPx + textSizePx)) / 2
        val center = size.center

        for (i in sweepAngles.indices) {
            val sweepAngle = sweepAngles[i] * animateFloat.value
            val color = sections[i].color

            drawArc(
                color = color,
                startAngle = startAngle,
                sweepAngle = sweepAngle,
                useCenter = false,
                style = Stroke(width = thickness),
                topLeft = Offset(center.x - radius, center.y - radius),
                size = Size(radius * 2, radius * 2),
            )

            val angleInRadians = Math.toRadians(startAngle.toDouble())
            val textRadius = radius + paddingPx
            val x = center.x + textRadius * cos(angleInRadians).toFloat()
            val y = center.y + textRadius * sin(angleInRadians).toFloat()

            val percentage = (sweepAngles[i] / ROUND_ANGLE * 100).toInt()
            
            drawContext.canvas.nativeCanvas.apply {
                drawText(
                    "$percentage%",
                    x,
                    y,
                    TextPaint().apply {
                        textAlign = Paint.Align.CENTER
                        textSize = textSizePx
                    }
                )
            }

            startAngle += sweepAngle
        }
    }
}

private fun findSweepAngles(sections: List<Section>): List<Float> {
    val values = sections.map(Section::value)
    val sumValues = values.sum()
    return values.map { value -> ROUND_ANGLE * value / sumValues }
}

The problem is that for some labels I have an incorrect offset from the pie chart. For a number of labels it is equal to 10dp as expected, and for the rest it is completely absent. What could be the problem?

Please, help me..

P.S. And how to draw label not at the beginning of the segment, but in the center, but so that it still remains outside, and not inside, the pie chart?

centered labels


Solution

  • You can refer this answer for drawing a pie chart with text at center of each segment.

    If you wish draw text at start of segment you can use equation such as

    drawText(
        textLayoutResult = textMeasureResult,
        color = Color.DarkGray,
        topLeft = Offset(
            x = center.x + textOffsetX + (offset + outerRadius) * cos,
            y = center.y + textOffsetY + (offset + outerRadius) * sin
        )
    )
    

    textOffset is what we use for offsetting from top left of text bounds base on rectangle of Text.

    offset is extra user offset in our case 10.dp,

    outer radius is the outer radius of the chart, its radius with blue circle in gif below.

    enter image description here

    In gif the reason text goes below segments i drew arcs and segments together. If they are ever to intersect draw texts in another loop

    @Preview
    @Composable
    private fun PieChartWithText() {
    
        Column(
            modifier = Modifier.fillMaxSize().padding(16.dp)
        ) {
    
            var chartStartAngle by remember {
                mutableFloatStateOf(0f)
            }
    
            Text("Chart Start angle: ${chartStartAngle.toInt()}")
            Slider(
                value = chartStartAngle,
                onValueChange = {
                    chartStartAngle = it
                },
                valueRange = 0f..360f
            )
    
            Box(
                modifier = Modifier
                    .fillMaxSize()
                    .padding(20.dp),
                contentAlignment = Alignment.Center
            ) {
                val chartDataList = listOf(
                    PieChartData(Pink400, 10f),
                    PieChartData(Orange400, 30f),
                    PieChartData(Yellow400, 40f),
                    PieChartData(Blue400, 20f)
                )
    
                val textMeasurer = rememberTextMeasurer()
                val textMeasureResults = remember(chartDataList) {
                    chartDataList.map {
                        textMeasurer.measure(
                            text = "${it.data.toInt()}%",
                            style = TextStyle(
                                fontSize = 24.sp,
                                fontWeight = FontWeight.Bold
                            )
                        )
                    }
                }
    
                Canvas(
                    modifier = Modifier
                        .padding(24.dp)
                        .fillMaxWidth()
                        .aspectRatio(1f)
                ) {
                    val width = size.width
                    val radius = width * .22f
                    val strokeWidth = radius * .6f
                    val outerRadius = radius + strokeWidth + strokeWidth / 2
                    val diameter = (radius + strokeWidth) * 2
                    val topLeft = (width - diameter) / 2f
    
                    var startAngle = chartStartAngle
    
                    for (index in 0..chartDataList.lastIndex) {
    
                        startAngle %= 360
    
                        val chartData = chartDataList[index]
                        val sweepAngle = chartData.data.asAngle
                        val textMeasureResult = textMeasureResults[index]
                        val textSize = textMeasureResult.size
    
                        val offset = 0.dp.toPx()
    
                        drawArc(
                            color = chartData.color,
                            startAngle = startAngle,
                            sweepAngle = sweepAngle,
                            useCenter = false,
                            topLeft = Offset(topLeft, topLeft),
                            size = Size(diameter, diameter),
                            style = Stroke(strokeWidth)
                        )
    
                        val rect = textMeasureResult.getBoundingBox(0)
    
                        val cos = cos(startAngle.degreeToRadian)
                        val sin = sin(startAngle.degreeToRadian)
    
    //                    val textOffset = getTextOffsets(startAngle, textSize)
                        val textOffsetX = 0
                        val textOffsetY = 0
    
                        drawCircle(
                            color = Color.Blue,
                            radius = outerRadius,
                            style = Stroke(2.dp.toPx())
                        )
    
                  /*      drawCircle(
                            color = Color.Magenta,
                            radius = outerRadius + offset,
                            style = Stroke(2.dp.toPx())
                        )
    */
                        drawRect(
                            color = Color.Black,
                            topLeft = Offset(
                                x = rect.topLeft.x + center.x + textOffsetX + (offset + outerRadius) * cos,
                                y = rect.topLeft.y + center.y + textOffsetY + (offset + outerRadius) * sin
                            ),
                            size = textSize.toSize(),
                            style = Stroke(3.dp.toPx())
                        )
    
                        drawText(
                            textLayoutResult = textMeasureResult,
                            color = Color.DarkGray,
                            topLeft = Offset(
                                x = center.x + textOffsetX + (offset + outerRadius) * cos,
                                y = center.y + textOffsetY + (offset + outerRadius) * sin
                            )
                        )
    
                        startAngle += sweepAngle
                    }
                }
            }
        }
    }
    
    private fun getTextOffsets(startAngle: Float, textSize: IntSize): Offset {
        var textOffsetX: Int = 0
        var textOffsetY: Int = 0
    
        when (startAngle) {
            in 0f..90f -> {
                textOffsetX = 0
                textOffsetY = 0
            }
    
            in 90f..180f -> {
                textOffsetX = -textSize.width
                textOffsetY = 0
            }
    
            in 180f..270f -> {
                textOffsetX = -textSize.width
                textOffsetY = -textSize.height
            }
    
            else -> {
                textOffsetX = 0
                textOffsetY = -textSize.height
            }
        }
        return Offset(textOffsetX.toFloat(), textOffsetY.toFloat())
    }
    
    private val Float.degreeToRadian
        get() = (this * Math.PI / 180f).toFloat()
    
    private val Float.asAngle: Float
        get() = this * 360f / 100f
    
    @Immutable
    data class ChartData(val color: Color, val data: Float)
    

    Top left of rectangle of texts point to start of each segment. Next step is changing where start position of segment should touch in rectangle for each quadrants.

    enter image description here

    Quadrant 1 -> rect bottom start
    
    Quadrant 2 -> rect bottom end
    
    Quadrant 3 -> rect top end
    
    Quadrant 4 -> rect top start
    

    Which is added with

    val textOffset = getTextOffsets(startAngle, textSize)
    val textOffsetX = textOffset.x
    val textOffsetY = textOffset.y
    

    Result when you get text offsets that aligned in each quadrant with getTextOffsets

    enter image description here

    Center labels approximately to start of segments

    If you wish to center labels approximately to start of each segment you can update getOffsetTexts as you see fit.

    I updated them to be close to center and have non discrete change after passing next quadrant with

    private fun getTextOffsets(startAngle: Float, textSize: IntSize): Offset {
        var textOffsetX: Int = 0
        var textOffsetY: Int = 0
    
        when (startAngle) {
            in 0f..90f -> {
                textOffsetX = if (startAngle < 60) 0
                else (-textSize.width / 2 * ((startAngle - 60) / 30)).toInt()
    
                textOffsetY = 0
            }
    
            in 90f..180f -> {
                textOffsetX = (-textSize.width / 2 - textSize.width / 2 * (startAngle - 90f) / 45).toInt()
                    .coerceAtLeast(-textSize.width)
    
                textOffsetY = if (startAngle < 135) 0
                else (-textSize.height / 2 * ((startAngle - 135) / 45)).toInt()
            }
    
            in 180f..270f -> {
                textOffsetX = if (startAngle < 240) -textSize.width
                else (-textSize.width + textSize.width / 2 * (startAngle - 240) / 30).toInt()
    
                textOffsetY = if (startAngle < 225) (-textSize.height / 2 * ((startAngle - 135) / 45)).toInt()
                else -textSize.height
            }
    
            else -> {
                textOffsetX =
                    if (startAngle < 315) (-textSize.width / 2 + (textSize.width / 2) * (startAngle - 270) / 45).toInt()
                    else 0
    
                textOffsetY = if (startAngle < 315) -textSize.height
                else (-textSize.height + textSize.height * (startAngle - 315) / 45).toInt()
            }
        }
        return Offset(textOffsetX.toFloat(), textOffsetY.toFloat())
    }
    

    Result

    enter image description here

    Quadrant 1 or angle between 270-360 might need a bit configuration but it almost keeps same distance to outer or user offset ring

    Placing texts outside at center of projection of segments

    Draw outside of chart but in projection of center of segments with

    val angleInRadians = (startAngle + sweepAngle / 2).degreeToRadian
    val textCenter = textSize.center
    
    drawRect(
        color = Color.Black,
        topLeft = Offset(
            -textCenter.x + center.x + (outerRadius + offset) * cos(angleInRadians),
            -textCenter.y + center.y + (outerRadius + offset) * sin(angleInRadians)
        ),
        size = textSize.toSize(),
        style = Stroke(3.dp.toPx())
    )
    
    drawText(
        textLayoutResult = textMeasureResult,
        color = Color.DarkGray,
        topLeft = Offset(
            -textCenter.x + center.x + (outerRadius + offset) * cos(angleInRadians),
            -textCenter.y + center.y + (outerRadius + offset) * sin(angleInRadians)
        )
    )
    

    Which doesn't require text offset for this approach since they will always be at the center.

    But in this case offset if from outer ring to center of texts or rectangles in amages so you need to set offset bigger.

    enter image description here

    Full code

    @Preview
    @Composable
    private fun PieChartWithText() {
    
        Column(
            modifier = Modifier.fillMaxSize().padding(16.dp)
        ) {
    
            var chartStartAngle by remember {
                mutableFloatStateOf(0f)
            }
    
            Text("Chart Start angle: ${chartStartAngle.toInt()}")
            Slider(
                value = chartStartAngle,
                onValueChange = {
                    chartStartAngle = it
                },
                valueRange = 0f..360f
            )
    
            Box(
                modifier = Modifier
                    .fillMaxSize()
                    .padding(20.dp),
                contentAlignment = Alignment.Center
            ) {
                val chartDataList = listOf(
                    PieChartData(Pink400, 10f),
                    PieChartData(Orange400, 30f),
                    PieChartData(Yellow400, 40f),
                    PieChartData(Blue400, 20f)
                )
    
                val textMeasurer = rememberTextMeasurer()
                val textMeasureResults = remember(chartDataList) {
                    chartDataList.map {
                        textMeasurer.measure(
                            text = "${it.data.toInt()}%",
                            style = TextStyle(
                                fontSize = 20.sp,
                                fontWeight = FontWeight.Bold
                            )
                        )
                    }
                }
    
                Canvas(
                    modifier = Modifier
                        .padding(24.dp)
                        .fillMaxWidth()
                        .aspectRatio(1f)
                ) {
                    val width = size.width
                    val radius = width * .22f
                    val strokeWidth = radius * .6f
                    val outerRadius = radius + strokeWidth + strokeWidth / 2
                    val diameter = (radius + strokeWidth) * 2
                    val topLeft = (width - diameter) / 2f
    
                    var startAngle = chartStartAngle
    
                    for (index in 0..chartDataList.lastIndex) {
    
                        startAngle %= 360
    
                        val chartData = chartDataList[index]
                        val sweepAngle = chartData.data.asAngle
                        val textMeasureResult = textMeasureResults[index]
                        val textSize = textMeasureResult.size
    
                        val offset = 30.dp.toPx()
    
                        drawArc(
                            color = chartData.color,
                            startAngle = startAngle,
                            sweepAngle = sweepAngle,
                            useCenter = false,
                            topLeft = Offset(topLeft, topLeft),
                            size = Size(diameter, diameter),
                            style = Stroke(strokeWidth)
                        )
    
                        val rect = textMeasureResult.getBoundingBox(0)
    
                        val adjustedAngle = (startAngle) % 360
    
                        val cos = cos(adjustedAngle.degreeToRadian)
                        val sin = sin(adjustedAngle.degreeToRadian)
    
    //                    val textOffset = getTextOffsets(startAngle, textSize)
                        val textOffsetX = -textSize.center.x
                        val textOffsetY = -textSize.center.y
    
                        drawCircle(
                            color = Color.Blue,
                            radius = outerRadius,
                            style = Stroke(2.dp.toPx())
                        )
    
                        drawCircle(
                            color = Color.Magenta,
                            radius = outerRadius + offset,
                            style = Stroke(2.dp.toPx())
                        )
    
                        val angleInRadians = (startAngle + sweepAngle / 2).degreeToRadian
                        val textCenter = textSize.center
    
                        drawRect(
                            color = Color.Black,
                            topLeft = Offset(
                                -textCenter.x + center.x + (outerRadius + offset) * cos(angleInRadians),
                                -textCenter.y + center.y + (outerRadius + offset) * sin(angleInRadians)
                            ),
                            size = textSize.toSize(),
                            style = Stroke(3.dp.toPx())
                        )
    
                        drawText(
                            textLayoutResult = textMeasureResult,
                            color = Color.DarkGray,
                            topLeft = Offset(
                                -textCenter.x + center.x + (outerRadius + offset) * cos(angleInRadians),
                                -textCenter.y + center.y + (outerRadius + offset) * sin(angleInRadians)
                            )
                        )
    
                        startAngle += sweepAngle
                    }
                }
            }
        }
    }