injecting-touch-gestures について
ドラッグやマルチタッチ、速度を指定したスワイプなどをテストする Android Compose 開発者向けのスキルです。非推奨の `performGesture` ではなく、`performTouchInput` とノード内座標を使います。
主な機能
- 01performTouchInput でタッチイベントを注入
- 02ノード内座標を使用
- 03スワイプ、長押し、ダブルタップを再現
- 04ピンチやマルチタッチ操作を実行
- 05ジェスチャーを分割して途中状態を確認
活用シーン
- 01performGesture を使うテストの移行
- 02ドラッグ可能なコンポーネントやスワイプのテスト
- 03ピンチズームや速度指定スワイプの検証
- 04端末サイズによる座標問題の回避
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ skydoves/ android-testing-skills --skill injecting-touch-gesturesインストール後、Claude Code で/injecting-touch-gesturesを入力して読み込まれたか確認します。
詳細
タッチジェスチャーの注入 — performTouchInput を使い、performGesture は使わない
performTouchInput { … } は、Compose テストで合成タッチイベントを注入するための唯一のエントリーポイントです。座標系がノードローカル(原点はノードの左上)である TouchInjectionScope 内でブロックを実行し、ブロックがキューに追加したすべてのイベントをまとめて、ブロックの終了時に再コンポーズを発生させない単一のバーストとしてフラッシュします。従来の performGesture { … } は @Deprecated("Replaced by performTouchInput") です(Actions.kt:337-355)。新しいコードには絶対に使用しないでください。
このスキルを使用する場合
- テストで、部分的なジェスチャー、マルチタッチ、速度を制御したフリング、ピンチによるズームなど、単一タップより複雑な操作をシミュレートする必要がある場合。
- 開発者がノードを基準とした正確な座標(例:
centerから正確に 100 px ドラッグ)を必要とする場合。 - 指を「押した」まま、ジェスチャーを 2 つの
performTouchInputブロックに分ける必要がある場合。 - 現在
performGesture { }を使用しているテストを移行する必要がある場合。 - テストでハードコードした画面座標を使ってタッチをシミュレートしており、ディスプレイサイズが異なると不安定になる場合。
このスキルを使用しない場合
- ノードを単純にタップする場合 —
../clicking-and-scrolling/SKILL.mdのperformClick()を使ってください。プラットフォームに適しており、より簡潔です。 - ジェスチャーがホバー、右クリック、またはスクロールホイールの場合 —
../injecting-mouse-and-keyboard/SKILL.mdを使ってください。 - フローが「ボタンをタップしてから Snackbar が表示されたことをアサートする」の場合 — タッチ状態を調べるのではなく、
../../assertions/asserting-node-state-and-text/SKILL.mdに従ってアサートしてください。 - テストでフリングアニメーションの処理に困っている場合 —
../../synchronization/testing-animations-deterministically/SKILL.mdに従い、先にクロックを一時停止してください。
前提条件
../../setup/configuring-test-dependencies/SKILL.mdに従って、androidx.compose.ui:ui-test-junit4(またはrunComposeUiTest用のui-test)が設定されていること。- テストがノードを確実に選択できるよう、本番コードで対象ノードに
Modifier.testTag("…")が設定されていること。 - マルチタッチジェスチャー(
pinch、multiTouchSwipe)では、対象のコンポーザブルがModifier.pointerInput { … }を通じてポインター入力を受け付けること。通常はModifier.transformable、Modifier.draggable2D、またはカスタムのdetectTransformGesturesを使用します。
ワークフロー
- レシーバーを取得します。
performTouchInputはSemanticsNodeInteractionの拡張関数です。まずノードを見つけます。
rule.onNodeWithTag(DragSurfaceTag).performTouchInput { /* DSL */ }
-
ノード内の座標を使います。 ブロック内の
(0, 0)はノードの左上です。InjectionScopeのジオメトリヘルパーを使用します(InjectionScope.kt:34-206):center、topLeft、topCenter、topRight、centerLeft、centerRight、bottomLeft、bottomCenter、bottomRight、width、height、さらにright == width - 1fとbottom == height - 1fです(ピクセルのインデックスは 0 から始まります — InjectionScope.kt:96-112)。相対位置にはpercentOffset(.5f, .5f)を使用します。画面座標をハードコードしてはいけません。 -
適切な抽象化レベルを選びます。 多くのケースでは高レベルのヘルパーで対応できます。
| ヘルパー | デフォルト | 動作 |
|---|---|---|
click(position = center) | 中央をタップ | down(); move(); up()(TouchInjectionScope.kt:372) |
longClick(position = center, durationMillis = vc.longPressTimeoutMillis + 100) | 長押しタイムアウトより 100 ms 長い時間 | 押して、保持し、離します(TouchInjectionScope.kt:390) |
doubleClick(position = center, delayMillis = …) | ダブルタップ可能時間の最小値と最大値の中間 | click(); advanceEventTime(delay); click()(TouchInjectionScope.kt:417) |
swipe(start, end, durationMillis = 200) | 200 ms の直線移動 | 2 点間を線形補間します(TouchInjectionScope.kt:445) |
swipeUp / swipeDown / swipeLeft / swipeRight | 中央を通る軸に沿って移動 | 端から端へのスワイプです(TouchInjectionScope.kt:649+) |
swipeWithVelocity(start, end, endVelocity, durationMillis) | 実行可能性に基づいて算出 | 最終速度が一致するように経路を調整します(TouchInjectionScope.kt:617) |
pinch(start0, end0, start1, end1, durationMillis = 400) | 400 ms の 2 本指操作 | 2 本のポインターが同時に直線移動します(TouchInjectionScope.kt:574) |
multiTouchSwipe(curves, durationMillis, keyTimes) | — | ポインター ID ごとに 1 本の曲線を使います(TouchInjectionScope.kt:491) |
-
部分的なジェスチャーには、低レベルのイベントを使います。 すべて
TouchInjectionScope内にあります(TouchInjectionScope.kt)。down(pointerId = 0, position)—positionでポインターを開始します。moveTo(pointerId = 0, position, delayMillis = eventPeriodMillis)— 移動イベントをキューに追加します。moveBy(pointerId = 0, delta, delayMillis = eventPeriodMillis)— 相対位置で指定する形式です。updatePointerTo(pointerId, position)/updatePointerBy(pointerId, delta)— 移動イベントをキューに追加せずにポインターの位置を調整します。複数のポインター更新を 1 回のmove(delayMillis)呼び出しにまとめる場合に便利です。move(delayMillis = eventPeriodMillis)— 保留中のポインター更新をすべてまとめて移動イベントを送信します。up(pointerId = 0)— ポインターを離します。cancel(delayMillis = eventPeriodMillis)—MotionEvent.ACTION_CANCEL相当のイベントを送信します。currentPosition(pointerId = 0): Offset?— ポインターの最後に確認された位置を取得します。
-
ジェスチャーの途中で再コンポジションが必要な場合は、ブロックを分けます。 同じノードに対する
performTouchInputの呼び出し間ではポインターの状態が共有されるため、「down」のままのポインターは押された状態を保ちます。これを利用すると、ジェスチャーが通過するフレームで状態をアサートできます。
rule.onNodeWithTag(Tag).performTouchInput { down(center) }
// recomposition + assertions happen here
rule.onNodeWithTag(Tag).performTouchInput { moveBy(Offset(0f, 100f)); up() }
別のノードで後続の呼び出しを行うと、ポインターの位置はそのノードのローカル座標系に再マッピングされます(TouchInjectionScope.kt:50-55)。そのため、位置は画面全体の座標ではなく、引き続きローカル座標です。
- イベントのバッチ処理を前提にします。 1 つの
performTouchInputブロック内でキューに追加されたイベントは、ブロックが戻るときに 1 つのバッチとして送信されます。ブロック内に再コンポジションが割り込むことはありません(Actions.kt:362-377)。そのため、すべての座標は事前に解決されます。また、1 つのブロック内では、移動するターゲットによってジェスチャーが無効になることもありません。
パターン
パターン:performGesture を performTouchInput に置き換える
// WRONG
@Suppress("DEPRECATION")
rule.onNodeWithTag(Tag).performGesture { swipeUp() }
// WRONG because: performGesture is @Deprecated("Replaced by performTouchInput") (Actions.kt:337).
// The replaceWith metadata in the @Deprecated annotation explicitly suggests performTouchInput.
// RIGHT
rule.onNodeWithTag(Tag).performTouchInput { swipeUp() }
これは必須の移行です。performGesture が存在するのは、バイナリ互換性を保つためだけです。
パターン:ハードコードされた画面座標とノードローカル座標
// WRONG
rule.onNodeWithTag(Tag).performTouchInput {
down(Offset(500f, 800f))
moveTo(Offset(500f, 700f))
up()
}
// WRONG because: (500, 800) only happens to land on the node on certain device profiles.
// On a tablet or in landscape the node sits elsewhere, and on a small phone it may not even
// be inside the visible bounds — the test becomes device-shaped.
// RIGHT
rule.onNodeWithTag(Tag).performTouchInput {
down(center)
moveBy(Offset(0f, -100f))
up()
}
center、bottomCenter、topRight などは、ノードの visibleSize から導出されます(InjectionScope.kt:49-50、:118-185)。このテストは画面サイズが変わっても変更せずに実行できます。
パターン:DraggableTest にある実際のスワイプ
DraggableTest.kt:114-146 にある、標準的な水平方向のドラッグパターンです。
@Test
fun draggable_horizontalDrag() {
var total = 0f
setDraggableContent { Modifier.draggable(Orientation.Horizontal) { total += it } }
rule.onNodeWithTag(draggableBoxTag).performTouchInput {
this.swipe(
start = this.center,
end = Offset(this.center.x + 100f, this.center.y),
durationMillis = 100,
)
}
val lastTotal = rule.runOnIdle {
assertThat(total).isGreaterThan(0)
total
}
}
参考にするとよい点:this.center でレシーバーを明示します(ブロックをネストする場合に便利です)。durationMillis = 100 によりテストを高速に保てます。アサーションは runOnIdle 内で実行します(skydoves のホットテイク #5)。
パターン:ピンチズーム
TransformableTest.kt:128-141 より:
rule.onNodeWithTag(TEST_TAG).performTouchInput {
val leftStartX = center.x - 10
val leftEndX = visibleSize.toSize().width * EDGE_FUZZ_FACTOR
val rightStartX = center.x + 10
val rightEndX = visibleSize.toSize().width * (1 - EDGE_FUZZ_FACTOR)
pinch(
Offset(leftStartX, center.y),
Offset(leftEndX, center.y),
Offset(rightStartX, center.y),
Offset(rightEndX, center.y),
)
}
pinch(start0, end0, start1, end1) は、durationMillis = 400 の間、2つのポインターを並行して補間します(TouchInjectionScope.kt:574-589)。右端や下端ぴったりの位置に着地しないよう、0f ではなく EDGE_FUZZ_FACTOR(例:0.05f)を使用してください。right == width - 1f であることを参照してください(InjectionScope.kt:96-102)。
パターン:ジェスチャー途中のアサーションのために分割する
@Test fun longPress_thenAssert_thenRelease() {
rule.setContent { /* … */ }
// Hold finger down at center, do not release.
rule.onNodeWithTag(Tag).performTouchInput { down(center) }
// Wait for the long-press timeout to elapse via the test clock.
rule.mainClock.advanceTimeBy(viewConfiguration.longPressTimeoutMillis + 100)
rule.onNodeWithTag(Tag).assertHasIndicationOfLongPress() // your assertion
// Release in a second block; the pointer is still "down" between blocks.
rule.onNodeWithTag(Tag).performTouchInput { up() }
}
ポインターの状態はブロック間で保持され、その間にリコンポーザーが実行されます。CombinedClickableTest では、長押し時の触覚フィードバックの経路をこの方法でテストしています(例:CombinedClickableTest.kt:446-471)。
パターン:速度を指定したフリング
rule.onNodeWithTag(Tag).performTouchInput {
swipeWithVelocity(
start = bottomCenter,
end = topCenter,
endVelocity = 4_000f, // px/second
durationMillis = 200,
)
}
swipeWithVelocity は、最終速度が目標値の約0.1以内になるように経路を調整します(TouchInjectionScope.kt:617-637)。入力イベントを少なくとも3回発生させるため、十分な長さの時間を指定する必要があります(最小約40 ms)。入力が実現不可能な場合、API は修正方法の提案を含む IllegalArgumentException をスローします。
必須ルール
- 必須:
performTouchInput { … }を使用します。禁止:performGesture { … }を使用しないでください。これは@Deprecatedです(Actions.kt:337-355)。このルールは絶対です。 - 必須: 座標は
center、topLeft、bottomRight、percentOffset(...)などを使用し、ノードローカル座標系で表現します。禁止: 画面のピクセル座標をハードコードしないでください。テスト結果はデバイスによって変わります。 - 必須: ジェスチャー後の状態アサーションは、
rule.runOnIdle { … }を通して実行します(skydoves のホットテイク #5)。テストスレッドでstateを読み取ると、ジェスチャーを処理したリコンポーザーとの間で競合が発生します。 - 必須: テストがジェスチャーの継続時間(長押し、ダブルタップの時間枠)に依存する場合、分割した
performTouchInputブロック間でrule.mainClock.advanceTimeBy(...)を使ってテストクロックを進めます。禁止:Thread.sleepを使用しないでください。../../synchronization/synchronizing-with-idle/SKILL.mdを参照してください。 - 推奨: すべてのドラッグを
centerから開始し、相対デルタにはmoveByを使用します。絶対パス(moveTo(topLeft))も動作しますが、コードの可読性が下がり、RTL レイアウトで正しく動作しません。 - 推奨: 単調な直線運動には
swipe、フリングの動作をテストする場合に限りswipeWithVelocity、2本を超えるポインターまたは非線形カーブにはmultiTouchSwipeを選択します。
検証
- テストソースセットに
performGestureが含まれていないこと:git grep -n 'performGesture' src/androidTest src/testの実行結果が空であること。 -
performTouchInputブロック内またはブロック間にThread.sleepがないこと(skydoves のホットテイク #7)。 - テストにハードコードされた
Offset(<screen-pixel>, <screen-pixel>)がないこと。すべての位置がcenter、top*、bottom*、centerLeftなどを通して解決されること。 - タッチジェスチャー後のアサーションが
rule.runOnIdle { … }内で実行されること。 - 変更対象のテストについて、
./gradlew :app:connectedDebugAndroidTest(または:app:testDebugUnitTest)が成功すること。
参考資料
- Compose のテストの概要:https://developer.android.com/develop/ui/compose/testing
- Compose のテストのチートシート:https://developer.android.com/develop/ui/compose/testing-cheatsheet
compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Actions.kt—performTouchInput(Actions.kt:399)、performGestureの非推奨化(Actions.kt:337-355)。compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/TouchInjectionScope.kt— DSL の概要(down、moveTo、moveBy、up、cancel、click、longClick、doubleClick、swipe、swipeUp/Down/Left/Right、swipeWithVelocity、pinch、multiTouchSwipe)。compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/InjectionScope.kt— ジオメトリヘルパー(center、topLeft、topCenter、bottomRight、width、height、right、bottom、percentOffset、visibleSize、advanceEventTime)。compose/foundation/foundation/src/androidDeviceTest/.../DraggableTest.kt— 標準的なswipe(start, end, durationMillis)(DraggableTest.kt:114-146)。compose/foundation/foundation/src/androidDeviceTest/.../TransformableTest.kt— 4点のジェスチャーpinch(...)(TransformableTest.kt:128-141)。compose/foundation/foundation/src/androidDeviceTest/.../CombinedClickableTest.kt—performTouchInput { longClick() }、触覚フィードバックのテストにおけるdown(center)/up()の分割ブロック。compose/foundation/foundation/src/androidDeviceTest/.../ScrollableAreaTest.kt— 低レベルのdown(); moveBy()フロー。- skydoves — compose-performance-skills:https://github.com/skydoves/compose-performance-skills