4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

アイスタイルAdvent Calendar 2024

Day 15

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

Last updated at Posted at 2024-12-14

はじめに

こんにちは。
株式会社アイスタイルで@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開発を行える最新のツールキットのことを指します。詳細についてはこちらをご覧ください。

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

Canvasとは

今回利用するCanvasはCanvas composableのことを指します。
何かカスタムして描画する際にはいくつか方法がありますが、Canvas composableを利用することで自分が描画したい形に描くことができます。
今回は、Compose Multiplatformでレーダーチャートを描くところまでやっていきます。
レーダーチャートは、簡潔にまとめると正多角形上に表現したグラフのことです。

Pathを用いたCanvasの使用方法

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

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

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

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

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

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

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

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

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

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

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

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

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

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

単位円

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

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

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

Android iOS

アニメーションを付与する

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

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

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

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

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

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

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

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

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

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

@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

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

おわりに

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

参考

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

4
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?