Compose MultiplatformでCanvasを用いたグラフ機能の実装方法について

# はじめに
こんにちは。
株式会社アイスタイルで@cosmeアプリのAndroidエンジニアをしている鈴木と申します。

今回は、Compose Multiplatformを使ってCanvasを用いたグラフ機能の実装方法について書いていこうと思います。
(この記事の情報は、2024/12時点での情報です。)

# Compose Multiplatformとは
iOS、Android、デスクトップ、ウェブといった複数のプラットフォームにて、Jetpack Composeを利用することができる仕組みです。
https://www.jetbrains.com/ja-jp/lp/compose-multiplatform/

Jetpack Composeとは、AndroidでのUI開発を行える最新のツールキットのことを指します。詳細については[こちら](https://developer.android.com/jetpack/compose?hl=ja)をご覧ください。

つまり、複数のプラットフォームにて、Androidの最新のUI開発の仕組みでViewを共通化することが可能になりました。

# Canvasとは
今回利用するCanvasは[Canvas composable](https://developer.android.com/reference/kotlin/androidx/compose/ui/graphics/package-summary#Canvas(android.graphics.Canvas))のことを指します。
何かカスタムして描画する際にはいくつか方法がありますが、Canvas composableを利用することで自分が描画したい形に描くことができます。
今回は、Compose Multiplatformでレーダーチャートを描くところまでやっていきます。
レーダーチャートは、簡潔にまとめると正多角形上に表現したグラフのことです。

# Pathを用いたCanvasの使用方法
Pathとは、図形や線の軌跡を定義するために使用されます。Canvasにて複雑な形状を描画したい時に役立ちます。
描きたい図形が長方形であったり円であればすでに用意されているdrawRectやdrawCircleを利用すると良いですが、レーダーチャートのような複雑な図形を描くにはこのPathを用いるのが良いと思います。

簡単にPathの使い方を見ていきましょう。
今回使用する関数は3つになります。moveToとlineToとcloseです。
それぞれについて解説していきます。
まずはlineToについてです。サンプルコードは次のようになります。

```kotlin
Box(
  modifier = Modifier.fillMaxSize(),
  contentAlignment = Alignment.Center
) {
    Canvas(
      modifier = Modifier.fillMaxSize()
    ) {
      val canvasWidth = size.width
      val canvasHeight = size.height
      val path = Path().apply {
        lineTo(canvasWidth, canvasHeight)
      }
      drawPath(
        path = path,
        color = Color.Black,
        style = Stroke(width = 4f)
      )
  }
}
```

このように記載した場合、始点は(0,0)から始まるので端末の左上からになり、端末のwidthとheightを指定しているので右下まで直線に描かれます。

Android|iOS
------|------
<img width=350 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3615372/92711e93-8260-9dce-2ee5-58e3d5c8eecc.png" />|<img width=350 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3615372/3466d638-6793-db1c-3864-b9705cc243ee.png" />

次に、moveToについてです。サンプルコードは次のようになります。

```kotlin
Box(
  modifier = Modifier.fillMaxSize(),
  contentAlignment = Alignment.Center
) {
    Canvas(
      modifier = Modifier.fillMaxSize()
    ) {
      val canvasWidth = size.width
      val canvasHeight = size.height
      val path = Path().apply {
        moveTo(0f, canvasHeight / 2) // canvasHeight / 2は端末の半分のサイズ
        lineTo(canvasWidth, canvasHeight)
      }
      drawPath(
        path = path,
        color = Color.Black,
        style = Stroke(width = 4f)
      )
  }
}

```

このように記載した場合、始点は(0,0)からmoveToによって(0, canvasHeight / 2)になり、lineToで端末のwidthとheightを指定しているので半分から右下まで直線が描かれる形になります。

Android|iOS
-------|------
<img width=350 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3615372/a3f45c8b-c12e-aabc-1ee2-be0af9a5283b.png" />|<img width=350 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3615372/c23a63e1-f072-71a6-fc3c-fa9796868f26.png" />


次に、closeについてです。サンプルコードは次のようになります。

```kotlin
Box(
  modifier = Modifier.fillMaxSize(),
  contentAlignment = Alignment.Center
) {
    Canvas(
      modifier = Modifier.fillMaxSize()
    ) {
      val canvasWidth = size.width
      val canvasHeight = size.height
      val path = Path().apply {
        moveTo(0f, canvasHeight / 2)
        lineTo(canvasWidth / 2, canvasHeight)
        lineTo(canvasWidth / 2, canvasHeight / 2)
        close()
      }
      drawPath(
        path = path,
        color = Color.Black,
        style = Stroke(width = 4f)
      )
  }
}
```
このように記載した場合、始点は(0,0)からmoveToによって(0, canvasHeight / 2)になり、lineToで端末のwidthの半分とheightの半分を指定しているので半分から右に端末の半分まで直線が描かれ、さらにlineToで端末のwidthの半分と端末のheightの半分のサイズを指定しているので上に直線が描かれます。最後にcloseを呼ぶことで終点と始点が結ばれます。
一連の流れから三角形を描くことができます。

Android|iOS
-------|------
<img width=350 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3615372/9d08a66d-aeed-57eb-1de7-4677290d7950.png" />|<img width=350 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3615372/11cd9080-9627-a011-f3b9-a5ddc10c4ab7.png" />

moveTo、lineTo、closeの役割をまとめると次のようになります。

|関数名|役割|
|------|------|
|moveTo|描画開始位置を変更する|
|lineTo|現在の位置から指定した点まで直線を描画する|
|close|パスの始点と終点を自動的に結び、閉じた形状を作る|

これらを用いて、さらに五角形のレーダーチャートを作成していきます。

# 正五角形のレーダーチャートを作成

## 正五角形の図形を作成する

正五角形を描くためには、先に確認したmoveTo、lineTo、closeを組み合わせて実現します。

正多角形を描く上で必要なのは、単位円の考え方です。
単位円とは、原点を中心とする半径1の円のことです。
単位円上で、(1,0)から反時計回りにθ回転した点をP(x,y)とした時、点Pの座標はx=cosθ, y=sinθとなります。

|単位円|
|------|
|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3615372/657bf746-e617-8b35-ad6b-ed1ecbe0cff4.png" />|


半径1の場合に、円周上の座標点Pは(1 * cosθ, 1 * sinθ)であるので、半径rとした場合に(r * cosθ, r * sinθ)となります。
また、2π=360°であるので、正n角形を描く場合の内角は2π/nになります。
正n角形の円周上の座標を求めるにあたってθ=2π/nとなります。今回は正五角形を描くので、n=5になります。
それらを踏まえてコードにすると次の通りです。

```kotlin
Box(
  modifier = Modifier.fillMaxSize(),
  contentAlignment = Alignment.Center
) {
  Canvas(
    modifier = Modifier.fillMaxSize()
  ) {
      val radius = 200f
      val pi = 3.14159  // Math.PIを使えなかったため、決めうちで置いています
      val path = Path().apply {
        for (i in 0 until 5) {
          val angle = (2 * pi / 5) * i
          if (i == 0) {
            moveTo(
              center.x + radius * cos(angle).toFloat(),
              center.y + radius * sin(angle).toFloat()
            )
          } else {
            val x = center.x + radius * cos(angle)
            val y = center.y + radius * sin(angle)
            lineTo(x.toFloat(), y.toFloat())
        }
      }
      close()
    }
    drawPath(
      path = path,
      color = Color.Black,
      style = Stroke(width = 4f)
    )
  }
}
```

円周上の五角形の座標にmoveToで移動して、lineToで直線を次の点にかけて描き、最後にcloseで始点と終点を結ぶ形になります。
実行すると次のようになります。

Android|iOS
--------|------
<img width=350 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3615372/664d7e9b-df28-04a3-6a13-873dc01d4b38.png" />|<img width=350 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3615372/267001b9-bac7-06ff-9d21-031fc0b6f1e4.png" />

この状態ですと若干傾いた状態になりますので、angleの値を90°調整します。
val angle = (2 * pi / 5) * iから、-(pi / 2)を行い、val angle = (2 * pi / 5) * i - (pi / 2)とします。
実行すると次のようになります。

Android|iOS
--------|------
<img width=350 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3615372/2305f388-aaad-e4dc-6fe5-fcc5881e5b4c.png" />|<img width=350 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3615372/9bb06c38-f587-ca92-3402-dc8e01b95eb5.png" />

## アニメーションを付与する
次に、レーダーチャートを実装するにあたってアニメーションの付与を行いましょう。  

今回は実験的に、先に作成した五角形に対してアニメーションをつけていきます。  

アニメーションを実装するには、Animatable関数を用います。Animatable関数は、animateToによって値が変更された時に自動的に値を更新するfloat値を持つホルダーを作成します。  

animateTo関数では、AnimationStateから値と速度を取得して、targetValueまでアニメーション化してくれます。アニメーション中は指定された速度、フレーム時間などで更新されていきます。  
今回は中心から円周上の座標にかけて広がるアニメーションを作成します。

次のようなコードになります。

```kotlin
val animationProgress = remember { Animatable(0f) }
LaunchedEffect(Unit) {
  animationProgress.animateTo(
    targetValue = 1f,
    animationSpec = tween(durationMillis = 5000, easing = LinearOutSlowInEasing)
  )
}
Box(
  modifier = Modifier.fillMaxSize(),
  contentAlignment = Alignment.Center
) {
  Canvas(
    modifier = Modifier.fillMaxSize()
  ) {
      val radius = 200f
      val pi = 3.14159
      val valueRadius = radius * animationProgress.value
      val path = Path().apply {
        for (i in 0 until 5) {
          val angle = (2 * pi / 5) * i - (pi / 2)
          if (i == 0) {
            moveTo(
              center.x + valueRadius * cos(angle).toFloat(),
              center.y + valueRadius * sin(angle).toFloat()
            )
          } else {
            val x = center.x + valueRadius * cos(angle)
            val y = center.y + valueRadius * sin(angle)
            lineTo(x.toFloat(), y.toFloat())
        }
      }
      close()
    }
    drawPath(
      path = path,
      color = Color.Black,
      style = Stroke(width = 4f)
    )
  }
}
```
animationSpecには、どういったアニメーションの仕方を定義したいかを決めることができます。加速もしくは減速具合を調整したりできます。  

初回描画時に定義していたアニメーションを起動するようにし、アニメーションの変化に応じてx,y座標の位置が変わるようにしています。
これを実行すると次のようになります。

Android|iOS
------|------
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3615372/b429ad49-f046-7f1d-914d-01e4e5794198.gif" />|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3615372/68d02dfd-fb0a-90ca-8da5-9cd52364e649.gif" />

このようにして、アニメーションの描画もできるようになります。

## Boxで重ねてメモリ部分とグラフ部分を描画する

今までのことを踏まえて、さらにBox composableを用いると複数のViewを重ねて描画することができます。
つまりチャートを作成したい場合は、背景にメモリとなるViewを描画し、その上に実線や点線といったグラフを描画します。さらにアニメーションを加えたい場合はAnimatable関数を用いることで、容易にアニメーションの動きも合わせたレーダーチャートを作成することができます。

実際に作成してみたコードが次の通りです。

```kotlin
@Composable
internal fun RadarChart(
    chartDataList: List<ChartData>,
    maxValue: Float,
    radius: Float = 200f,
    radarColor: Color = Color.Black,
    fillColor: Color = Color.DarkGray,
    gridColor: Color = Color.Gray,
    memoryColor: Color = Color.White,
    memoryStep: Int = 9,
    averageLineColor: Color = Color(0xFF233838),
    dashArray: FloatArray = floatArrayOf(3f, 3f),
) {
    val numberOfAxes = chartDataList.size
    val animationProgress = remember { Animatable(0f) }
    LaunchedEffect(Unit) {
        animationProgress.animateTo(
            targetValue = 1f,
            animationSpec = tween(durationMillis = 5000, easing = LinearOutSlowInEasing)
        )
    }
    var screenCenter by remember { mutableStateOf(0f to 0f) }
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        val pi = 3.14159
        Canvas(
            modifier = Modifier.fillMaxSize()
        ) {
            screenCenter = center.x to center.y
            val memoryLength = 2.dp.toPx()
            for (i in 1..memoryStep) {
                val stepRadius = radius * (i / memoryStep.toFloat())
                val path = Path()
                for (j in 0 until numberOfAxes) {
                    val angle = ((2 * pi / numberOfAxes) * j) - (pi / 2)
                    val x = center.x + stepRadius * cos(angle).toFloat()
                    val y = center.y + stepRadius * sin(angle).toFloat()

                    if (j == 0) {
                        path.moveTo(x, y)
                    } else {
                        path.lineTo(x, y)
                    }
                }
                path.close()
                drawPath(
                    path = path,
                    color = gridColor
                )
            }
            for (i in 0 until numberOfAxes) {
                val angle = (2 * pi / numberOfAxes) * i - (pi / 2)
                val xEnd = center.x + radius * cos(angle).toFloat()
                val yEnd = center.y + radius * sin(angle).toFloat()
                drawLine(
                    color = memoryColor,
                    start = center,
                    end = Offset(xEnd, yEnd),
                    strokeWidth = 2f
                )
                for (j in 1 until memoryStep) {
                    val stepRadius = radius * (j / memoryStep.toFloat())
                    val xMemory = center.x + stepRadius * cos(angle).toFloat()
                    val yMemory = center.y + stepRadius * sin(angle).toFloat()
                    val memoryStart = Offset(
                        xMemory - memoryLength / 2 * sin(angle).toFloat(),
                        yMemory + memoryLength / 2 * cos(angle).toFloat()
                    )
                    val memoryEnd = Offset(
                        xMemory + memoryLength / 2 * sin(angle).toFloat(),
                        yMemory - memoryLength / 2 * cos(angle).toFloat()
                    )

                    drawLine(
                        color = memoryColor,
                        start = memoryStart,
                        end = memoryEnd,
                        strokeWidth = 2f
                    )
                }
            }
            val path = Path()
            for (i in chartDataList.indices) {
                val angle = (2 * pi / numberOfAxes) * i - (pi / 2)
                val valueRadius =
                    radius * (chartDataList[i].data / maxValue) * animationProgress.value
                val x = center.x + valueRadius * cos(angle).toFloat()
                val y = center.y + valueRadius * sin(angle).toFloat()

                if (i == 0) {
                    path.moveTo(x, y)
                } else {
                    path.lineTo(x, y)
                }
            }
            path.close()
            drawPath(
                path = path,
                color = fillColor
            )
            drawPath(
                path = path,
                color = radarColor,
                style = Stroke(width = 1.5f)
            )
            val averagePath = Path()
            for (i in chartDataList.indices) {
                val angle = (2 * pi / numberOfAxes) * i - (pi / 2)
                val valueRadius = radius * (chartDataList[i].averageData / maxValue)
                val x = center.x + valueRadius * cos(angle).toFloat()
                val y = center.y + valueRadius * sin(angle).toFloat()

                if (i == 0) {
                    averagePath.moveTo(x, y)
                } else {
                    averagePath.lineTo(x, y)
                }
            }
            averagePath.close()
            drawPath(
                path = averagePath,
                color = averageLineColor,
                style = Stroke(
                    width = 1.5f,
                    pathEffect = PathEffect.dashPathEffect(dashArray)
                )
            )
        }

        // 頂点の先のラベルを設置する
        for (i in chartDataList.indices) {
            val angle = (2 * pi / numberOfAxes) * i - (pi / 2)
            // 0.49は決め打ちで半径を元に比を設定
            val valueRadius = radius * 0.49
            val x = valueRadius * cos(angle).toFloat()
            val y = valueRadius * sin(angle).toFloat()
            Text(
                text = chartDataList[i].label,
                style = TextStyle(
                    fontSize = chartDataList[i].fontSize.sp,
                ),
                color = chartDataList[i].textColor,
                fontWeight = chartDataList[i].fontWeight,
                modifier = Modifier.offset(
                    x = x.dp,
                    y = y.dp
                )
            )
        }
    }
}
```
これを実行すると次のようになります。

Android|iOS
------|------
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3615372/05b9011e-952d-d519-84d7-4b45190e5796.gif" />|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3615372/efcc9cf4-7584-a4ab-b9e6-4c03a5f00418.gif" />

今までの考え方を踏まえてメモリ部分を描画し、その上に平均値のチャートを描き、実数値のチャートを上に重ねてアニメーションを行うようにしました。また、頂点の先にテキストをさらに重ねて表示することで何の値を示しているのかをわかるようにしました。


# おわりに
Canvas composableを利用することで、マルチプラットフォーム環境でも複雑な図形を描画することができるため、色々といろんな形を試行錯誤してみるとまた楽しいかもしれません。
少し複雑な形を実装してみたいと思う方に少しでも参考になれば幸いです。


# 参考

実装にあたって下記リンクを参考にさせていただきました。
https://developer.android.com/develop/ui/compose/graphics/draw/overview