まえがき
Androidで手書きノートアプリを作っています。
手書きにはAndroidX Inkを使っていますが、書いた線の一部分だけを消す処理が必要になったので、実装した方法をまとめておきます。
画面上では一本の線に見えますが、Inkの手書きはペンの位置や筆圧などを含んだストロークとして扱われています。そのため、ストロークをそのまま削除すると、一筆で書いた線が全部消えてしまいます。
今回は、消しゴムに触れた部分を取り除いて、残った部分を別々のストロークとして作り直します。
環境
- Kotlin
- AndroidX Ink 1.0.0
線の一部分を消す
ストロークには、ペンが通った位置が入力点として記録されています。点と点の間隔はいつも同じではなく、ペンの動かし方によって広くなることもあります。
消しゴムに触れた点を削除すれば良さそうですが、点と点の間を消しゴムが通った場合、点だけを調べても触れたことが分かりません。
●──────■──────●
消しゴム
なので、隣り合う点を結ぶ線分が消しゴムの範囲に入るか調べます。範囲に入った場合は、その前後に点を作って、線を分割します。
消しゴムの範囲を作成
消しゴムを動かしたときも、位置は点として取得されます。現在の位置だけを調べると、速く動かしたときに点の間が消えない可能性があります。
前回の位置と現在の位置を線で結び、その周囲に幅を持たせたものを消去範囲として使います。InkのMutableParallelogramで範囲を作成しています。
val shape = MutableParallelogram()
.populateFromSegmentAndPadding(
MutableSegment(
MutableVec(startX, startY),
MutableVec(endX, endY)
),
padding
)startX、startYが前回の位置、endX、endYが現在の位置です。paddingで線分の周囲に幅を持たせています。
タッチ位置は画面を拡大・移動した状態の座標なので、実際にはノート用紙の座標に変換してから渡しています。見開きでは、隣のページだけをなぞったときにこちらのページまで消えないよう、消去経路をページの範囲内に切り取っています。
消しゴムに触れたストロークを探す
すべてのストロークを細かく調べる前に、まず消去範囲とストロークの形状が交差するかを確認します。交差しないものは、そのまま結果に追加します。
for (stroke in strokes) {
if (!stroke.shape.intersects(
shape,
AffineTransform.IDENTITY
)
) {
result.add(stroke)
continue
}
// 交差したストロークを調べて分割する
// result.addAll(splitStroke(...))
}実際には、ここで前述の移動経路や幅を渡してsplitStrokeを呼び出しています。AffineTransform.IDENTITYは変換なし、という意味です。ここでは消去範囲もストロークも用紙の座標で扱っています。
消す区間を調べる
交差したストロークから入力点を取り出し、隣り合う点を結ぶ線分について、消去範囲に入る区間を調べます。
斜めに動かした消しゴムも同じように計算できるように、入力点を「消しゴムの進行方向が横向きになる座標」に変換しています。この座標では、消去範囲を横長の矩形として扱えます。
線分の始点を0、終点を1として、範囲に入る位置と出る位置を求めます。例えば0.3から0.6までが範囲内なら、0から0.3と、0.6から1までを残します。範囲の前後に残った部分は、それぞれ別のストロークになります。
また、切断後の丸い線端が消した場所にはみ出しにくいよう、判定する幅にはペンの太さの半分を加えています。
val radius = padding + stroke.brush.size / 2.0これは中心線を使った判定なので、すべてのペン形状に対して完全に一致する計算ではありません。使うブラシの形によっては調整が必要だと思います。
境界に点を追加する
消去範囲の境界が、ちょうど記録済みの点の位置にあるとは限りません。そこで、2つの点の間に新しい点を作ります。これを補間といいます。
例えば、点Aから点Bまでの40%の場所に点を作る場合、座標は次のように計算できます。
val x = a.x + (b.x - a.x) * 0.4f
val y = a.y + (b.y - a.y) * 0.4f0なら点A、1なら点B、0.5ならその中間です。実際の処理では計算した境界の位置を使い、残す線の端に新しい点を追加しています。
位置だけでなく、入力時刻や筆圧、傾きなども必要に応じて補間しています。ペンの向きは角度が一周すると元に戻るため、例えば359度と1度の中間が180度にならないよう、近い方向へ補間しています。
ストロークを作り直す
残す部分ごとに入力点をまとめて、元のブラシを使って新しいストロークを作ります。
return fragments.map { fragment ->
val batch = MutableStrokeInputBatch()
batch.add(fragment)
Stroke(
brush = stroke.brush,
inputs = batch
)
}これで、消しゴムが通った部分を挟んで、元の線が複数のストロークに分かれます。消去範囲が線全体を覆っていれば、残るストロークはありません。
確認したいところ
線の真ん中をゆっくり消すだけでなく、速く動かしたときや太い線を消したときも確認します。分割後の線端や、筆圧のあるブラシの見え方も確認しておくと良さそうです。
アプリでは、マーカーと筆ペンのストロークを分割した後も元のブラシが保たれるか、保存と読み込みができるかをテストしています。
あとがき
消しゴムで一部分だけ消すには、見えている線を消すだけでなく、入力点を調べてストロークを作り直す必要がありました。
今回はアプリで使っている方法を紹介しました。ブラシの形や、消しゴムにどの程度触れたら消すかによっても見え方が変わるので、作りたい操作感に合わせて調整してみてください。
参考リンク
いずれもAndroid Developersの資料です。公式の消しゴムのサンプルはストローク全体を削除する例ですが、部分的に消す場合の考え方にも触れています。
- Geometry APIs — 図形の交差判定と消しゴムの実装例
- Ink APIの各モジュール — ストローク、ブラシ、描画などの役割
- ストロークを描画する — ペン入力の取得と座標変換
- MutableStrokeInputBatch リファレンス — 入力点の追加方法と、重複や入力時刻に関する制約

コメント