【JetpackCompose】detectDragGesturesを用いてCanvasお絵描きUndo機能をサクッと作る

## はじめに

JetpackComposeでCanvasお絵描きUndo機能をわりと簡単に実装できるやり方がありましたので備忘がてら共有します。

特に`pointerInteropFilter`からMotionEventを取得して実装する手法は記事などでいくつか見かけるのですが、`detectDragGestures`を使ったやり方はあまり見かけない気がしたので、もし誰かの役に立てれば嬉しいです。

:::note warn
どちらのやり方が良いとか悪いとかの話ではありません。
こういうやり方もあるという話です。
:::

## キャプチャ


<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/662505/993acd71-f06b-ac16-da7c-b62c1b27096d.gif" width="250">





## ソースコード全文

```kotlin
data class DrawLine(
    val drawKey: Long,
    val start: Offset,
    val end: Offset,
)

@Composable
fun DrawingScreen() {
    val lines = remember { mutableStateListOf<DrawLine>() }
    val color: Color = Color.Red
    val strokeWidth: Dp = 3.dp

    Column {
        // Undoボタン
        Button(
            onClick = {
                if (lines.isNotEmpty()) {
                    val latestKey = lines.last().drawKey
                    lines.removeIf {
                        it.drawKey == latestKey
                    }
                }
            },
            modifier = Modifier.padding(16.dp)
        ) {
            Text(text = "戻す")
        }

        Canvas(
            modifier = Modifier
                .fillMaxSize()
                .background(Color.LightGray)
                .pointerInput(true) {
                    detectDragGestures(
                        onDrag = { change, dragAmount ->
                            change.consume()
                            val line = DrawLine(
                                drawKey = change.id.value,
                                start = change.position - dragAmount,
                                end = change.position
                            )
                            lines.add(line)
                        }
                    )
                }
        ) {
            lines.forEach { line ->
                drawLine(
                    color = color,
                    start = line.start,
                    end = line.end,
                    strokeWidth = strokeWidth.toPx(),
                    cap = StrokeCap.Round
                )
            }
        }
    }
}
```

## 解説

### DrawLine

お絵描き開始の位置と終わり位置のOffsetを持ちます。
また、drawKey： Longも持たせます。これは後述しますが線描画のひとまとまりを識別するためのものです。

### detectDragGestures

・ドキュメント

https://developer.android.com/reference/kotlin/androidx/compose/foundation/gestures/package-summary#(androidx.compose.ui.input.pointer.PointerInputScope).detectDragGestures(kotlin.Function1,kotlin.Function0,kotlin.Function0,kotlin.Function2)

onDragのラムダで`change: PointerInputChange`と`dragAmount: Offset`が取得できます。
これらを使って機能を実現します。

#### お絵描き機能

ドラッグを進めるたびにchangeとdragAmountが返ってきますので、それを元にひとつのドラッグイベント単位で線の情報を生成して、
rememberで保持しているStateに追加します。

```kotlin
val lines = remember { mutableStateListOf<DrawLine>() }
```

```kotlin
val line = DrawLine(
    // drawKey = change.id.value, ※線を書くだけなら不要
    start = change.position - dragAmount,
    end = change.position
)
lines.add(line)
```

linesの情報をCanvasのDrawScopeで描画させます。

```kotlin
// DrawScope
lines.forEach { line ->
    drawLine(
        color = color,
        start = line.start,
        end = line.end,
        strokeWidth = strokeWidth.toPx(),
        cap = StrokeCap.Round
    )
}
```

### Undo機能

描いた線をひとつ前に戻す、というやつです。

これを実現するには、ドラッグから得た線の情報から一筆で描かれた分を纏めて抽出する必要があります。

MotionEventのACTIN_DOWNでポイントを拾ったりしてなんやかんやするアレです（雑）

なんとこれがdetectDragGesturesだと簡単に纏めることができるのです！

```kotlin
detectDragGestures(
    onDrag = { change, dragAmount ->
        change.consume()
        val line = DrawLine(
            drawKey = change.id.value,
            start = change.position - dragAmount,
            end = change.position
        )
        lines.add(line)
    }
) 
```

```kotlin
drawKey = change.id.value
```

これです。

detectDragGesturesのonDragは、ドラッグイベントごとに`PointerInputChange`というクラスを取得できて、positionなんかもここから取得しているのですが、他にもいろいろ便利な情報を持っています。

```kotlin
@Immutable
class PointerInputChange(
    val id: PointerId,
    ...etc
)
```

これの`id: PointerId`を利用します。
PointerIdはPointerId(val value: Long)となっており、onDragStartからonDragEnd（もしくはonDragCancel）ごとにvalueがインクリメントされて提供されているようです。

:::note warn
実はここら辺の正確な挙動については筆者は把握しきれておりません。
AwaitPointerEventScopeなどのコードを読んでみたりして、PointerがPressされたときにpointerIdがotherDown.idに差し変わっていそうに見えているので、上記のような曖昧な記載をしています。

勉強して解決したら、更新しますm(__)m
:::

つまり、一筆書きのドラッグは全て同じPointerId.valueが取得できるということになります。

これを利用して、Undoのときは同じvalueを持つ線を全て削除すればOKです。

```kotlin
// Undoボタン
Button(
    onClick = {
        if (lines.isNotEmpty()) {
            val latestKey = lines.last().drawKey
            lines.removeIf {
                it.drawKey == latestKey
            }
        }
    },
)
```

ひとつ前を消したいのでListの最後のpointer.value(= drawKey)を特定するためlast()を使って抽出します。

これでUndo機能の完成です。

### おまけ

全部消去（お絵描きリセット）とかするのであればListをAllRemoveとかしてあげればOK。

あと、線の色とか太さを途中で変えたりする機能はよくあるユースケースだと思いますが、

```kotlin
data class DrawLine(
    val drawKey: Long,
    val start: Offset,
    val end: Offset,
    val color: Color = Color.Red,
    val strokeWidth: Dp = 3.dp
)
```

このようにdata classに情報を持たせておけば、途中で切り替えてもListに線ごとの情報を保持しておけるので便利です。

## おわりに

最近Canvas描画系の実装を勉強し始めましたが、けっこう奥が深く沼にハマりそうです。

実務でもこの辺の実装が控えてたりするので、また知見溜まったら記事にしていきますのでよろしくお願いします。