본문 바로가기

Android

[Android] Jetpack Compose Radar Chart

Jetpack Compose로 구현해본 Radar Chart(방사형 차트) 입니다.

Preview

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