testivanivan
testivanivan

Reputation: 1514

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

Upvotes: 2

Views: 279

Answers (1)

Thracian
Thracian

Reputation: 67238

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

Upvotes: 4

Related Questions