Every gesture in Compose is a coroutine reading a stream of events

pointerInput gives you a coroutine scope over raw pointer events. Understanding that gestures are suspending loops rather than callbacks explains the whole API, including why the key parameter matters more than it looks.

6 min read
androidcomposekotlingestures

Day 71 — A gesture is a coroutine reading an event stream

Day 71 of 100, opening the gestures pillar. Underneath clickable, draggable and every other gesture modifier is one primitive, and it has a shape worth knowing.

The symptom

A drag handler written the way the View system taught:

Modifier.pointerInput(Unit) {
    awaitPointerEventScope {
        while (true) {
            val event = awaitPointerEvent()
            when {
                event.changes.all { it.pressed } && !isDragging -> { isDragging = true; … }
                event.changes.any { !it.pressed } -> { isDragging = false; … }
                else -> { /* update position */ }
            }
        }
    }
}

A state machine in a when, with flags tracking which phase the gesture is in. It works and it's difficult to read, difficult to extend, and difficult to get right at the edges — a second finger, a cancelled gesture, a pointer leaving the bounds.

This is onTouchEvent with better syntax, and it's not what the API is for.

Why the obvious approach fails

The obvious approach is more flags. Add pointerCount, add hasMovedPastSlop, add isCancelled, and the when grows a branch each time.

The reason it doesn't scale is that a gesture is sequential — press, then move, then release — and a state machine over a callback stream is what you write when the language can't express "wait for the next thing". Kotlin can.

A gesture is a suspending loop over pointer events, not a state machine over callbacks

The actual mechanism

Modifier.pointerInput gives you a coroutine scope. Inside it, awaiting events is a suspend call, so a gesture reads as a sequence:

Modifier.pointerInput(Unit) {
    awaitEachGesture {
        val down = awaitFirstDown()
        val up = waitForUpOrCancellation()
        if (up != null) onTap(up.position)
    }
}

Press, wait for release, act. No flags, no when, no phase tracking — the position in the code is the state.

awaitEachGesture loops for you: it runs the block, and when the block returns it waits for the next gesture to begin. Before it existed people wrote forEachGesture { … } or a while (true), and the current form handles cancellation correctly where the manual loop often didn't.

Inside, the vocabulary is small:

awaitFirstDown()                  // the first pointer to touch down
awaitPointerEvent()               // the next event of any kind
waitForUpOrCancellation()         // release, or null if cancelled
drag(pointerId) { change -> … }   // consume drags for one pointer
awaitTouchSlopOrCancellation(id)  // wait until movement exceeds the slop threshold

Each one suspends. That's the whole idea: a gesture is a program that reads its input as it arrives, rather than a set of handlers reacting to it.

The key parameter is not decoration

pointerInput(Unit) looks like boilerplate and is the most common bug in this API:

Modifier.pointerInput(Unit) {                       // captured ONCE
    detectTapGestures { onItemClick(item.id) }      // item is captured from the first composition
}

The lambda is restarted only when the key changes. With Unit, it never restarts — so it holds the item from the composition where it was created, forever. In a LazyColumn where slots are reused (Day 23), that means taps go to the wrong item.

The key follows the same rule as LaunchedEffect — Day 9's contract, unchanged:

Modifier.pointerInput(item.id) { detectTapGestures { onItemClick(item.id) } }

The general form: anything the block captures must be in the key, or hoisted so it isn't captured. rememberUpdatedState works here for the same reason it does in effects, and is the right tool when restarting the gesture detector would be disruptive.

Consuming events

When a gesture handles an event, it should say so:

awaitEachGesture {
    val down = awaitFirstDown()
    down.consume()                   // this pointer is mine
    …
}

Unconsumed events propagate to ancestors. That's how a tappable row inside a scrollable list works — the tap doesn't consume the drag, so the list can still scroll.

Getting this wrong produces the two classic gesture bugs: consuming too eagerly means a parent scroll stops working, and consuming too little means both your handler and the parent respond to the same touch.

The three passes

Each pointer event is dispatched three times, in different directions:

  • PointerEventPass.Initial — parents first, going down. A parent can claim an event before its children see it.
  • PointerEventPass.Main — children first, going up. The default, and where nearly all gesture code lives.
  • PointerEventPass.Final — parents again, going down, after everyone has had a turn. For reacting to what was consumed.

awaitPointerEvent(PointerEventPass.Initial) is how a parent implements "I take priority" — a pull-to-refresh that must win over a list's scroll, for instance. It's a small API with a large effect, and it's the answer to most "my parent and child are fighting" questions.

Most code never needs to specify a pass. Knowing the passes exist is what lets you fix the cases that do.

Touch slop

One value that separates a gesture that feels right from one that fights the user: fingers move a few pixels during a tap, so treating any movement as a drag makes buttons hard to press.

awaitEachGesture {
    val down = awaitFirstDown()
    val dragged = awaitTouchSlopOrCancellation(down.id) { change, over ->
        change.consume()
    }
    if (dragged == null) onTap(down.position)   // never exceeded slop → a tap
}

awaitTouchSlopOrCancellation waits until movement passes the system's slop threshold, returning null if the pointer lifts first. That's how one gesture handler distinguishes a tap from a drag without a distance constant of your own — and the threshold comes from ViewConfiguration, so it's correct per device rather than per guess.

Prefer the built-ins

Worth stating plainly at the start of the pillar: you should rarely write raw pointerInput. The detectors cover almost everything:

detectTapGestures(onTap = …, onDoubleTap = …, onLongPress = …)
detectDragGestures(onDragStart = …, onDrag = …, onDragEnd = …)
detectHorizontalDragGestures(…)
detectTransformGestures { centroid, pan, zoom, rotation -> … }

and above those sit clickable, draggable, scrollable, transformable — which add semantics, accessibility, ripple and focus handling that raw pointer code does not.

The reason to understand the layer underneath is to know what the built-ins are doing, and to have somewhere to go when they genuinely don't fit — which is the next six days.

How to prove it

The captured-value bug is worth reproducing once, because it's silent:

@Composable
fun BuggyRow(item: Item, onClick: (String) -> Unit) {
    Box(Modifier.pointerInput(Unit) { detectTapGestures { onClick(item.id) } })
}

Put that in a LazyColumn, scroll far enough for slot reuse, and tap. The id logged won't match the row you touched. Change the key to item.id and it will.

For consumption, log both handlers:

Modifier
    .pointerInput(Unit) { awaitEachGesture { … ; Log.d("G", "child") } }

inside a scrollable parent. If the parent stops scrolling, you're consuming too much.

What this generalizes to

The idea is sequential input deserves sequential code. Callbacks force you to reconstruct the sequence from flags; a coroutine lets you write it as a sequence, and the compiler keeps track of where you are.

That's the same argument as async/await over callback chains, and it applies wherever input arrives over time — parsing, protocols, wizards, animations. When the code's shape matches the process's shape, the edge cases stop needing invented state.

The cancellation story follows for free, which is the part that's easy to undervalue. A gesture interrupted by a parent taking over, a pointer leaving the window, or the composable leaving the composition all cancel the coroutine — so cleanup happens at the finally block rather than in a callback nobody remembered to write.

Tomorrow, Day 72: taps and presses — the detector you'll use most, and the interaction source underneath it.


Day 71 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Pointer input.