[Android] 016. グラフ(Canvas)

急に要件が出てきたら困るかもなので、グラフの表示試してみます

Vicoなどサードベンダーのライブラリを使うのもありだけど、保守性を考えるとあまり依存したくないのでCanvasに描画&アニメーションで独自に作ってみました
https://developer.android.com/develop/ui/compose/graphics/draw/overview
https://developer.android.com/develop/ui/compose/animation/quick-guide

折れ線グラフ

ただの直線じゃ面白くないので、よくある?滑らかな曲線で作ってみようと思います
こちらが参考になりました
https://medium.com/@kezhang404/either-compose-is-elegant-or-if-you-want-to-draw-something-with-an-android-view-you-have-to-7ce00dc7cc1

// ↑の実装を流用
fun buildCurveLine(path: Path, startPoint: Offset, endPoint: Offset) {
    val firstControlPoint = Offset(
        x = startPoint.x + (endPoint.x - startPoint.x) / 2F,
        y = startPoint.y,
    )
    val secondControlPoint = Offset(
        x = startPoint.x + (endPoint.x - startPoint.x) / 2F,
        y = endPoint.y,
    )
    path.cubicTo(
        x1 = firstControlPoint.x,
        y1 = firstControlPoint.y,
        x2 = secondControlPoint.x,
        y2 = secondControlPoint.y,
        x3 = endPoint.x,
        y3 = endPoint.y,
    )
}
data class Item (
    val title: String,
    val amount: Float
)

@Composable
fun LineChart(chart: List<Item>, modifier: Modifier = Modifier) {
    val max = remember {
        chart.map { it.amount }.max()
    }

    Canvas(modifier) {
        val moveX = this.size.width / chart.size
        val drawH = this.size.height

        var current = Offset(0.0f, 0.0f)
        val path = Path().apply {
            chart.forEachIndexed { i, it ->
                val x = moveX * i
                val y = drawH - drawH * (it.amount / max)

                if (i == 0) moveTo(x, y)
                else buildCurveLine(this, current, Offset(x, y))
                current = Offset(x, y)
            }
        }

        repeat((max / 500).toInt()) {
            if (it != 0) {
                drawLine(
                    Color.Gray,
                    start = Offset(0.0f, drawH - drawH * (it * 500 / max)),
                    end = Offset(this.size.width, drawH - drawH * (it * 500 / max))
                )
            }
        }

        drawPath(
            path,
            Color.Black,
            1.0f,
            style = Stroke(width = 4.0f)
        )

        path.lineTo(current.x, drawH)
        path.lineTo(0.0f, drawH)
        path.close()

        drawPath(
            path,
            Brush.verticalGradient(listOf(Color(1.0f, 0.2f, 0.2f, 1.0f), Color(1.0f, 0.4f, 0.4f, 1.0f), Color(1.0f, 0.95f, 0.95f, 1.0f))),
            0.75f
        )
    }
}

@Composable
fun Screen(modifier: Modifier = Modifier) {
    val chart = remember {
        listOf(
            Item("2010", 1000f),
            Item("2015", 700f),
            Item("2020", 0f),
            Item("2025", 100f),
            Item("2030", 500f),
            Item("2030", 1500f),
            Item("2030", 1200f),
            Item("2030", 900f),
        )
    }

    Column(
        modifier
            .fillMaxWidth()
            .padding(top = 44.dp), horizontalAlignment = Alignment.CenterHorizontally) {
        LineChart(chart, Modifier.fillMaxSize().padding(44.dp))
    }
}

ドーナツグラフ

表示にアニメーションするドーナツグラフを作ってみました

@Composable
fun DonutChart(
    chart: List<Item>,
    startColor: Color = Color(0, 79, 70),
    endColor: Color = Color(178, 255, 255)
) {
    val sum = remember {
        chart.map { it.amount }.sum()
    }
    val color = remember {
        var i = 0
        chart.map {
            val r = startColor.red - ((startColor.red - endColor.red) / chart.size * i)
            val g = startColor.green - ((startColor.green - endColor.green) / chart.size * i)
            val b = startColor.blue - ((startColor.blue - endColor.blue) / chart.size * i)
            i++
            Color(r, g, b, 1.0f)
        }
    }

    val startAngle = remember {
        var angle = 270f
        chart.map {
            val value = angle
            angle += 360f * (it.amount / sum)
            if (angle > 360f) angle -= 360f
            value
        }
    }
    val animationAngle = remember {
        chart.map { Animatable(0f) }
    }

    LaunchedEffect(Unit) {
        animationAngle.forEachIndexed { i, it ->
            it.animateTo(360f * (chart[i].amount / sum) - 1f, tween(1000 / chart.size, easing = LinearEasing))
        }
    }

    Column {
        Canvas(Modifier.size(200.dp, 200.dp)) {
            chart.forEachIndexed { i, value ->
                drawArc(
                    color = color[i],
                    startAngle = startAngle[i],
                    sweepAngle = animationAngle[i].value,
                    useCenter = false,
                    style = Stroke(width = 64f),
                )
            }
        }
        Spacer(Modifier.height(44.dp))

        Column(Modifier.width(200.dp)) {
            Text("資産合計 ${sum.toInt()} 万円", style = MaterialTheme.typography.labelSmall)
            HorizontalDivider()
            chart.forEachIndexed { i, it ->
                Row(
                    Modifier.fillMaxWidth(),
                    horizontalArrangement = Arrangement.spacedBy(4.dp),
                    verticalAlignment = Alignment.CenterVertically
                ) {
                    Box(Modifier.size(8.dp).background(color[i]))
                    Row(
                        Modifier.fillMaxWidth(),
                        horizontalArrangement = Arrangement.SpaceBetween
                    ) {
                        Text(it.title, style = MaterialTheme.typography.labelSmall)
                        Text("${it.amount.toInt()} 万円", style = MaterialTheme.typography.labelSmall)
                    }
                }
            }
        }
    }
}

@Composable
fun Screen(modifier: Modifier = Modifier) {
    val chart = remember {
        listOf(
            Item("現金", 1000f),
            Item("株式", 500f),
            Item("債権", 0f),
            Item("保険", 50f),
            Item("不動産", 2500f),
        )
        .filter { it.amount != 0f }
        .sortedByDescending { it.amount }
    }

    Column(modifier.fillMaxWidth().padding(top = 44.dp), horizontalAlignment = Alignment.CenterHorizontally) {
        DonutChart(chart)
    }
}

Android Studio Koala 2024.1.1 Patch 1 built on July 11, 2024