Skip to content

Composable building blocks for continuous press → drag → hold → drop gestures #1

Description

@comp615

Summary

The library has full atomic gestures (click, longClick, swipe, draw, pinch) but no composable building blocks for "press → move → hold → move → release" drag-and-drop flows. The natural primitives for this are missing — and there's no API-supported way to model one continuous pointer stream that includes a long-press, drag, dwell, and drop.

Motivating use case

A real-world cross-page drag-and-drop gesture in a HorizontalPager:

longPress(startTile) → drag-to-edge → dwell (~5s, pager auto-pages) → drag-to-drop on new page → settle → release

The downstream gesture detectors (long-press recognizer, drag state machine, per-event snap detection) all assume the gesture is one continuous pointer stream. Any finger lift between phases breaks the chain — long-press recognition resets, drag state clears, etc.

What's missing

click and longClick exist as full press-and-release atoms, but no "press-without-release" half-gestures:

Production action Available today
Tap click
Long-press + release longClick
Swipe swipe
Long-press, then drag
Drag, hold, continue dragging
Drag, settle, release
Multi-touch hold down + delay + up
Multi-touch with motion between presses

What I found trying to model this with 0.1.0 (and verified against main / 0.2.0-SNAPSHOT)

  • draw(path, duration) distributes time strictly by arc length × EaseInOutSine → a zero-length "hold" point gets zero time and is explicitly skipped at RealTouchRobotGestureScope.kt lines 192–196.
  • down() then draw() hits require(ongoingGesture == null) — hard crash at line 163.
  • down + delay + up is the official hold idiom, but it lifts the finger and ends the gesture.
  • A multi-contour Path in draw() produces a separate ACTION_DOWN/ACTION_UP per contour, not a continuous touch.

The workaround that does work is a vertical-only zigzag inside a single-contour draw() path, sized so each zigzag's arc length matches the desired hold time at the path's pixels-per-millisecond rate. It works but reads weirdly — and embeds a lot of arc-length / time-budget math in the test that's incidental to what we're trying to express.

Proposal — 4 new primitives

interface TouchRobotGestureScope {
  // ... existing methods unchanged ...

  /**
   * Press [pointerId] down at [position] and hold for [duration] without lifting.
   * Pair with [moveTo], [hold], or [up]. If [duration] is zero, sends ACTION_DOWN
   * and returns immediately.
   */
  suspend fun press(
    position: IntOffset,
    duration: Duration = Duration.ZERO,
    pointerId: PointerId = PointerId(0),
  )

  /**
   * [press] for `viewConfiguration.longPressTimeoutMillis + 100`. Mirrors
   * [longClick]'s timing but does not release the finger.
   */
  suspend fun longPress(position: IntOffset, pointerId: PointerId = PointerId(0))

  /**
   * Animate [pointerId] from its current position to [position] over [duration],
   * sending ACTION_MOVE events on each frame (same easing as [draw]). Requires
   * an active gesture for [pointerId].
   */
  suspend fun moveTo(
    position: IntOffset,
    duration: Duration,
    pointerId: PointerId = PointerId(0),
  )

  /**
   * Hold [pointerId] stationary at its current position for [duration]. Sends
   * no motion events. Requires an active gesture for [pointerId].
   */
  suspend fun hold(duration: Duration, pointerId: PointerId = PointerId(0))
}

What the test would look like

Before (zigzag workaround):

draw(
  path = Path().apply {
    moveTo(start.x, start.y)
    repeat(pressCycles) { zigzagCycle(start.x, start.y) }
    lineTo(dwell.x, dwell.y)
    repeat(holdCycles) { zigzagCycle(dwell.x, dwell.y) }
    lineTo(drop.x, drop.y)
    repeat(settleCycles) { zigzagCycle(drop.x, drop.y) }
  },
  duration = totalMs.milliseconds,
)

After:

longPress(start)
moveTo(dwell, 700.milliseconds)
hold(5500.milliseconds)
moveTo(drop, 700.milliseconds)
hold(400.milliseconds)
up()

Reads like the production gesture spec; loses all arc-length / cycle / amplitude bookkeeping.

Implementation cost looks small

All four primitives reuse infrastructure that's already present in RealTouchRobotGestureScope:

New method Reuses
press existing down + kotlinx.coroutines.delay
longPress press + viewConfiguration.longPressTimeoutMillis (already used by longClick)
moveTo the already-private dispatchMoveEventWithHistory + the time/easing loop from draw (extract)
hold just kotlinx.coroutines.delay — no event dispatch

The only structural decision is whether to:

  • (a) Relax draw's require(ongoingGesture == null) guard so down + draw + up works, or
  • (b) Keep draw self-contained and treat moveTo as the explicit "continue current gesture" entry point.

(b) feels cleaner — draw stays a "one-shot full gesture" primitive, moveTo is the building block.

Happy to send a PR if the API shape sounds right. Wanted to file the issue first to confirm direction.


🤖 Drafted with AI assistance based on reading the 0.1.0 / main source.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions