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