JetpackComposeでお絵描きアプリ作ってみた

## はじめに
こんにちは！北九州高専4年のことりんです！
この記事は[「学生iOS&Androidエンジニア Advent Calendar 2023」](https://qiita.com/advent-calendar/2023/android_ios_students)12日目の記事です.
今回は，JetpackComposeでお絵描きアプリを作成した時の個人的に難しかったところを書いていきます．

## Canvasとは
Canvasは自由に直線や図形を描きたい時やアニメーションの作成などに用います．
JetpackComposeのCanvasは以下のように示されています．本記事ではこのDrawScopeに対して描画をしていきます．

```Canvas
@Composable
fun Canvas(modifier: Modifier, onDraw: DrawScope.() -> Unit) =
    Spacer(modifier.drawBehind(onDraw))
```

## 今回作成したアプリ
ペンのサイズと色の変更ができるお絵描きアプリです．
<div align="center">
  <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1273704/b046c211-09cf-5e5a-0a73-1f11169e2775.gif" width="300" />
</div>

## 作成手順
### 線を描けるようにする
本記事では「DroidKaigi2022 Jetpack Composeを用いて、Canvasを直接触るようなコンポーネントを作成する方法」の資料を参考にしてプログラムを記述しています．

https://speakerdeck.com/kkkkan/droidkaigi2022-jetpack-composewoyong-ite-canvaswozhi-jie-hong-ruyounakonponentowozuo-cheng-surufang-fa

以下のプログラムはユーザーがなぞった経路を取得して表示するプログラムです．キャンバス上に指を置いたときと経路を書いているときを検知して，そのパスを保存，表示しています．

```DrawingScreen.kt
@Composable
fun DrawingScreen() {
    // 描画の履歴の記録のため
    val tracks = rememberSaveable{ mutableStateOf<List<DrawingPathRoute>?>(null) }
    DrawingCanvas(tracks = tracks)
}

// 描画の記録するためにpathを表現する
sealed class DrawingPathRoute: Serializable {
    abstract val x: Float
    abstract val y: Float

    data class MoveTo(override val x: Float, override val y: Float): DrawingPathRoute()
    data class LineTo(override val x: Float = 0f, override val y: Float = 0f): DrawingPathRoute()
}

```


```component/Canvas.kt
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun DrawingCanvas(tracks: MutableState<List<DrawingPathRoute>?>) {
    Canvas(
        modifier = Modifier
            .fillMaxSize()
            .pointerInteropFilter { motionEvent: MotionEvent ->
                when (motionEvent.action) {
                    // 描き始めの処理
                    MotionEvent.ACTION_DOWN -> {
                        tracks.value = ArrayList<DrawingPathRoute>().apply {
                            tracks.value?.let { addAll(it) }
                            add(DrawingPathRoute.MoveTo(motionEvent.x, motionEvent.y))
                        }
                    }
                    // 書いてる途中の処理
                    MotionEvent.ACTION_MOVE -> {
                        tracks.value = ArrayList<DrawingPathRoute>().apply {
                            tracks.value?.let { addAll(it) }
                            add(DrawingPathRoute.LineTo(motionEvent.x, motionEvent.y))
                        }
                    }
                    else -> false
                }
                true
            }) {
        val paths = ArrayList<Path>()
        tracks?.let {
            var path = Path()
            tracks.value?.forEach { drawingPathRoute ->
                when (drawingPathRoute) {
                    is DrawingPathRoute.MoveTo -> {
                        paths.add(path)
                        path = Path()
                        path.moveTo(drawingPathRoute.x, drawingPathRoute.y)
                    }
                    is DrawingPathRoute.LineTo -> {
                        path.lineTo(drawingPathRoute.x, drawingPathRoute.y)
                    }
                }
            }
            paths.add(path)
        }
        val penSize = 10f
        inset(horizontal = penSize, vertical = penSize) {
            paths.forEach {
                drawPath(
                    path = it,
                    color = Color.Black,
                    style = Stroke(width = penSize),
                    blendMode = BlendMode.SrcOver
                )
            }
        }
    }
}
```

これで画面上にお絵描きをすることが可能になりました．

<div align="center">
  <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1273704/856b705c-c72e-f601-7573-b1a1e354c650.gif" width="300" />
</div>


### 絵の削除とペンのサイズ・色を選択できるようにする
次にペンサイズ・色を選択できるようなボタンと絵の削除ボタンを作成します．今回はBottomAppBarを作成してその中にこれらの機能を入れるようにしました．
DrawingScreen.ktを次のように書き換えます．スライダーでpenSizeの値を変更し，その値をDrawingCanvasに渡すことにより，DrawingCanvas内でのペンのサイズ変換ができます．色の変更は，[colorpicker-compose](https://github.com/skydoves/colorpicker-compose)を用いて作成しています．

```DrawingScreen.kt
@Composable
fun DrawingScreen() {
    // 描画の履歴の記録のため
    val tracks = rememberSaveable { mutableStateOf<List<CustomDrawingPath>?>(null) }
    var penSize by remember { mutableFloatStateOf(4f) }
    var penColor by remember { mutableStateOf(Color.Black) }
    var showPenSizeSlider by remember { mutableStateOf(false) }
    var showColorPicker by remember { mutableStateOf(false) }

    Scaffold(
        bottomBar = {
            Column {
                if(showColorPicker) {
                    ColorPicker(penColor = penColor) { color -> penColor = color }
                }
                if(showPenSizeSlider){
                    Row {
                        Slider(
                            value = penSize,
                            valueRange = 0f..100f,
                            onValueChange = {
                                penSize = it
                            },
                            modifier = Modifier.fillMaxWidth(0.8f)
                        )
                        Text(text = String.format("%.2f", penSize), modifier = Modifier.align(CenterVertically))
                    }
                }
                BottomAppBar(
                    actions = {
                        IconButton(onClick = { tracks.value = null }) {
                            Icon(Icons.Filled.Delete, contentDescription = "削除")
                        }
                        IconButton(onClick = { showPenSizeSlider = !showPenSizeSlider }) {
                            Icon(
                                Icons.Filled.Edit,
                                contentDescription = "ペンサイズ変更",
                            )
                        }
                        IconButton(onClick = { showColorPicker = !showColorPicker}) {
                            Icon(
                                Icons.Filled.MoreVert,
                                contentDescription = "色の変更",
                            )
                        }
                        IconButton(onClick = { /* do something */  }) {
                            Icon(
                                Icons.Filled.Add,
                                contentDescription = "ペンの変更",
                            )
                        }
                    },
                    floatingActionButton = {
                        FloatingActionButton(
                            onClick = {  },
                            containerColor = BottomAppBarDefaults.bottomAppBarFabColor,
                            elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation()
                        ) {
                            Icon(Icons.Filled.ExitToApp, "保存")
                        }
                    }
                )
            }
            }
    ) {
        DrawingCanvas(tracks = tracks, penSize = penSize, penColor = penColor, canvasHeight = it)
    }
}

```

```DrawingCanvas.kt
@Suppress("UNUSED_EXPRESSION")
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun DrawingCanvas(tracks: MutableState<List<CustomDrawingPath>?>, penSize: Float, penColor: Color, canvasHeight: PaddingValues) {
    Canvas(
        modifier = Modifier
            .fillMaxSize()
            .pointerInteropFilter { motionEvent: MotionEvent ->
                when (motionEvent.action) {
                    // 描き始めの処理
                    MotionEvent.ACTION_DOWN -> {
                        tracks.value = ArrayList<CustomDrawingPath>().apply {
                            tracks.value?.let { addAll(it) }
                            add(CustomDrawingPath(
                                path = DrawingPathRoute.MoveTo(motionEvent.x, motionEvent.y),
                                color = penColor,
                                size = penSize
                            ))
                        }
                    }
                    // 書いてる途中の処理
                    MotionEvent.ACTION_MOVE -> {
                        tracks.value = ArrayList<CustomDrawingPath>().apply {
                            tracks.value?.let { addAll(it) }
                            add(CustomDrawingPath(
                                path = DrawingPathRoute.LineTo(motionEvent.x, motionEvent.y),
                                color = penColor,
                                size = penSize
                            ))
                        }
                    }
                    else -> false
                }
                true
            }) {
            
        var currentPath = Path()
        var currentSize = penSize

        tracks.let {
            tracks.value?.forEach { customDrawingPath ->
                when (customDrawingPath.path) {
                    is DrawingPathRoute.MoveTo -> {
                        currentPath = Path().apply { moveTo(customDrawingPath.path.x, customDrawingPath.path.y) }
                        currentSize = customDrawingPath.size
                        customDrawingPath.color = penColor
                    }
                    is DrawingPathRoute.LineTo -> {
                        drawPath(
                            path = currentPath.apply { lineTo(customDrawingPath.path.x, customDrawingPath.path.y) },
                            color = customDrawingPath.color,
                            style = Stroke(width = currentSize),
                            blendMode = BlendMode.SrcOver
                        )
                    }
                }
            }
        }
    }
}
```

```ColorPicker.kt
@Composable
fun ColorPicker(penColor: Color, onColorChange: (Color) -> Unit) {
    val controller = rememberColorPickerController()
    Column {
        HsvColorPicker(
            modifier = Modifier
                .fillMaxWidth()
                .height(240.dp)
                .padding(20.dp),
            controller = controller,
            initialColor = penColor,
            onColorChanged = { colorEnvelope: ColorEnvelope ->
                onColorChange(colorEnvelope.color)
            }
        )
        Row {
            BrightnessSlider(
                modifier = Modifier
                    .fillMaxWidth(0.8f)
                    .padding(40.dp)
                    .height(20.dp),
                controller = controller,
                initialColor = penColor,
            )
            AlphaTile(
                modifier = Modifier
                    .size(40.dp)
                    .clip(RoundedCornerShape(6.dp))
                    .border(width = 1.dp, color = Color.Black),
                controller = controller
            )
        }
    }
}
```

先ほどの線をかくプログラムは経路のみを保存していましたが，ペンのサイズ・色の変更する機能を導入するときは経路だけでなく，ペンのサイズ・色も一緒に保存する必要があります．そのため，経路を保存後で表示していたプログラムを変更し，経路を取得しながら保存，描画の処理を行なっています．(より良い回答があればぜひ教えてください...)


## まとめ
この記事ではJetpackComposeのCanvasを用いてお絵描きアプリを作成した手順を解説しました．これからは，ペンの種類の変更機能や消しゴム機能の追加など作成していきたいと考えています．解説した中でもっと違うやり方を知っている方がいればぜひ教えてください．