Skip to content
Skillv1.0.0

injecting-touch-gestures

Use this skill to drive Jetpack Compose UI with synthetic touch events through performTouchInput and the TouchInjectionScope DSL — click, longClick, doubleClick, swipe, swipeUp / swipeDown / swipeLeft

by citytexi(0) 0 installs
Free
Sign in to install

Free account. Installing gives you the manifest plus copy-paste snippets.

See reviews

About

Imported from citytexi/team-yg-pesonal-agent (.claude/skills/injecting-touch-gestures/SKILL.md). Install upstream with npx skills add citytexi/team-yg-pesonal-agent --skill injecting-touch-gestures. Copyright stays with the author (Apache-2.0. See LICENSE for complete ter).

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:

Helper Default What it does
click(position = center) tap at center down(); move(); up() (TouchInjectionScope.kt:372)
longClick(position = center, durationMillis = vc.longPressTimeoutMillis + 100) 100 ms past the long-press timeout press, hold, release (TouchInjectionScope.kt:390)
doubleClick(position = center, delayMillis = …) midway between min/max double-tap window click(); advanceEventTime(delay); click() (TouchInjectionScope.kt:417)
swipe(start, end, durationMillis = 200) 200 ms linear linear interpolation between two points (TouchInjectionScope.kt:445)
swipeUp / swipeDown / swipeLeft / swipeRight along axis through center edge-to-edge sweeps (TouchInjectionScope.kt:649+)
swipeWithVelocity(start, end, endVelocity, durationMillis) computed for feasibility shapes the path so the final velocity matches (TouchInjectionScope.kt:617)
pinch(start0, end0, start1, end1, durationMillis = 400) 400 ms two-pointer two 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.ktperformTouchInput (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.ktpinch(...) four-point gesture (TransformableTest.kt:128-141).
  • compose/foundation/foundation/src/androidDeviceTest/.../CombinedClickableTest.ktperformTouchInput { 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

Use it

Copy one of these into your project. Installing also returns the manifest and these snippets.

yaml
targets:
  - https://api.opensmartroute.ai/api/v1/registry/citytexi-team-yg-pesonal-agent-injecting-touch-gestures/manifest   # or paste the manifest below

Manifest

An Open Capability Manifest: the router reads it to know what this does, what it costs and when to pick it.

citytexi-team-yg-pesonal-agent-injecting-touch-gestures.ocm.jsonjson
{
  "ocm": "1",
  "id": "citytexi-team-yg-pesonal-agent-injecting-touch-gestures",
  "kind": "skill",
  "name": "injecting-touch-gestures",
  "description": "Use this skill to drive Jetpack Compose UI with synthetic touch events through performTouchInput and the TouchInjectionScope DSL — click, longClick, doubleClick, swipe, swipeUp / swipeDown / swipeLeft / swipeRight, swipeWithVelocity, pinch, multiTouchSwipe, plus the low-level down / moveTo / moveBy / move / up / cancel primitives. Covers node-local coordinates, the geometry helpers (center, topLeft, bottomRight, percentOffset), event batching, splitting a gesture across multiple performTouchInput blocks, and why performGesture must not be used. Use when the developer asks \"how do I simulate a swipe\", \"drag a node by 100 pixels\", \"long-press in a Compose test\", \"pinch to zoom\", \"test a fling with velocity\", or reports \"performGesture is deprecated\", a Thread.sleep mid-gesture, or a flaky drag test using hardcoded screen coordinates.",
  "publisher": "citytexi",
  "version": "1.0.0",
  "capabilities": {
    "domains": [
      "math",
      "customer_support"
    ],
    "tags": [
      "skill-md",
      "jetpack-compose",
      "ui-testing",
      "performtouchinput",
      "touch-injection-scope",
      "swipe",
      "pinch",
      "longclick",
      "swipe-with-velocity",
      "performgesture-deprecated"
    ],
    "languages": [
      "en"
    ]
  },
  "quality_prior": 0.6,
  "examples": [
    "Use this skill to drive Jetpack Compose UI with synthetic touch events through performTouchInput and the TouchInjectionScope DSL — click, longClick, doubleClick, swipe, swipeUp / swipeDown / swipeLeft / swipeRight, swipeWithVelocity, pinch, multiTouchSwipe, plus the low-level down / moveTo / moveBy / move / up / cancel primitives. Covers node-local coordinates, the geometry helpers (center, topLeft, bottomRight, percentOffset), event batching, splitting a gesture across multiple performTouchInput blocks, and why performGesture must not be used. Use when the developer asks \"how do I simulate a swipe\", \"drag a node by 100 pixels\", \"long-press in a Compose test\", \"pinch to zoom\", \"test a fling with velocity\", or reports \"performGesture is deprecated\", a Thread.sleep mid-gesture, or a flaky drag test using hardcoded screen coordinates."
  ],
  "primary": false,
  "metadata": {
    "source": {
      "provider": "github",
      "repository": "https://github.com/citytexi/team-yg-pesonal-agent",
      "path": ".claude/skills/injecting-touch-gestures/SKILL.md",
      "ref": "54ea7bfe6170540eb8f43b5b8bcceb185b73d25c",
      "url": "https://github.com/citytexi/team-yg-pesonal-agent/blob/54ea7bfe6170540eb8f43b5b8bcceb185b73d25c/.claude/skills/injecting-touch-gestures/SKILL.md",
      "key": "citytexi/team-yg-pesonal-agent/.claude/skills/injecting-touch-gestures/SKILL.md"
    },
    "license": "Apache-2.0. See LICENSE for complete ter"
  },
  "instructions": "# Injecting Touch Gestures — performTouchInput, Not performGesture\n\n`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.\n\n## When to use this skill\n\n- The test must simulate a partial",
  "cost": {
    "context_tokens": 3429
  }
}

Fetch it by URL: GET /api/v1/registry/citytexi-team-yg-pesonal-agent-injecting-touch-gestures/manifest?version=1.0.0

Reviews

Star ratings from people who tried it. One review per account; edit yours any time.

No reviews yet. Install it, try it, and be the first to rate it.