PointerInputとジェスチャー検出の仕組みを理解する

この記事をシェア

Jetpack ComposeのジェスチャーはModifier.pointerInputを使って実装しますが、標準APIで用意されていないカスタムジェスチャーを実装しようとすると、急に難易度が上がる印象があります。今回はなるべく簡単にpointerInputのジェスチャー検出の仕組みを説明していきます。

Modifier.pointerInputがすべてのジェスチャーのベースとなる

Jetpack Composeでジェスチャーやタッチイベントを扱うコンポーネントや関数は、ButtonModifier.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以降で導入されました。それ以前は、forEachGestureawaitPointerEventScopeを組み合わせて実装するのでご注意ください。従来の方式では、イベントを取りこぼす可能性があったようです。

AwaitPointerEventScopeでイベントを検出する

awaitEachGestureblockのレシーバは、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にはcalculatePancalculateZoomなど、calculateXXXという関数が用意されていて、ズームや移動、回転といった変化を取得できます。

また、PointerEventchangesプロパティが、PointerInputChangeのListになっています。List内の一つのPointerInputChangeオブジェクトが、一つの指のタッチに割り当てられます。例えば指が3本触れているときは、3つのPointerInputChangeオブジェクトがPointerEvent.changesに格納されています。そして、個々のPointerInputChangeオブジェクトがそれぞれ、画面上の位置情報や、指が画面に触れているかどうか(pressed)といった情報を持っています。これらを使ってジェスチャーを判定します。

PointerInputScopeの拡張関数としてジェスチャー検出を実装する

PointerInputScopeには、detectTapGesturesdetectTransformGesturesなど、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関数を組み合わせて使いたい場合はどのようにすればよいでしょうか。例えば、タップの検出とドラッグの検出を一つのコンポーネントに実装したいケースはよくありそうです。

detectTapGesturesdetectDragGestuersを組み合わせれば実現できそうですが、これら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とジェスチャー検出の仕組みについて説明しました。

この記事をシェア