はじめに
JetpackComposeでCanvasお絵描きUndo機能をわりと簡単に実装できるやり方がありましたので備忘がてら共有します。
特にpointerInteropFilter
からMotionEventを取得して実装する手法は記事などでいくつか見かけるのですが、detectDragGestures
を使ったやり方はあまり見かけない気がしたので、もし誰かの役に立てれば嬉しいです。
どちらのやり方が良いとか悪いとかの話ではありません。
こういうやり方もあるという話です。
キャプチャ
ソースコード全文
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
・ドキュメント
onDragのラムダでchange: PointerInputChange
とdragAmount: Offset
が取得できます。
これらを使って機能を実現します。
お絵描き機能
ドラッグを進めるたびにchangeとdragAmountが返ってきますので、それを元にひとつのドラッグイベント単位で線の情報を生成して、
rememberで保持しているStateに追加します。
val lines = remember { mutableStateListOf<DrawLine>() }
val line = DrawLine(
// drawKey = change.id.value, ※線を書くだけなら不要
start = change.position - dragAmount,
end = change.position
)
lines.add(line)
linesの情報をCanvasのDrawScopeで描画させます。
// DrawScope
lines.forEach { line ->
drawLine(
color = color,
start = line.start,
end = line.end,
strokeWidth = strokeWidth.toPx(),
cap = StrokeCap.Round
)
}
Undo機能
描いた線をひとつ前に戻す、というやつです。
これを実現するには、ドラッグから得た線の情報から一筆で描かれた分を纏めて抽出する必要があります。
MotionEventのACTIN_DOWNでポイントを拾ったりしてなんやかんやするアレです(雑)
なんとこれがdetectDragGesturesだと簡単に纏めることができるのです!
detectDragGestures(
onDrag = { change, dragAmount ->
change.consume()
val line = DrawLine(
drawKey = change.id.value,
start = change.position - dragAmount,
end = change.position
)
lines.add(line)
}
)
drawKey = change.id.value
これです。
detectDragGesturesのonDragは、ドラッグイベントごとにPointerInputChange
というクラスを取得できて、positionなんかもここから取得しているのですが、他にもいろいろ便利な情報を持っています。
@Immutable
class PointerInputChange(
val id: PointerId,
...etc
)
これのid: PointerId
を利用します。
PointerIdはPointerId(val value: Long)となっており、onDragStartからonDragEnd(もしくはonDragCancel)ごとにvalueがインクリメントされて提供されているようです。
実はここら辺の正確な挙動については筆者は把握しきれておりません。
AwaitPointerEventScopeなどのコードを読んでみたりして、PointerがPressされたときにpointerIdがotherDown.idに差し変わっていそうに見えているので、上記のような曖昧な記載をしています。
勉強して解決したら、更新しますm(__)m
つまり、一筆書きのドラッグは全て同じPointerId.valueが取得できるということになります。
これを利用して、Undoのときは同じvalueを持つ線を全て削除すればOKです。
// 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。
あと、線の色とか太さを途中で変えたりする機能はよくあるユースケースだと思いますが、
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描画系の実装を勉強し始めましたが、けっこう奥が深く沼にハマりそうです。
実務でもこの辺の実装が控えてたりするので、また知見溜まったら記事にしていきますのでよろしくお願いします。