3

I'm using a canvas in jetpack compose to draw a circle formed by multiple arches. And to for the arches to look better I set the cap to a round shape.

  style = Stroke(width = chartBarWidth.toPx(),
                 cap = StrokeCap.Round)

The problem is that when using this stroke cap, is that the arch angle does not ajust to take into consideration the extra degrees produced by the StrokeCap. So I end up with arches overlapping . How can I calculate the extra degrees produced by the strokeCap ?enter image description here

Thracian
  • 43,021
  • 16
  • 133
  • 222

1 Answers1

6

You need to calculate circumference of circle at center position and divide it to stroke width to get angle. Then add this to startAngle and remove 2 times from sweep angle to get same length after rounded stroke width is removed.

enter image description here

val width = size.width
val radius = width / 2f
val strokeWidth = 20.dp.toPx()
            
val circumference = 2 * Math.PI * (radius - strokeWidth / 2)
val strokeAngle = (strokeWidth / circumference * 180f).toFloat()

drawArc(
    color = Color.Blue,
    startAngle = 180f + strokeAngle,
    sweepAngle = 180f - strokeAngle * 2,
    useCenter = false,
    topLeft = Offset(strokeWidth / 2, strokeWidth / 2),
    size = Size(width - strokeWidth, width - strokeWidth),
    style = Stroke(strokeWidth, cap = StrokeCap.Round)

)
            
drawArc(
    color = Color.Red,
    startAngle = 0f + strokeAngle,
    sweepAngle = 180f - strokeAngle * 2,
    useCenter = false,
    topLeft = Offset(strokeWidth / 2, strokeWidth / 2),
    size = Size(width - strokeWidth, width - strokeWidth),
    style = Stroke(strokeWidth, cap = StrokeCap.Round)
)

You can add a coefficient if you wish to increase space between arcs

val strokeAngle = 1.1f * (strokeWidth / circumference * 180f).toFloat()

Here is a sample with with labels placed outside of arcs with rounded join

@Preview
@Composable
private fun PieChartWithLabels() {

    Box(
        modifier = Modifier
            .fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        val chartDataList = listOf(
            ChartData(Pink400, 10f),
            ChartData(Orange400, 20f),
            ChartData(Yellow400, 15f),
            ChartData(Green400, 5f),
            ChartData(Blue400, 50f),
        )

        Canvas(
            modifier = Modifier
                .fillMaxWidth(.7f)
                .aspectRatio(1f)
        ) {
            val width = size.width
            val radius = width / 2f
            val strokeWidth = 20.dp.toPx()

            val circumference = 2 * Math.PI * (radius - strokeWidth / 2)
            val strokeAngle = 1.1f * (strokeWidth / circumference * 180f).toFloat()

            var startAngle = -90f

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

                val chartData = chartDataList[index]
                val sweepAngle = chartData.data.asAngle
                val angleInRadians = (startAngle + sweepAngle / 2).degreeToAngle

                drawArc(
                    color = chartData.color,
                    startAngle = startAngle + strokeAngle,
                    sweepAngle = sweepAngle - strokeAngle * 2,
                    useCenter = false,
                    topLeft = Offset(strokeWidth / 2, strokeWidth / 2),
                    size = Size(width - strokeWidth, width - strokeWidth),
                    style = Stroke(strokeWidth, cap = StrokeCap.Round)
                )

                startAngle += sweepAngle


                val rectWidth = 20.dp.toPx()
                drawRect(
                    color = Color.Red,
                    size = Size(rectWidth, rectWidth),
                    topLeft = Offset(
                        -rectWidth / 2 + center.x + (radius + strokeWidth) * cos(
                            angleInRadians
                        ),
                        -rectWidth / 2 + center.y + (radius + strokeWidth) * sin(
                            angleInRadians
                        )
                    )
                )
            }
        }
    }
}

private val Float.degreeToAngle
    get() = (this * Math.PI / 180f).toFloat()


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

Edit

Math behind getting circumference is we need to get center of stroke width to change this circle into lines with rounded caps and correctly measuring the stroke radius which is half of stroke witdh.

enter image description here

As you can see in the image below we are getting radius where red lines are drawn with strokeWidth/2 width.

@Preview
@Composable
fun CanvasTest() {
    Canvas(
        modifier = Modifier
            .padding(50.dp)
            .fillMaxWidth()
            .aspectRatio(1f)
//            .border(1.dp, Color.Green)
    ) {

        val strokeWidth = 80f

        drawLine(
            color = Color.Blue,
            start = Offset(strokeWidth / 2, strokeWidth / 2),
            end = Offset(200f - strokeWidth / 2, strokeWidth / 2),
            strokeWidth = strokeWidth,
            cap = StrokeCap.Round
        )

        drawArc(
            color = Color.Red,
            useCenter = false,
            topLeft = Offset.Zero,
            size = Size(strokeWidth, strokeWidth),
            startAngle = 90f,
            sweepAngle = 180f,
            style = Stroke(2.dp.toPx())

        )

        drawLine(
            color = Color.Red,
            start = Offset(0f, strokeWidth / 2 + 1),
            end = Offset(strokeWidth / 2, strokeWidth / 2 + 1),
            strokeWidth = 2f
        )

        drawLine(
            color = Color.Red,
            start = Offset(200f - strokeWidth / 2, strokeWidth / 2 + 1),
            end = Offset(200f, strokeWidth / 2 + 1),
            strokeWidth = 2f
        )
    }
}

If there were no round cap we were drawing blue line as a whole. With rounded caps we offset as strokeAngle at startAngle to not overlap from start position and remove - 2*strokeAngle to reserve space for arc only the where only blue line is drawn.

Thracian
  • 43,021
  • 16
  • 133
  • 222
  • Is there a way to invert one of the ends of the arc ? like one end of the arc would curve out like ) and the other end would also curve in the same direction. So that the arc would look like )====) something like this? so instead of `( )` can we draw `) )` ?? – oop Aug 02 '23 at 18:05
  • Would you mind asking this as a question. This is pretty close to what OP asked, it requires drawing only 2 items inside a layer while applying blendMode to destination – Thracian Aug 02 '23 at 18:21
  • Yeah I thought of that first, but then got scared that people will flag me for asking similar question and refer me to this post :). But I will ask a separate question with an image for visual learners like me. – oop Aug 02 '23 at 18:23
  • 1
    They are similar but not same. You question requires 2 items to be drawn in a layer so that orange is drawn on top only where it intersects with pink arc – Thracian Aug 02 '23 at 18:26
  • https://stackoverflow.com/questions/76822626/how-do-draw-both-end-of-the-arc-curved-in-same-direction – oop Aug 02 '23 at 18:32
  • Your question is even more complex with spaces between each arc. If there were no space it would be solved pretty easily as i mentioned above. – Thracian Aug 02 '23 at 18:37
  • So in my case, I should have a pair of colors in separate layer is what you are suggesting? I know for the space, I may need to do some math and all. – oop Aug 02 '23 at 18:39
  • 1
    I don't think there is a simple workaround for that. It seems to me that you will need to draw it with Paths with quadTo and cubicTo – Thracian Aug 03 '23 at 04:31
  • Hi , is there a way to detect which one of the segments is clicked ? I want to redirect the user to a specific screen if any of the segments is clicked – avram andrei tiberiu Aug 31 '23 at 08:09
  • @Thracian could you help me please ? – avram andrei tiberiu Aug 31 '23 at 08:24
  • I explained it in [here](https://stackoverflow.com/a/75932354/5457853), you can check it out. You also need to take spaces as angles into consideration for this answer. – Thracian Aug 31 '23 at 08:27
  • You can also ask it as a separate question that i can look into when i'm available. But simply what you need to do is measure the distance from center to point of touch and check if its in bounds of inner and outer circle that contains arc. Then get angle via `atan2`and check which segment matches this angle. Also you will need to another check if you wan to when user touches space between segments. – Thracian Aug 31 '23 at 08:30
  • I managed to determine the distance, but when I try to calculate the angle ( given that my ring does not start at 0, but at 40 ) , it always gives me an angle between 250 and 270. ` var touchAngle = (-chartStartAngle + 180f + atan2( yPos, xPos ) * 180 / Math.PI) % 360f if (touchAngle < 0) { touchAngle += 360f } return touchAngle.toFloat() ` – avram andrei tiberiu Aug 31 '23 at 12:15