mcpField
I
injecting-touch-gestures

injecting-touch-gestures

github.com/skydovesテスト・品質33963 回インストール22 週間前に更新
リポジトリ

injecting-touch-gestures について

ドラッグやマルチタッチ、速度を指定したスワイプなどをテストする Android Compose 開発者向けのスキルです。非推奨の `performGesture` ではなく、`performTouchInput` とノード内座標を使います。

主な機能

  1. 01performTouchInput でタッチイベントを注入
  2. 02ノード内座標を使用
  3. 03スワイプ、長押し、ダブルタップを再現
  4. 04ピンチやマルチタッチ操作を実行
  5. 05ジェスチャーを分割して途中状態を確認

活用シーン

  1. 01performGesture を使うテストの移行
  2. 02ドラッグ可能なコンポーネントやスワイプのテスト
  3. 03ピンチズームや速度指定スワイプの検証
  4. 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 を使用します。

ワークフロー

  1. レシーバーを取得します。 performTouchInput は SemanticsNodeInteraction の拡張関数です。まずノードを見つけます。
rule.onNodeWithTag(DragSurfaceTag).performTouchInput { /* DSL */ }
  1. ノード内の座標を使います。 ブロック内の (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) を使用します。画面座標をハードコードしてはいけません。

  2. 適切な抽象化レベルを選びます。 多くのケースでは高レベルのヘルパーで対応できます。

ヘルパーデフォルト動作
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)
  1. 部分的なジェスチャーには、低レベルのイベントを使います。 すべて 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? — ポインターの最後に確認された位置を取得します。
  2. ジェスチャーの途中で再コンポジションが必要な場合は、ブロックを分けます。 同じノードに対する 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. イベントのバッチ処理を前提にします。 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

コメント