アプリでのジェスチャー処理に取り組む際に理解しておくべき用語や概念がいくつかあります。このページでは、ポインタ、ポインタ イベント、ジェスチャーという用語について説明し、ジェスチャーのさまざまな抽象化レベルを紹介します。また、イベントの使用と伝播について詳しく説明します。
定義
このページで紹介しているさまざまなコンセプトを理解するには、以下の用語を理解しておく必要があります。
- ポインタ: アプリの操作に使用できる物理オブジェクト。モバイル デバイスの場合、最も一般的なポインタはタッチスクリーンを操作する指です。または、タッチペンを使用して指を置き換えてもかまいません。
大画面では、マウスまたはトラックパッドを使用して、ディスプレイを間接的に操作できます。入力デバイスがポインタとみなされる座標を「ポイント」できる必要があるため、たとえばキーボードはポインタとはみなされません。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 のユーザー補助をご覧ください。
修飾子を使用して任意のコンポーザブルに特定の操作を追加する
任意のコンポーザブルにジェスチャー修飾子を適用すると、コンポーザブルで操作をリッスンできます。たとえば、汎用の Box
は clickable
にしてタップ操作を処理できるようにし、Column
は verticalScroll
を適用して垂直方向のスクロールを処理できます。
さまざまなタイプのジェスチャーを処理するための修飾子が多数あります。
clickable
、combinedClickable
、selectable
、toggleable
、triStateToggleable
の修飾子でタップと押下を処理する。horizontalScroll
、verticalScroll
などの一般的な修飾子scrollable
を使用して、スクロールを処理します。draggable
修飾子とswipeable
修飾子を使用してドラッグを処理します。transformable
修飾子を使用して、パン、回転、ズームなどのマルチタッチ操作を処理します。
原則として、カスタム ジェスチャー処理よりも、すぐに使用できるジェスチャー修飾子が優先されます。この修飾子により、純粋なポインタ イベント処理に機能が追加されています。
たとえば、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
には、以下をリッスンするメソッドが用意されています。
- 押す、タップ、ダブルタップ、長押し:
detectTapGestures
- ドラッグ:
detectHorizontalDragGestures
、detectVerticalDragGestures
、detectDragGestures
、detectDragGesturesAfterLongPress
- 変換:
detectTransformGestures
これらはトップレベル検出機能であるため、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
はポインタのダウンイベントまたはポインタアップ イベントに応答しません。必要なのは、ポインタが境界を出入りするタイミングを知ることだけです。
特定のイベントやサブジェスチャーを待つ
ジェスチャーの一般的な部分を特定するのに役立つメソッドのセットがあります。
awaitFirstDown
でポインタがダウンするまで一時停止するか、waitForUpOrCancellation
ですべてのポインタが上昇するまで待ちます。awaitTouchSlopOrCancellation
とawaitDragOrCancellation
を使用して、低レベルのドラッグ リスナーを作成します。ジェスチャー ハンドラは、ポインタがタッチスロップに達するまで一時停止し、最初のドラッグ イベントが完了するまで一時停止します。単一の軸に沿ってドラッグする場合は、代わりにawaitHorizontalTouchSlopOrCancellation
+awaitHorizontalDragOrCancellation
、またはawaitVerticalTouchSlopOrCancellation
とawaitVerticalDragOrCancellation
を使用します。awaitLongPressOrCancellation
で長押しするまで一時停止します。- ドラッグ イベントを継続的にリッスンするには、
drag
メソッドを使用し、1 つの軸上のドラッグ イベントをリッスンするには、horizontalDrag
またはverticalDrag
を使用します。
マルチタッチ イベントの計算を適用する
ユーザーが複数のポインタを使用してマルチタッチ ジェスチャーを行っている場合、未加工の値に基づいて必要な変換を理解するのは困難です。transformable
修飾子または detectTransformGestures
メソッドでユースケースに対して十分なきめ細かい制御ができない場合は、未加工のイベントをリッスンして計算を適用できます。これらのヘルパー メソッドは、calculateCentroid
、calculateCentroidSize
、calculatePan
、calculateRotation
、calculateZoom
です。
イベントのディスパッチとヒットテスト
すべてのポインタ イベントがすべての pointerInput
修飾子に送信されるわけではありません。イベントのディスパッチは次のように機能します。
- ポインタ イベントはコンポーザブルの階層にディスパッチされます。新しいポインタが最初のポインタ イベントをトリガーすると、システムは「適格」なコンポーザブルのヒットテストを開始します。コンポーザブルは、ポインタ入力処理機能を備えている場合に適格とみなされます。ヒットテストは UI ツリーの上部から下部に向かって流れます。コンポーザブルの境界内でポインタ イベントが発生すると、コンポーザブルは「ヒット」します。このプロセスにより、ヒットテストで陽性となるコンポーザブルのチェーンが生成されます。
- デフォルトでは、ツリーの同じレベルに対象となるコンポーザブルが複数ある場合は、Z-Index が最も高いコンポーザブルのみが「ヒット」します。たとえば、2 つの重複する
Button
コンポーザブルをBox
に追加すると、上部に描画されたもののみがポインタ イベントを受け取ります。理論的には、独自のPointerInputModifierNode
実装を作成し、sharePointerInputWithSiblings
を true に設定することで、この動作をオーバーライドできます。 - 同じポインタの以降のイベントは、同じコンポーザブルのチェーンにディスパッチされ、イベント伝播ロジックに従ってフローされます。このポインタのヒットテストはそれ以上行われません。つまり、チェーン内の各コンポーザブルは、コンポーザブルの境界外で発生した場合でも、そのポインタのすべてのイベントを受け取ります。チェーンにないコンポーザブルは、ポインタが境界内にある場合でもポインタ イベントを受信しません。
マウスまたはタッチペンでのホバー操作によってトリガーされるホバー イベントは、ここで定義するルールの例外です。ヒットしたコンポーザブルにホバーイベントが送信されます。そのため、ユーザーがあるコンポーザブルの境界から次のコンポーザブルにポインタにカーソルを合わせると、その最初のコンポーザブルにイベントを送信するのではなく、新しいコンポーザブルにイベントが送信されます。
イベントの使用
複数のコンポーザブルにジェスチャー ハンドラが割り当てられている場合、これらのハンドラが競合しないようにする必要があります。たとえば、次の UI を見てみましょう。
ユーザーがブックマーク ボタンをタップすると、ボタンの 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 ツリーに変換されます。ここでは、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 の操作について詳しくは、以下のリソースをご覧ください。
あなたへのおすすめ
- 注: JavaScript がオフになっている場合はリンクテキストが表示されます
- Compose のユーザー補助機能
- スクロール
- タップして押し