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
- Acquire the receiver.
performTouchInput is an extension on SemanticsNodeInteraction. Find the node first.
rule.onNodeWithTag(DragSurfaceTag).performTouchInput { /* DSL */ }
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.
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) |
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.
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.
- 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
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
1---2name: injecting-touch-gestures3description: 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.4license: Apache-2.0. See LICENSE for complete terms.5---67# Injecting Touch Gestures — performTouchInput, Not performGesture89`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.1011## When to use this skill1213- The test must simulate a partial gesture, multi-touch, fling with controlled velocity, or pinch-to-zoom — anything richer than a single tap.14- The developer needs precise coordinates relative to a node (e.g. drag by exactly 100 px from `center`).15- A gesture must be split across two `performTouchInput` blocks while a finger is still "down".16- A test currently uses `performGesture { }` and must be migrated.17- A test simulates touch with hardcoded screen coordinates and is flaky on different display sizes.1819## When NOT to use this skill2021- A simple tap on a node — use `performClick()` from `../clicking-and-scrolling/SKILL.md`. It is platform-appropriate and shorter.22- The gesture is a hover, right-click, or scroll wheel — use `../injecting-mouse-and-keyboard/SKILL.md`.23- 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.24- The test is fighting a fling animation — pause the clock first per `../../synchronization/testing-animations-deterministically/SKILL.md`.2526## Prerequisites2728- `androidx.compose.ui:ui-test-junit4` (or `ui-test` for `runComposeUiTest`) configured per `../../setup/configuring-test-dependencies/SKILL.md`.29- The receiver node carries `Modifier.testTag("…")` from production code so the test selects it deterministically.30- For multi-touch gestures (`pinch`, `multiTouchSwipe`), the target composable accepts pointer input via `Modifier.pointerInput { … }` — typically `Modifier.transformable`, `Modifier.draggable2D`, or a custom `detectTransformGestures`.3132## Workflow33341. **Acquire the receiver.** `performTouchInput` is an extension on `SemanticsNodeInteraction`. Find the node first.3536```kotlin37rule.onNodeWithTag(DragSurfaceTag).performTouchInput { /* DSL */ }38```39402. **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.41423. **Pick the right level of abstraction.** High-level helpers cover most cases:4344| Helper | Default | What it does |45|---|---|---|46| `click(position = center)` | tap at center | `down(); move(); up()` (TouchInjectionScope.kt:372) |47| `longClick(position = center, durationMillis = vc.longPressTimeoutMillis + 100)` | 100 ms past the long-press timeout | press, hold, release (TouchInjectionScope.kt:390) |48| `doubleClick(position = center, delayMillis = …)` | midway between min/max double-tap window | `click(); advanceEventTime(delay); click()` (TouchInjectionScope.kt:417) |49| `swipe(start, end, durationMillis = 200)` | 200 ms linear | linear interpolation between two points (TouchInjectionScope.kt:445) |50| `swipeUp / swipeDown / swipeLeft / swipeRight` | along axis through center | edge-to-edge sweeps (TouchInjectionScope.kt:649+) |51| `swipeWithVelocity(start, end, endVelocity, durationMillis)` | computed for feasibility | shapes the path so the final velocity matches (TouchInjectionScope.kt:617) |52| `pinch(start0, end0, start1, end1, durationMillis = 400)` | 400 ms two-pointer | two simultaneous linear paths (TouchInjectionScope.kt:574) |53| `multiTouchSwipe(curves, durationMillis, keyTimes)` | — | one curve per pointer id (TouchInjectionScope.kt:491) |54554. **Drop to low-level events for partial gestures.** All in `TouchInjectionScope` (TouchInjectionScope.kt):56 - `down(pointerId = 0, position)` — start a pointer at `position`.57 - `moveTo(pointerId = 0, position, delayMillis = eventPeriodMillis)` — enqueue a move event.58 - `moveBy(pointerId = 0, delta, delayMillis = eventPeriodMillis)` — relative form.59 - `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.60 - `move(delayMillis = eventPeriodMillis)` — flush a move event combining every pending pointer update.61 - `up(pointerId = 0)` — release.62 - `cancel(delayMillis = eventPeriodMillis)` — emit a `MotionEvent.ACTION_CANCEL`-equivalent.63 - `currentPosition(pointerId = 0): Offset?` — query the last-known position of a pointer.64655. **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.6667```kotlin68rule.onNodeWithTag(Tag).performTouchInput { down(center) }69// recomposition + assertions happen here70rule.onNodeWithTag(Tag).performTouchInput { moveBy(Offset(0f, 100f)); up() }71```7273Subsequent 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.74756. **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.7677## Patterns7879### Pattern: Replace performGesture with performTouchInput8081```kotlin82// WRONG83@Suppress("DEPRECATION")84rule.onNodeWithTag(Tag).performGesture { swipeUp() }85// WRONG because: performGesture is @Deprecated("Replaced by performTouchInput") (Actions.kt:337).86// The replaceWith metadata in the @Deprecated annotation explicitly suggests performTouchInput.87```8889```kotlin90// RIGHT91rule.onNodeWithTag(Tag).performTouchInput { swipeUp() }92```9394This is a non-negotiable migration. `performGesture` exists only for binary compatibility.9596### Pattern: Hardcoded screen coordinates vs node-local9798```kotlin99// WRONG100rule.onNodeWithTag(Tag).performTouchInput {101 down(Offset(500f, 800f))102 moveTo(Offset(500f, 700f))103 up()104}105// WRONG because: (500, 800) only happens to land on the node on certain device profiles.106// On a tablet or in landscape the node sits elsewhere, and on a small phone it may not even107// be inside the visible bounds — the test becomes device-shaped.108```109110```kotlin111// RIGHT112rule.onNodeWithTag(Tag).performTouchInput {113 down(center)114 moveBy(Offset(0f, -100f))115 up()116}117```118119`center`, `bottomCenter`, `topRight`, etc. are derived from `visibleSize` of the node (InjectionScope.kt:49-50, :118-185). The test runs unchanged across screen sizes.120121### Pattern: Real swipe from DraggableTest122123The canonical horizontal-drag pattern from `DraggableTest.kt:114-146`:124125```kotlin126@Test127fun draggable_horizontalDrag() {128 var total = 0f129 setDraggableContent { Modifier.draggable(Orientation.Horizontal) { total += it } }130 rule.onNodeWithTag(draggableBoxTag).performTouchInput {131 this.swipe(132 start = this.center,133 end = Offset(this.center.x + 100f, this.center.y),134 durationMillis = 100,135 )136 }137 val lastTotal = rule.runOnIdle {138 assertThat(total).isGreaterThan(0)139 total140 }141}142```143144Notes 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).145146### Pattern: Pinch-to-zoom147148From `TransformableTest.kt:128-141`:149150```kotlin151rule.onNodeWithTag(TEST_TAG).performTouchInput {152 val leftStartX = center.x - 10153 val leftEndX = visibleSize.toSize().width * EDGE_FUZZ_FACTOR154 val rightStartX = center.x + 10155 val rightEndX = visibleSize.toSize().width * (1 - EDGE_FUZZ_FACTOR)156157 pinch(158 Offset(leftStartX, center.y),159 Offset(leftEndX, center.y),160 Offset(rightStartX, center.y),161 Offset(rightEndX, center.y),162 )163}164```165166`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.167168### Pattern: Split gesture for mid-gesture assertion169170```kotlin171@Test fun longPress_thenAssert_thenRelease() {172 rule.setContent { /* … */ }173174 // Hold finger down at center, do not release.175 rule.onNodeWithTag(Tag).performTouchInput { down(center) }176177 // Wait for the long-press timeout to elapse via the test clock.178 rule.mainClock.advanceTimeBy(viewConfiguration.longPressTimeoutMillis + 100)179 rule.onNodeWithTag(Tag).assertHasIndicationOfLongPress() // your assertion180181 // Release in a second block; the pointer is still "down" between blocks.182 rule.onNodeWithTag(Tag).performTouchInput { up() }183}184```185186Pointer 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`).187188### Pattern: Velocity-controlled fling189190```kotlin191rule.onNodeWithTag(Tag).performTouchInput {192 swipeWithVelocity(193 start = bottomCenter,194 end = topCenter,195 endVelocity = 4_000f, // px/second196 durationMillis = 200,197 )198}199```200201`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.202203## Mandatory rules204205- **MUST** use `performTouchInput { … }`. **MUST NOT** use `performGesture { … }` — it is `@Deprecated` (Actions.kt:337-355). This is non-negotiable.206- **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.207- **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.208- **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`.209- **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.210- **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.211212## Verification213214- [ ] No occurrence of `performGesture` in the test source set: `git grep -n 'performGesture' src/androidTest src/test` returns nothing.215- [ ] No `Thread.sleep` inside or between `performTouchInput` blocks (skydoves hot take #7).216- [ ] No hardcoded `Offset(<screen-pixel>, <screen-pixel>)` in the test — every position resolves through `center`, `top*`, `bottom*`, `centerLeft`, etc.217- [ ] Assertions following a touch gesture run inside `rule.runOnIdle { … }`.218- [ ] `./gradlew :app:connectedDebugAndroidTest` (or `:app:testDebugUnitTest`) passes for the test under change.219220## References221222- Compose testing overview: https://developer.android.com/develop/ui/compose/testing223- Compose testing cheat sheet: https://developer.android.com/develop/ui/compose/testing-cheatsheet224- `compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Actions.kt` — `performTouchInput` (Actions.kt:399), `performGesture` deprecation (Actions.kt:337-355).225- `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`).226- `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`).227- `compose/foundation/foundation/src/androidDeviceTest/.../DraggableTest.kt` — canonical `swipe(start, end, durationMillis)` (DraggableTest.kt:114-146).228- `compose/foundation/foundation/src/androidDeviceTest/.../TransformableTest.kt` — `pinch(...)` four-point gesture (TransformableTest.kt:128-141).229- `compose/foundation/foundation/src/androidDeviceTest/.../CombinedClickableTest.kt` — `performTouchInput { longClick() }`, split `down(center)` / `up()` blocks for haptic tests.230- `compose/foundation/foundation/src/androidDeviceTest/.../ScrollableAreaTest.kt` — low-level `down(); moveBy()` flow.231- skydoves — compose-performance-skills: https://github.com/skydoves/compose-performance-skills