Jetpack Compose로 구현해본 Radar Chart(방사형 차트) 입니다.
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.DrawStyle
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.toSize
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.launch
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.sin
/**
* Created by Junjae Bae on 2024. 7. 4.
*/
val Grey = Color(0xFF666666)
@Composable
fun RadarChart(
labels: ImmutableList<String>,
values: ImmutableList<ImmutableList<Float>>,
colors: ImmutableList<Color>,
drawStyles: ImmutableList<DrawStyle>,
modifier: Modifier = Modifier,
padding: Dp = 10.dp,
lineCount: Int = 4,
lineColor: Color = Color.LightGray,
durationMillis: Int = 700,
labelStyle: TextStyle = TextStyle.Default,
) {
val itemSize = values.first().size
check(values.all { it.size == itemSize } && colors.isNotEmpty() && drawStyles.isNotEmpty())
val animationValues = remember(values.size, itemSize) {
buildList {
repeat(values.size) {
add(buildList {
repeat(itemSize) {
add(Animatable(0f))
}
})
}
}
}
LaunchedEffect(values) {
values.forEachIndexed { i, value ->
value.forEachIndexed { j, fl ->
launch {
animationValues[i][j].animateTo(fl, animationSpec = tween(durationMillis = durationMillis))
}
}
}
}
val radianUnit = remember(itemSize) {
2 * Math.PI / itemSize
}
val chartPath = remember {
Path()
}
val dataPath = remember {
Path()
}
val textMeasurer = rememberTextMeasurer()
Canvas(modifier = modifier) {
// Draw Chart Path
chartPath.reset()
val measuredTexts = labels.map { textMeasurer.measure(AnnotatedString(it), style = labelStyle) }
val maxRadius = (0 until itemSize).mapIndexed { index, i ->
size.maxLengthFromCenter(i * radianUnit + PI / 2) - padding.toPx() - measuredTexts[index].size.toSize()
.maxLengthFromCenter(i * radianUnit + PI / 2) * 2
}.min()
val gap = maxRadius / lineCount
repeat(lineCount) { step ->
val radius = (step + 1) * gap
(0..itemSize).forEach {
val x = radius * cos(it * radianUnit + PI / 2)
val y = radius * sin(it * radianUnit + PI / 2)
if (it == 0) chartPath.moveTo(x.toFloat(), y.toFloat())
else chartPath.lineTo(x.toFloat(), y.toFloat())
}
}
(0 until itemSize / 2).forEach {
val x1 = maxRadius * cos(it * radianUnit + PI / 2)
val y1 = maxRadius * sin(it * radianUnit + PI / 2)
val x2 = maxRadius * cos(it * radianUnit + PI * 3 / 2)
val y2 = maxRadius * sin(it * radianUnit + PI * 3 / 2)
chartPath.moveTo(x1.toFloat(), y1.toFloat())
chartPath.lineTo(x2.toFloat(), y2.toFloat())
}
translate(left = center.x, top = center.y) {
drawPath(path = chartPath, color = lineColor, style = Stroke(width = 1.dp.toPx()))
}
// Generate Data Path
animationValues.forEachIndexed { i, value ->
dataPath.reset()
value.forEachIndexed { j, animatable ->
val radius = maxRadius * animatable.value
val x = radius * -cos(j * radianUnit + PI / 2)
val y = radius * -sin(j * radianUnit + PI / 2)
if (j == 0) dataPath.moveTo(x.toFloat(), y.toFloat())
else dataPath.lineTo(x.toFloat(), y.toFloat())
}
run {
val radius = maxRadius * value.first().value
val x = radius * -cos(PI / 2)
val y = radius * -sin(PI / 2)
dataPath.lineTo(x.toFloat(), y.toFloat())
}
translate(left = center.x, top = center.y) {
val color = colors[i % colors.size]
val pathEffect = drawStyles[i % drawStyles.size]
drawPath(path = dataPath, color = color, style = Fill)
drawPath(path = dataPath, color = color.copy(alpha = 1f), style = pathEffect)
}
}
// Draw
translate(left = center.x, top = center.y) {
measuredTexts.forEachIndexed { index, label ->
val radian = index * radianUnit + PI / 2
val radius =
size.maxLengthFromCenter(radian) - label.size.toSize().maxLengthFromCenter(radian)
val x = radius * -cos(radian) - label.size.width / 2
val y = radius * -sin(radian) - label.size.height / 2
drawText(textLayoutResult = label, topLeft = Offset(x.toFloat(), y.toFloat()), color = Grey)
}
}
}
}
private fun Size.maxLengthFromCenter(radian: Double): Double {
val width: Double = (1.0 / cos(radian)).let {
if (it.isFinite()) {
it * width / 2.0
} else {
width / 2.0
}
}
val height: Double = (1.0 / sin(radian)).let {
if (it.isFinite()) {
it * height / 2.0
} else {
height / 2.0
}
}
return minOf(abs(width), abs(height))
}
@Preview
@Composable
private fun RadarChartPreview() {
RadarTheme {
val labels = persistentListOf("가", "나", "다", "라", "마", "바")
val colors = persistentListOf(Color.Red.copy(alpha = 0.2f), MaterialTheme.colorScheme.primary.copy(alpha = 0.3f))
val pathEffects = with(LocalDensity.current) {
remember(this) {
listOf(
Stroke(width = 1.dp.toPx(), pathEffect = PathEffect.dashPathEffect(floatArrayOf(1.dp.toPx(), 1.dp.toPx()))),
Stroke(width = 1.dp.toPx()),
).toImmutableList()
}
}
val data = remember {
buildList {
repeat(2) {
add(buildList {
repeat(6) {
add(Math.random().toFloat() * 0.3f + 0.7f)
}
}.toImmutableList())
}
}.toImmutableList()
}
RadarChart(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f),
values = data,
labels = labels,
colors = colors,
drawStyles = pathEffects,
)
}
}
'Android' 카테고리의 다른 글
[Android] Compose Scrollable Column에서 처음 보여지는 타이밍 캐치하기 (0) | 2024.07.05 |
---|---|
[Android] Fragment.rootFragment extension property (1) | 2023.11.20 |
[Android] Compose Flow.collectAsState() 파악하기 (0) | 2023.05.14 |
[개발 정보] 코틀린 코루틴 레시피(활용법) (0) | 2023.04.24 |
[Android] Gson을 대체하는 Moshi (0) | 2023.03.10 |