操作について理解する

アプリでジェスチャー処理を行う際に理解しておくべき重要な用語とコンセプトがいくつかあります。このページでは、ポインタ、ポインタイベント、ジェスチャーという用語について説明し、ジェスチャーのさまざまな抽象化レベルを紹介します。また、イベントの消費と伝播についても詳しく説明します。

定義

このページのさまざまなコンセプトを理解するには、使用されている用語の一部を理解する必要があります。

  • ポインタ: アプリケーションを操作するために使用できる物理オブジェクト。モバイル デバイスの場合、最も一般的なポインタは、タッチスクリーンを操作する指です。指の代わりにタッチペンを使用することもできます。 大画面では、マウスやトラックパッドを使用してディスプレイを間接的に操作できます。入力デバイスがポインタと見なされるには、座標を「ポイント」できる必要があります。たとえば、キーボードはポインタとは見なされません。Compose では、PointerType を使用してポインタの変更にポインタタイプが含まれます。
  • ポインタ イベント: 特定の時点における 1 つ以上のポインタとアプリとの下位レベルのインタラクションを記述します。画面に指を置く、マウスをドラッグするなど、ポインタの操作によってイベントがトリガーされます。Compose では、このようなイベントに関連するすべての情報が PointerEvent クラスに含まれています。
  • ジェスチャー: 単一のアクションとして解釈できる一連のポインタイベント。たとえば、タップ操作は、ダウンイベントとそれに続くアップイベントのシーケンスと見なすことができます。タップ、ドラッグ、変換など、多くのアプリで使用される一般的な操作がありますが、必要に応じて独自のカスタム操作を作成することもできます。

さまざまな抽象化レベル

Jetpack Compose には、ジェスチャーを処理するためのさまざまなレベルの抽象化が用意されています。最上位はコンポーネント サポートです。Button などのコンポーザブルには、ジェスチャーのサポートが自動的に含まれます。カスタム コンポーネントにジェスチャーのサポートを追加するには、clickable などのジェスチャー修飾子を任意のコンポーザブルに追加します。最後に、カスタム ジェスチャーが必要な場合は、pointerInput 修飾子を使用できます。

原則として、必要な機能を提供する最も高い抽象化レベルで構築します。これにより、レイヤに含まれるベスト プラクティスを活用できます。たとえば、Button には、clickable よりも多くのセマンティック情報(ユーザー補助に使用)が含まれています。clickable には、未加工の pointerInput 実装よりも多くの情報が含まれています。

コンポーネントのサポート

Compose の多くの標準コンポーネントには、何らかの内部ジェスチャー処理が含まれています。たとえば、LazyColumn はコンテンツをスクロールすることでドラッグ操作に応答し、Button は長押しすると波紋を表示します。SwipeToDismiss コンポーネントには、要素を閉じるスワイプ ロジックが含まれています。このタイプのジェスチャー処理は自動的に行われます。

内部ジェスチャー処理に加えて、多くのコンポーネントでは、呼び出し元がジェスチャーを処理する必要もあります。たとえば、Button はタップを自動的に検出し、クリック イベントをトリガーします。onClick ラムダを Button に渡して、ジェスチャーに反応します。同様に、Slider に onValueChange ラムダを追加して、ユーザーがスライダー ハンドルをドラッグしたときに反応するようにします。

ユースケースに適合する場合は、コンポーネントに含まれるジェスチャーを使用することをおすすめします。コンポーネントに含まれるジェスチャーは、フォーカスとユーザー補助をすぐにサポートし、十分にテストされているためです。たとえば、Button は、ユーザー補助サービスがクリック可能な要素としてではなく、ボタンとして正しく記述できるように、特別な方法でマークされています。

// Talkback: "Click me!, Button, double tap to activate"
Button(onClick = { /* TODO */ }) { Text("Click me!") }
// Talkback: "Click me!, double tap to activate"
Box(Modifier.clickable { /* TODO */ }) { Text("Click me!") }

Compose のユーザー補助機能について詳しくは、Compose のユーザー補助機能をご覧ください。

修飾子を使用して任意のコンポーザブルに特定のジェスチャーを追加する

任意のコンポーザブルにジェスチャー修飾子を適用して、コンポーザブルがジェスチャーをリッスンするようにできます。たとえば、一般的な Box を clickable にすることでタップ操作を処理したり、verticalScroll を適用することで Column に垂直スクロールを処理させたりできます。

さまざまな種類の操作を処理する修飾子が多数あります。

原則として、カスタム ジェスチャー処理よりも、すぐに使えるジェスチャー修飾子を優先します。修飾子は、純粋なポインタイベント処理に機能を追加します。たとえば、clickable 修飾子は、押下とタップの検出を追加するだけでなく、セマンティック情報、インタラクションの視覚的な表示、ホバー、フォーカス、キーボード サポートも追加します。clickable のソースコードをチェックして、機能がどのように追加されているかを確認できます。

pointerInput 修飾子を使用して、任意のコンポーザブルにカスタム ジェスチャーを追加する

すべてのジェスチャーが、すぐに使えるジェスチャー修飾子で実装されているわけではありません。たとえば、修飾子を使用して、長押し後のドラッグ、Control キーを押しながらのクリック、3 本指タップに反応することはできません。代わりに、独自のジェスチャー ハンドラを作成して、これらのカスタム ジェスチャーを識別できます。pointerInput 修飾子を使用して、未加工のポインタイベントにアクセスできるジェスチャー ハンドラを作成できます。

次のコードは、未加工のポインタイベントをリッスンします。

@Composable
private fun LogPointerEvents(filter: PointerEventType? = null) {
    var log by remember { mutableStateOf("") }
    Column {
        Text(log)
        Box(
            Modifier
                .size(100.dp)
                .background(Color.Red)
                .pointerInput(filter) {
                    awaitPointerEventScope {
                        while (true) {
                            val event = awaitPointerEvent()
                            // handle pointer event
                            if (filter == null || event.type == filter) {
                                log = "${event.type}, ${event.changes.first().position}"
                            }
                        }
                    }
                }
        )
    }
}

このスニペットを分解すると、コア コンポーネントは次のようになります。

  • pointerInput 修飾子。1 つ以上のキーを渡します。これらのキーのいずれかの値が変更されると、修飾子コンテンツ ラムダが再実行されます。このサンプルでは、オプションのフィルタをコンポーザブルに渡しています。そのフィルタの値が変更された場合は、ポインター イベント ハンドラを再実行して、適切なイベントがログに記録されるようにする必要があります。
  • awaitPointerEventScope は、ポインタイベントを待機するために使用できるコルーチン スコープを作成します。
  • awaitPointerEvent は、次のポインタ イベントが発生するまでコルーチンを中断します。

未加工の入力イベントをリッスンすることは強力ですが、この元データに基づいてカスタム ジェスチャーを記述することは複雑でもあります。カスタム ジェスチャーの作成を簡素化するために、多くのユーティリティ メソッドが用意されています。

フル ジェスチャーを検出する

未加工のポインタイベントを処理する代わりに、特定のジェスチャーが発生するのをリッスンして、適切に応答できます。AwaitPointerEventScope には、次のイベントをリッスンするメソッドが用意されています。

これらは最上位の検出機能であるため、1 つの pointerInput 修飾子内に複数の検出機能を追加することはできません。次のスニペットは、タップのみを検出し、ドラッグは検出しません。

var log by remember { mutableStateOf("") }
Column {
    Text(log)
    Box(
        Modifier
            .size(100.dp)
            .background(Color.Red)
            .pointerInput(Unit) {
                detectTapGestures { log = "Tap!" }
                // Never reached
                detectDragGestures { _, _ -> log = "Dragging" }
            }
    )
}

内部的には、detectTapGestures メソッドがコルーチンをブロックし、2 番目の検出機能に到達することはありません。コンポーズ可能な関数に複数の操作リスナーを追加する必要がある場合は、代わりに個別の pointerInput 修飾子インスタンスを使用します。

var log by remember { mutableStateOf("") }
Column {
    Text(log)
    Box(
        Modifier
            .size(100.dp)
            .background(Color.Red)
            .pointerInput(Unit) {
                detectTapGestures { log = "Tap!" }
            }
            .pointerInput(Unit) {
                // These drag events will correctly be triggered
                detectDragGestures { _, _ -> log = "Dragging" }
            }
    )
}

ジェスチャーごとにイベントを処理する

定義上、ジェスチャーはポインタダウン イベントで始まります。各未加工イベントを通過する while(true) ループの代わりに、awaitEachGesture ヘルパー メソッドを使用できます。awaitEachGesture メソッドは、すべてのポインタが離されたときに、ジェスチャーが完了したことを示すコンテナ ブロックを再起動します。

@Composable
private fun SimpleClickable(onClick: () -> Unit) {
    Box(
        Modifier
            .size(100.dp)
            .pointerInput(onClick) {
                awaitEachGesture {
                    awaitFirstDown().also { it.consume() }
                    val up = waitForUpOrCancellation()
                    if (up != null) {
                        up.consume()
                        onClick()
                    }
                }
            }
    )
}

実際には、ジェスチャーを識別せずにポインタイベントに応答する場合を除き、ほとんどの場合 awaitEachGesture を使用します。たとえば、hoverable は、ポインタのダウンイベントやアップイベントには応答せず、ポインタが境界内に入ったとき、または境界外に出たときを認識するだけで済みます。

特定のイベントまたはサブジェスチャーを待機する

一般的な操作を識別するのに役立つ一連のメソッドがあります。

マルチタッチ イベントの計算を適用する

ユーザーが複数のポインタを使用してマルチタッチ ジェスチャーを行っている場合、未加工の値に基づいて必要な変換を理解するのは複雑です。transformable 修飾子または detectTransformGestures メソッドでは、ユースケースに対して十分なきめ細かい制御ができない場合は、未加工のイベントをリッスンして、それらに計算を適用できます。これらのヘルパー メソッドは、calculateCentroid、calculateCentroidSize、calculatePan、calculateRotation、calculateZoom です。

イベント ディスパッチとヒットテスト

すべてのポインタイベントがすべての pointerInput 修飾子に送信されるわけではありません。イベントのディスパッチは次のように機能します。

  • ポインタイベントはコンポーザブル階層にディスパッチされます。新しいポインタが最初のポインタイベントをトリガーした時点で、システムは「対象となる」コンポーザブルのヒットテストを開始します。コンポーザブルは、ポインタ入力処理機能を備えている場合に、対象と見なされます。ヒットテストは、UI ツリーの上部から下部に向かって行われます。コンポーザブルは、ポインタイベントがそのコンポーザブルの境界内で発生したときに「ヒット」します。このプロセスにより、ヒットテストで陽性となるコンポーザブルのチェーンが生成されます。
  • デフォルトでは、ツリーの同じレベルに複数の対象となるコンポーザブルがある場合、z-index が最も高いコンポーザブルのみが「ヒット」します。たとえば、重複する 2 つの Button コンポーザブルを Box に追加すると、上に描画されたコンポーザブルのみがポインタ イベントを受け取ります。理論的には、独自の PointerInputModifierNode 実装を作成し、sharePointerInputWithSiblings を true に設定することで、この動作をオーバーライドできます。
  • 同じポインタの以降のイベントは、同じコンポーザブルのチェーンにディスパッチされ、イベント伝播ロジックに従ってフローします。システムは、このポインタに対してヒットテストを実行しなくなります。つまり、チェーン内の各コンポーザブルは、そのポインタのすべてのイベントを受け取ります。これは、イベントがそのコンポーザブルの境界外で発生した場合でも同様です。チェーンに含まれていないコンポーザブルは、ポインタが境界内にある場合でも、ポインタイベントを受け取ることはありません。

マウスまたはタッチペンをホバーすることでトリガーされるホバー イベントは、ここで定義されているルールの例外です。ホバーイベントは、ヒットしたコンポーザブルに送信されます。そのため、ユーザーがポインタをあるコンポーザブルの境界から次のコンポーザブルの境界に移動すると、イベントは最初のコンポーザブルではなく、新しいコンポーザブルに送信されます。

イベントの消費

複数のコンポーザブルにジェスチャー ハンドラが割り当てられている場合、それらのハンドラは競合しないようにする必要があります。たとえば、次の UI を見てみましょう。

画像、2 つのテキストを含む列、ボタンを含むリスト項目。

ユーザーがブックマーク ボタンをタップすると、ボタンの onClick ラムダがその操作を処理します。ユーザーがリストアイテムの他の部分をタップすると、ListItem がその操作を処理して記事に移動します。ポインタ入力の観点から、Button はこのイベントを消費する必要があります。これにより、親はイベントに反応しないことを認識します。標準コンポーネントに含まれるジェスチャーと一般的なジェスチャー修飾子には、この消費動作が含まれていますが、独自のカスタム ジェスチャーを作成する場合は、イベントを手動で消費する必要があります。これを行うには、PointerInputChange.consume メソッドを使用します。

Modifier.pointerInput(Unit) {

    awaitEachGesture {
        while (true) {
            val event = awaitPointerEvent()
            // consume all changes
            event.changes.forEach { it.consume() }
        }
    }
}

イベントを消費しても、他のコンポーザブルへのイベントの伝播は停止しません。コンポーザブルは、消費されたイベントを明示的に無視する必要があります。カスタム ジェスチャーを記述する場合は、イベントが別の要素によってすでに消費されているかどうかを確認する必要があります。

Modifier.pointerInput(Unit) {
    awaitEachGesture {
        while (true) {
            val event = awaitPointerEvent()
            if (event.changes.any { it.isConsumed }) {
                // A pointer is consumed by another gesture handler
            } else {
                // Handle unconsumed event
            }
        }
    }
}

イベントの伝播

前述のように、ポインタの変更は、ヒットした各コンポーザブルに渡されます。しかし、そのようなコンポーザブルが複数存在する場合、イベントはどのような順序で伝播するのでしょうか。前のセクションの例を使用すると、この UI は次の UI ツリーに変換されます。このツリーでは、ListItem と Button のみがポインタイベントに応答します。

ツリー構造。最上位レイヤは ListItem、2 番目のレイヤは Image、Column、Button で、Column は 2 つの Text に分割されています。ListItem と Button がハイライト表示されています。

ポインタイベントは、3 つの「パス」の間に、これらのコンポーザブルを 3 回通過します。

  • 初期パスでは、イベントは UI ツリーの上部から下部へと流れます。このフローにより、親は子がイベントを消費する前にイベントをインターセプトできます。たとえば、ツールチップは、長押しをインターセプトして、子に渡さないようにする必要があります。この例では、ListItem は Button の前にイベントを受け取ります。
  • メインパスでは、イベントは UI ツリーのリーフノードから UI ツリーのルートまで流れます。このフェーズは、通常、ジェスチャーを消費するフェーズであり、イベントをリッスンする際のデフォルトのパスです。このパスでジェスチャーを処理するということは、リーフノードが親よりも優先されることを意味します。これは、ほとんどのジェスチャーにとって最も論理的な動作です。この例では、Button は ListItem より前にイベントを受け取ります。
  • 最終パスでは、イベントは UI ツリーの上部からリーフノードまでもう一度流れます。このフローにより、スタックの上位にある要素が、親によるイベントの消費に応答できるようになります。たとえば、ボタンは、押下操作がスクロール可能な親のドラッグ操作に変わると、リップル インジケーターを削除します。

イベントフローを視覚的に表すと、次のようになります。

入力の変更が消費されると、この情報はフローのその時点から先に渡されます。

コードでは、対象となるパスを指定できます。

Modifier.pointerInput(Unit) {
    awaitPointerEventScope {
        val eventOnInitialPass = awaitPointerEvent(PointerEventPass.Initial)
        val eventOnMainPass = awaitPointerEvent(PointerEventPass.Main) // default
        val eventOnFinalPass = awaitPointerEvent(PointerEventPass.Final)
    }
}

このコード スニペットでは、消費に関するデータが変更されている可能性がありますが、これらの await メソッド呼び出しのそれぞれによって同じイベントが返されます。

ジェスチャーをテストする

テストメソッドでは、performTouchInput メソッドを使用してポインタイベントを手動で送信できます。これにより、高レベルのフル ジェスチャー(ピンチ操作や長押しなど)または低レベルのジェスチャー(カーソルを特定のピクセル数だけ移動するなど)を実行できます。

composeTestRule.onNodeWithTag("MyList").performTouchInput {
    swipeUp()
    swipeDown()
    click()
}

その他の例については、performTouchInput のドキュメントをご覧ください。

詳細

Jetpack Compose のジェスチャーについて詳しくは、次のリソースをご覧ください。