mcpField
I
injecting-touch-gestures

injecting-touch-gestures

github.com/skydovesTesting & quality33963 installsUpdated 22w ago
Repository

About injecting-touch-gestures

A skill for Android Compose developers writing tests that simulate touch gestures, including drags, multi-touch, and velocity-controlled swipes. It centers on `performTouchInput` and node-local coordinates instead of deprecated `performGesture`.

Key features

  1. 01Inject touch events with performTouchInput
  2. 02Use node-local coordinates
  3. 03Simulate swipes, long presses, and double taps
  4. 04Perform pinch and multi-touch gestures
  5. 05Split gestures to inspect intermediate state

Use cases

  1. 01Migrate tests from performGesture
  2. 02Test draggable components and swipe interactions
  3. 03Verify pinch zoom or velocity-controlled swipes
  4. 04Avoid coordinate issues across device sizes

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/skydoves/android-testing-skills --skill injecting-touch-gestures

After installing, type/injecting-touch-gesturesin Claude Code to confirm it loaded.

Details

From the project README

Injecting Touch Gestures — performTouchInput, Not performGesture

performTouchInput { … } is the single entry point for synthetic touch events in Compose tests. It runs a block in a TouchInjectionScope whose coordinate system is node-local (origin at the node's top-left), batches every event the block enqueues, and flushes them as a single non-recomposing burst when the block returns. The legacy performGesture { … } is @Deprecated("Replaced by performTouchInput") (Actions.kt:337-355) — MUST NOT appear in any new code.

When to use this skill

  • The test must simulate a partial gesture, multi-touch, fling with controlled velocity, or pinch-to-zoom — anything richer than a single tap.
  • The developer needs precise coordinates relative to a node (e.g. drag by exactly 100 px from center).
  • A gesture must be split across two performTouchInput blocks while a finger is still "down".
  • A test currently uses performGesture { } and must be migrated.
  • A test simulates touch with hardcoded screen coordinates and is flaky on different display sizes.

When NOT to use this skill

  • A simple tap on a node — use performClick() from ../clicking-and-scrolling/SKILL.md. It is platform-appropriate and shorter.
  • The gesture is a hover, right-click, or scroll wheel — use ../injecting-mouse-and-keyboard/SKILL.md.
  • The flow is "tap a button, then assert a Snackbar appears" — assert via ../../assertions/asserting-node-state-and-text/SKILL.md, not by inspecting touch state.
  • The test is fighting a fling animation — pause the clock first per ../../synchronization/testing-animations-deterministically/SKILL.md.

Prerequisites

  • androidx.compose.ui:ui-test-junit4 (or ui-test for runComposeUiTest) configured per ../../setup/configuring-test-dependencies/SKILL.md.
  • The receiver node carries Modifier.testTag("…") from production code so the test selects it deterministically.
  • For multi-touch gestures (pinch, multiTouchSwipe), the target composable accepts pointer input via Modifier.pointerInput { … } — typically Modifier.transformable, Modifier.draggable2D, or a custom detectTransformGestures.

Workflow

  1. Acquire the receiver. performTouchInput is an extension on SemanticsNodeInteraction. Find the node first.
rule.onNodeWithTag(DragSurfaceTag).performTouchInput { /* DSL */ }
  1. Use node-local coordinates. Inside the block, (0, 0) is the node's top-left. Reach for the geometry helpers from InjectionScope (InjectionScope.kt:34-206): center, topLeft, topCenter, topRight, centerLeft, centerRight, bottomLeft, bottomCenter, bottomRight, width, height, plus right == width - 1f and bottom == height - 1f (pixels are 0-based — InjectionScope.kt:96-112). Use percentOffset(.5f, .5f) for relative positioning. MUST NOT hardcode screen coordinates.

  2. Pick the right level of abstraction. High-level helpers cover most cases:

HelperDefaultWhat it does
click(position = center)tap at centerdown(); move(); up() (TouchInjectionScope.kt:372)
longClick(position = center, durationMillis = vc.longPressTimeoutMillis + 100)100 ms past the long-press timeoutpress, hold, release (TouchInjectionScope.kt:390)
doubleClick(position = center, delayMillis = …)midway between min/max double-tap windowclick(); advanceEventTime(delay); click() (TouchInjectionScope.kt:417)
swipe(start, end, durationMillis = 200)200 ms linearlinear interpolation between two points (TouchInjectionScope.kt:445)
swipeUp / swipeDown / swipeLeft / swipeRightalong axis through centeredge-to-edge sweeps (TouchInjectionScope.kt:649+)
swipeWithVelocity(start, end, endVelocity, durationMillis)computed for feasibilityshapes the path so the final velocity matches (TouchInjectionScope.kt:617)
pinch(start0, end0, start1, end1, durationMillis = 400)400 ms two-pointertwo simultaneous linear paths (TouchInjectionScope.kt:574)
multiTouchSwipe(curves, durationMillis, keyTimes)—one curve per pointer id (TouchInjectionScope.kt:491)
  1. Drop to low-level events for partial gestures. All in TouchInjectionScope (TouchInjectionScope.kt):

    • down(pointerId = 0, position) — start a pointer at position.
    • moveTo(pointerId = 0, position, delayMillis = eventPeriodMillis) — enqueue a move event.
    • moveBy(pointerId = 0, delta, delayMillis = eventPeriodMillis) — relative form.
    • updatePointerTo(pointerId, position) / updatePointerBy(pointerId, delta) — adjust the pointer position without enqueueing a move; useful when batching multiple pointer updates into a single move(delayMillis) call.
    • move(delayMillis = eventPeriodMillis) — flush a move event combining every pending pointer update.
    • up(pointerId = 0) — release.
    • cancel(delayMillis = eventPeriodMillis) — emit a MotionEvent.ACTION_CANCEL-equivalent.
    • currentPosition(pointerId = 0): Offset? — query the last-known position of a pointer.
  2. Split gestures across blocks when recomposition matters mid-gesture. Pointer state is shared across performTouchInput invocations on the same node — a pointer left "down" stays down. Use this to assert state at a frame the gesture passes through.

rule.onNodeWithTag(Tag).performTouchInput { down(center) }
// recomposition + assertions happen here
rule.onNodeWithTag(Tag).performTouchInput { moveBy(Offset(0f, 100f)); up() }

Subsequent invocations on a different node remap pointer positions into that node's local space (TouchInjectionScope.kt:50-55), so positions are still local, not screen-global.

  1. Trust event batching. Every event enqueued inside one performTouchInput block is sent as one batch when the block returns; recomposition cannot interleave inside a block (Actions.kt:362-377). That is why all coordinates are resolved up front — and why a moving target does not invalidate the gesture inside one block.

Patterns

Pattern: Replace performGesture with 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() }

This is a non-negotiable migration. performGesture exists only for binary compatibility.

Pattern: Hardcoded screen coordinates vs node-local
// 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, etc. are derived from visibleSize of the node (InjectionScope.kt:49-50, :118-185). The test runs unchanged across screen sizes.

Pattern: Real swipe from DraggableTest

The canonical horizontal-drag pattern from 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
    }
}

Notes worth copying: this.center makes the receiver explicit (handy when nesting blocks); durationMillis = 100 keeps the test fast; the assertion runs inside runOnIdle (skydoves hot take #5).

Pattern: Pinch-to-zoom

From 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) interpolates two pointers in parallel for durationMillis = 400 (TouchInjectionScope.kt:574-589). Use EDGE_FUZZ_FACTOR (e.g. 0.05f) instead of 0f to avoid landing exactly on the right/bottom edge — right == width - 1f, see InjectionScope.kt:96-102.

Pattern: Split gesture for mid-gesture assertion
@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() }
}

Pointer state survives across blocks; the recomposer runs between them. This is how CombinedClickableTest exercises haptic-feedback-on-long-press paths (e.g. CombinedClickableTest.kt:446-471).

Pattern: Velocity-controlled fling
rule.onNodeWithTag(Tag).performTouchInput {
    swipeWithVelocity(
        start = bottomCenter,
        end = topCenter,
        endVelocity = 4_000f,         // px/second
        durationMillis = 200,
    )
}

swipeWithVelocity shapes the path so the final velocity is within ~0.1 of the target (TouchInjectionScope.kt:617-637). The duration must be long enough for at least 3 input events (~40 ms minimum); the API throws IllegalArgumentException with a fix suggestion if the input is infeasible.

Mandatory rules

  • MUST use performTouchInput { … }. MUST NOT use performGesture { … } — it is @Deprecated (Actions.kt:337-355). This is non-negotiable.
  • MUST express coordinates in the node-local system using center, topLeft, bottomRight, percentOffset(...), etc. MUST NOT hardcode screen pixel coordinates — the test will be device-shaped.
  • MUST route post-gesture state assertions through rule.runOnIdle { … } (skydoves hot take #5). Reading state on the test thread races with the recomposer that processed the gesture.
  • MUST advance the test clock with rule.mainClock.advanceTimeBy(...) between split performTouchInput blocks when the test depends on a duration (long-press, double-tap window). MUST NOT use Thread.sleep — see ../../synchronization/synchronizing-with-idle/SKILL.md.
  • PREFERRED: start every drag from center and use moveBy with relative deltas. Absolute paths (moveTo(topLeft)) work but read worse and break under RTL layouts.
  • PREFERRED: pick swipe for monotonic linear motion, swipeWithVelocity only when fling behaviour is under test, and multiTouchSwipe only for >2 pointers or non-linear curves.

Verification

  • No occurrence of performGesture in the test source set: git grep -n 'performGesture' src/androidTest src/test returns nothing.
  • No Thread.sleep inside or between performTouchInput blocks (skydoves hot take #7).
  • No hardcoded Offset(<screen-pixel>, <screen-pixel>) in the test — every position resolves through center, top*, bottom*, centerLeft, etc.
  • Assertions following a touch gesture run inside rule.runOnIdle { … }.
  • ./gradlew :app:connectedDebugAndroidTest (or :app:testDebugUnitTest) passes for the test under change.

References

  • Compose testing overview: https://developer.android.com/develop/ui/compose/testing
  • Compose testing cheat sheet: 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 deprecation (Actions.kt:337-355).
  • compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/TouchInjectionScope.kt — DSL surface (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 — geometry helpers (center, topLeft, topCenter, bottomRight, width, height, right, bottom, percentOffset, visibleSize, advanceEventTime).
  • compose/foundation/foundation/src/androidDeviceTest/.../DraggableTest.kt — canonical swipe(start, end, durationMillis) (DraggableTest.kt:114-146).
  • compose/foundation/foundation/src/androidDeviceTest/.../TransformableTest.kt — pinch(...) four-point gesture (TransformableTest.kt:128-141).
  • compose/foundation/foundation/src/androidDeviceTest/.../CombinedClickableTest.kt — performTouchInput { longClick() }, split down(center) / up() blocks for haptic tests.
  • compose/foundation/foundation/src/androidDeviceTest/.../ScrollableAreaTest.kt — low-level down(); moveBy() flow.
  • skydoves — compose-performance-skills: https://github.com/skydoves/compose-performance-skills

Comments

Markdown supported