操作について理解する

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

定義

このページで紹介しているさまざまなコンセプトを理解するには、以下の用語を理解しておく必要があります。

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

さまざまなレベルの抽象化

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

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

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

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

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

ユースケースに適している場合は、コンポーネントにジェスチャーを含めることをおすすめします。コンポーネントには、フォーカスとユーザー補助のサポートが標準で付属しており、十分にテストされているためです。たとえば、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 のユーザー補助をご覧ください。

修飾子を使用して任意のコンポーザブルに特定の操作を追加する

任意のコンポーザブルにジェスチャー修飾子を適用すると、コンポーザブルで操作をリッスンできます。たとえば、汎用の Boxclickable にしてタップ操作を処理できるようにし、ColumnverticalScroll を適用して垂直方向のスクロールを処理できます。

さまざまなタイプのジェスチャーを処理するための修飾子が多数あります。

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

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

すべてのジェスチャーが、すぐに使えるジェスチャー修飾子で実装されているわけではありません。たとえば、修飾子を使用して、長押し、Ctrl+クリック、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 メソッドでユースケースに対して十分なきめ細かい制御ができない場合は、未加工のイベントをリッスンして計算を適用できます。これらのヘルパー メソッドは、calculateCentroidcalculateCentroidSizecalculatePancalculateRotationcalculateZoom です。

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

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

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

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

イベントの使用

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

画像、2 つのテキストを含む列、ボタンを含むリストアイテム。

ユーザーがブックマーク ボタンをタップすると、ボタンの onClick ラムダがそのジェスチャーを処理します。ユーザーがリストアイテムの他の部分をタップすると、ListItem がそのジェスチャーを処理して記事に移動します。ポインタ入力に関しては、ボタンはこのイベントを「消費」して、親がもう反応しないようにする必要があります。すぐに使用できるコンポーネントに含まれるジェスチャーと、一般的なジェスチャー修飾子には、この消費動作が含まれていますが、独自のカスタム ジェスチャーを作成する場合は、イベントを手動で消費する必要があります。これを行うには 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 ツリーに変換されます。ここでは、ListItemButton のみがポインタ イベントに応答します。

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

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

  • 初期パスでは、イベントは UI ツリーの上部から下部に向かって流れます。このフローにより、子がイベントを使用する前に、親がイベントをインターセプトできます。たとえば、ツールチップは、子に渡すのではなく、長押しをインターセプトする必要があります。この例では、ListItemButton の前にイベントを受け取ります。
  • メインパスでは、イベントは UI ツリーのリーフノードから UI ツリーのルートまで流れます。このフェーズでは、通常は操作を使用します。また、イベントをリッスンするときのデフォルトのパスです。このパスで操作を処理すると、リーフノードはその親よりも優先されます。これは、ほとんどの操作で最も論理的な動作です。この例では、ButtonListItem の前にイベントを受け取ります。
  • 最終パスでは、イベントは 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 の操作について詳しくは、以下のリソースをご覧ください。