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.
1 Answers
By default half of the stroke is drawn inside selected position while the other half of it being is drawn out.
@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.
@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)
}
}
}
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)
}
-
the part that is confusing is -chartStartAngle + 180. is thie becasue in android its polar co-ordinate and in computer graphics the y axis is inversed? – Raghunandan Apr 05 '23 at 04:56
-
adding 180 degrees is for transforming from -180 and 180 range of arctangent to 0-360 range. Then it starts as how coordinates of arc starts 0 being center right while top center 270 degrees. Then i subtract chartStartAngle to map different start positions. For instance you might want your chart drawing start from top center as in this question https://stackoverflow.com/questions/75903172/draw-legend-of-pie-chart-donut-chart-in-center-of-segment or from bottom center – Thracian Apr 05 '23 at 05:29
-
I also mapped from 10, 20, 30 values to angles starting from 0 degrees and ending at 360 degrees – Thracian Apr 05 '23 at 05:30
-
If you want to map from start angle instead of mapping from 0 degrees you don't need to subtract it in touch but do it in drawing part. It's a preference to offset chart angle to change start quadrant. – Thracian Apr 05 '23 at 07:06