默认情况下,笔划的一半绘制在选定位置内,而另一半则绘制出。
@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)
)
}
}
}
通过更改绘制的矩形弧的左上角和大小,可以创建在单击时向外生长的弧,或者可以通过操作进行动画处理。在下图中,圆弧内部部分的半径不会改变,在下面的示例中,绿色矩形永远不会接触蓝色圆圈。
@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)
}
}
}
绘制圆环图时,我们需要有一个用红色圆圈表示的外半径、用蓝色圆圈表示的笔画宽度和内半径。我还使用内部笔划宽度为圆环图提供一些深度。
为了计算我们首先触摸图表或圆的哪个部分,我们需要通过测量从圆弧/圆的中心到触摸位置的距离来确定我们是否触摸圆弧内的部分,因为距离应该在内半径和外半径之间才能知道我们触摸了所需的区域。
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
如果触摸位置位于所需区域内,我们可以使用反正切函数获得角度,该函数给出以弧度为单位的角度。
https://en.wikipedia.org/wiki/Inverse_trigonometric_functions 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
}
获得中心和触摸位置之间的角度后,需要检查该角度位于哪个段。我将图像中的角度映射到数据作为开始和结束角度
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
)
}
}
}
}
映射是使用绘制坐标系中的起始角度顶部起始位置为 -90 度来完成的
// 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
)
}
}
还可以根据我使用的颜色加深颜色
val colorInner =
Color(
ColorUtils
.blendARGB(animatedColor.toArgb(), Color.Black.toArgb(), 0.1f)
)
并在未选定的颜色和选定的颜色之间设置动画颜色lerp
函数是在一种颜色之间进行动画处理的最方便的方法
val animatedColor = androidx.compose.ui.graphics.lerp(
color,
color.copy(alpha = .8f),
fraction
)
全面实施
@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)
}