Jetpack ComposeのジェスチャーはModifier.pointerInput
を使って実装しますが、標準APIで用意されていないカスタムジェスチャーを実装しようとすると、急に難易度が上がる印象があります。今回はなるべく簡単にpointerInput
のジェスチャー検出の仕組みを説明していきます。
目次
Modifier.pointerInputがすべてのジェスチャーのベースとなる
Jetpack Composeでジェスチャーやタッチイベントを扱うコンポーネントや関数は、Button
やModifier.clickable
などいろいろありますが、内部ではModifier.pointerInput
が使われています。そのため、カスタムジェスチャーを実装するには、Modifier.pointerInput
の理解が不可欠です。
Modifier.pointerInput
の定義は下記のとおりです。
fun Modifier.pointerInput(key1: Any?, block: suspend PointerInputScope.() -> Unit): Modifier
引数のblock
は、PointerInputScope
をレシーバとするsuspend関数です。なぜsuspend関数になっているかというと、ジェスチャー検出処理がsuspend関数で構成されているからです。ジェスチャー検出処理は、タッチ開始・移動・終了などのジェスチャーイベントを待っている間は中断(suspend)して、イベントが発生したらそのイベントに対する処理を行い、次のイベントが発生するまでまた中断する、というのを繰り返します。そのため、suspend関数で実装する必要があるのです。
PointerInputScopeでジェスチャーを検出する
PointerInputScope
内では、awaitEachGesture
でジェスチャーを検出します。ここでいう「ジェスチャー」とは、最初の指が画面に触れてから、(場合によって複数の)指が触れたり離れたり、移動したりするイベントを経て、最終的にすべての指が画面から離れるまでの一連のイベントを意味します。awaitEachGesture
はこの「ジェスチャー」が発生するまでsuspendし、ジェスチャーが発生するとblock
を呼び出します。そして、ジェスチャーが完了すると、また次のジェスチャーが発生するまでsuspendします。
suspend fun PointerInputScope.awaitEachGesture(block: suspend AwaitPointerEventScope.() -> Unit): Unit
なお、awaitEachGesture
はCompose 1.4以降で導入されました。それ以前は、forEachGesture
とawaitPointerEventScope
を組み合わせて実装するのでご注意ください。従来の方式では、イベントを取りこぼす可能性があったようです。
AwaitPointerEventScopeでイベントを検出する
awaitEachGesture
のblock
のレシーバは、AwaitPointerEventScope
です。このスコープでは、awaitXXXという関数を使って、ジェスチャーを構成する個々のイベント(指が触れた・移動した・離れたなど)を受け取ることができます。最初の指が触れたことを検出するのが、awaitFirstDown
です。その後、do-whileループ内でawaitPointerEvent
を使って個々のイベントを受け取り、処理をします。そして最終的には、すべての指が離れたらループを抜けます。これが基本的な流れです。
Modifier.pointerInput(Unit) { // このスコープはPointerInputScope
awaitEachGesture { //このスコープはAwaitPointerEventScope
awaitFirstDown() // 最初のタッチダウンイベント
do {
val event = awaitPointerEvent() // イベントが発生
// 個々のeventに対する処理
} while (event.changes.fastAny { it.pressed }) // 一つでもタッチ継続中ならループ継続
}
}
実はawaitFirstDown
も内部でawaitPointerEvent
を呼び出しています。それなら最初から、do-whileループ内でawaitPointerEvent
だけ使っても同じでは?と思うかもしれません。もちろんそのような実装も可能ですが、awaitPointerEvent
が検出するイベントには、マウスカーソルがその領域に入った・出た(いわゆるマウスオーバー)も含まれます。タッチジェスチャーを検出するにはそれらを除いて指が触れたことを検出する必要があり、それを簡単に実現するための関数が、awaitFirstDown
ということになります。
PointerEventでイベントの内容を取得する
awaitPointerEvent
は、PointerEvent
オブジェクトを返します。PointerEvent
にはcalculatePan
やcalculateZoom
など、calculateXXXという関数が用意されていて、ズームや移動、回転といった変化を取得できます。
また、PointerEvent
のchanges
プロパティが、PointerInputChange
のListになっています。List内の一つのPointerInputChange
オブジェクトが、一つの指のタッチに割り当てられます。例えば指が3本触れているときは、3つのPointerInputChange
オブジェクトがPointerEvent.changes
に格納されています。そして、個々のPointerInputChange
オブジェクトがそれぞれ、画面上の位置情報や、指が画面に触れているかどうか(pressed)といった情報を持っています。これらを使ってジェスチャーを判定します。
PointerInputScopeの拡張関数としてジェスチャー検出を実装する
PointerInputScope
には、detectTapGestures
やdetectTransformGestures
など、detectXXXという拡張関数がいくつか用意され、基本的なジェスチャーを簡単に実装できるようになっています。これらのdetect関数は、上のサンプルコードのPointerInputScope
内の処理(2行目から8行目)を抜き出したものになっています。そして、ジェスチャーを検出した時にコールバックを返す実装になっています。
これを踏まえて、例として2本指のズームジェスチャーを検出する処理を、もっともシンプルに書くと、下記のようになります。
private suspend fun PointerInputScope.detectTransformGestures(
onZoom: (zoom: Float) -> Unit
) {
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false)
do {
val event = awaitPointerEvent()
val zoom = event.calculateZoom()
onZoom(zoom)
} while (event.changes.fastAny { it.pressed })
}
}
calculateZoom
は、PointerEvent
の拡張関数で、複数のタッチ位置の距離の変化を算出します。距離が広がっているときは1より大きい値を返し、縮まっているときは1より小さい値を返します。上で説明した基本の処理の流れの中で、取得したイベントからZoomの値を算出し、コールバックとして返してあげれば、ズームジェスチャー検出関数の出来上がりです。すべての指が離れるまで、指の位置が動くたびにawaitPointerEvent
がイベントを返すので、コールバックの呼び出しが繰り返されます。
実用レベルの実装は少し複雑
上の例は、とにかく簡単にジェスチャー検出の仕組みを説明するために、ジェスチャー検出の本質的な部分だけを実装しました。実際にアプリで使うには、これだけだといろいろ考慮不足で問題が起きます。実用レベルの実装例として、detectTransformGestures
のコードを見てみましょう。
suspend fun PointerInputScope.detectTransformGestures(
panZoomLock: Boolean = false,
onGesture: (centroid: Offset, pan: Offset, zoom: Float, rotation: Float) -> Unit
) {
awaitEachGesture {
var rotation = 0f
var zoom = 1f
var pan = Offset.Zero
var pastTouchSlop = false
val touchSlop = viewConfiguration.touchSlop
var lockedToPanZoom = false
awaitFirstDown(requireUnconsumed = false)
do {
val event = awaitPointerEvent()
val canceled = event.changes.fastAny { it.isConsumed }
if (!canceled) {
val zoomChange = event.calculateZoom()
val rotationChange = event.calculateRotation()
val panChange = event.calculatePan()
if (!pastTouchSlop) {
zoom *= zoomChange
rotation += rotationChange
pan += panChange
val centroidSize = event.calculateCentroidSize(useCurrent = false)
val zoomMotion = abs(1 - zoom) * centroidSize
val rotationMotion = abs(rotation * PI.toFloat() * centroidSize / 180f)
val panMotion = pan.getDistance()
if (zoomMotion > touchSlop ||
rotationMotion > touchSlop ||
panMotion > touchSlop
) {
pastTouchSlop = true
lockedToPanZoom = panZoomLock && rotationMotion < touchSlop
}
}
if (pastTouchSlop) {
val centroid = event.calculateCentroid(useCurrent = false)
val effectiveRotation = if (lockedToPanZoom) 0f else rotationChange
if (effectiveRotation != 0f ||
zoomChange != 1f ||
panChange != Offset.Zero
) {
onGesture(centroid, panChange, zoomChange, effectiveRotation)
}
event.changes.fastForEach {
if (it.positionChanged()) {
it.consume()
}
}
}
}
} while (!canceled && event.changes.fastAny { it.pressed })
}
}
比較的長くてネストが深く、変数も多いので少し読みづらいですが、上で説明した基本の流れを理解していると、それほど難しくありません。
detectTransformGestures
は、zoomの他に、centroid(タッチ座標の重心)、pan(重心の移動)、rotation(回転)を算出してコールバックで返しています。これらはすべて、PointerEvent
のcalculateXXX拡張関数で算出しています。
16行目では、ジェスチャーがキャンセルされたかどうかを確認しています。isConsumed
は、PointerInputChange
イベントが他のコンポーネントで消費されたことを示します。isConsumed
がtrueの場合、即座にdo-whileループを抜けています。
22行目以降では、touchSlopの判定を行っています。touchSlopは、タッチの移動距離がある閾値に達するまではジェスチャーとみなさないという仕組みです。実際のユーザー操作では、タップのつもりでも数ピクセル移動してしまうといったことが起こります。その場合にいちいちズームやパンが発生してしまうと使いづらいので、一定以上の距離を動かすまではジェスチャーが発動しないように判定しています。
touchSlopの判定が完了すると、呼び出し元にコールバックを返す記述があります(48行目)。また、PointerInputChange.consume
を呼び出して、この関数がイベントを消費することをシステム側に通知しています。これによって、UIの他のレイヤーと処理が競合しないようにしています。
おまけ
既存のPointerInputScope.detectXXX関数を組み合わせて使いたい場合はどのようにすればよいでしょうか。例えば、タップの検出とドラッグの検出を一つのコンポーネントに実装したいケースはよくありそうです。
detectTapGestures
とdetectDragGestuers
を組み合わせれば実現できそうですが、これらdetect関数はsuspend関数で、完了しません。そのため、以下のように実装しても、2つ目のdetect関数は呼び出されません。
Modifier
.pointerInput(Unit) {
detectTapGestures { println("Tap!!!") }
detectDragGestures { _, _ -> println("Drag!!!") }
}
下記のように、Modifier.pointerInput()を複数書くことは可能です。タップとドラッグのような、互いに独立したジェスチャーであれば、この書き方で共存できます。
Modifier
.pointerInput(Unit) {
detectTapGestures { println("Tap!!!") }
}
.pointerInput(Unit) {
detectDragGestures { _, _ -> println("Drag!!!") }
}
しかし、競合するジェスチャーを複数書いた場合は、後に書いた方しか動作しません。下記の例では、Tap 2しか出力されません。後に書いた方のdetect関数内でイベントを消費してしまい、先に書いた方にイベントが届かないからです。
Modifier
.pointerInput(Unit) {
detectTapGestures { println("Tap 1") }
}
.pointerInput(Unit) {
detectTapGestures { println("Tap 2") }
}
以上、今回はPointerInputとジェスチャー検出の仕組みについて説明しました。