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.
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?
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.
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.
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
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
Quadrant 1 or angle between 270-360 might need a bit configuration but it almost keeps same distance to outer or user offset ring
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.
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
}
}
}
}
}