Swipe-to-dismiss is a state machine with anchors, not a drag handler

Hand-rolled swipe gestures compare a distance threshold and ignore velocity, which is why a fast flick feels wrong. AnchoredDraggableState models discrete positions and settles between them using both distance and speed.

6 min read
androidcomposekotlingestures

Day 73 — Swipe-to-dismiss is a state machine with anchors

Day 73 of 100. Dragging is easy; deciding what happens when the finger lifts is the part that separates a gesture that feels native from one that doesn't.

The symptom

A swipe-to-delete that ignores how fast you swiped:

var offsetX by remember { mutableFloatStateOf(0f) }

Modifier
    .offset { IntOffset(offsetX.roundToInt(), 0) }
    .pointerInput(item.id) {
        detectHorizontalDragGestures(
            onHorizontalDrag = { _, delta -> offsetX += delta },
            onDragEnd = {
                if (abs(offsetX) > 200f) onDelete(item) else offsetX = 0f
            },
        )
    }

A slow drag past 200px deletes. A fast flick that only travels 150px snaps back — even though the user's intent was unmistakable. And the snap-back is instant, because there's no animation.

Every native swipe interaction on the platform handles the flick. This one doesn't.

Why the obvious fix fails

The obvious fix is to add velocity to the condition:

onDragEnd = {
    if (abs(offsetX) > 200f || abs(velocity) > 1000f) onDelete(item) else offsetX = 0f
}

Which requires tracking velocity yourself with a VelocityTracker, picking a threshold that feels right on your device, and animating the snap-back — and it still doesn't handle the interesting case: a swipe that reveals two actions at different distances, or a sheet with three detents.

The problem is the model. offsetX is a continuous value, and what you actually have is a set of discrete states with positions.

Anchors turn a continuous offset into a choice between discrete states, settled by distance and velocity

The actual mechanism

AnchoredDraggableState models exactly that: named states, each at an offset, with the drag settling to whichever the gesture implies.

enum class SwipeState { Resting, Revealed, Dismissed }

val state = remember {
    AnchoredDraggableState(
        initialValue = SwipeState.Resting,
        anchors = DraggableAnchors {
            SwipeState.Resting at 0f
            SwipeState.Revealed at -160f
            SwipeState.Dismissed at -screenWidth
        },
        positionalThreshold = { distance -> distance * 0.5f },
        velocityThreshold = { with(density) { 125.dp.toPx() } },
        snapAnimationSpec = spring(),
        decayAnimationSpec = exponentialDecay(),
    )
}

Box(
    Modifier
        .offset { IntOffset(state.requireOffset().roundToInt(), 0) }
        .anchoredDraggable(state, Orientation.Horizontal)
) { RowContent(item) }

Four things come for free that the hand-rolled version needed code for:

Velocity is part of the decision. A fast flick past velocityThreshold settles to the next anchor regardless of distance. That's the flick behaviour, and it's a parameter rather than a VelocityTracker.

Settling is animated. snapAnimationSpec handles the release; decayAnimationSpec handles the fling before it. Both are Day 62's specs.

The state is observable. state.currentValue is the settled state, targetValue is where it's heading — Day 63's distinction, and the right things to key side effects on.

More than two states works. The three-anchor version above gives reveal-then-dismiss with no extra logic.

Reacting to the settled state

LaunchedEffect(state.settledValue) {
    if (state.settledValue == SwipeState.Dismissed) onDelete(item)
}

settledValue rather than currentValue for anything destructive — the same reason Day 25 preferred settledPage. A fast drag through the dismiss anchor and back shouldn't delete the row.

Use the Material component first

Before any of that: swipe-to-dismiss on a list row is a Material component.

val dismissState = rememberSwipeToDismissBoxState(
    confirmValueChange = { value ->
        if (value == SwipeToDismissBoxValue.EndToStart) { onDelete(item); true } else false
    },
)

SwipeToDismissBox(
    state = dismissState,
    backgroundContent = { DeleteBackground(dismissState) },
) { MessageRow(item) }

It handles the anchors, the velocity, the animation, the background reveal and — Day 67 — the accessibility action, so a screen-reader user can delete without swiping. Hand-rolling that drops the last one silently.

AnchoredDraggable is for the cases the component doesn't cover: custom detents, a bottom sheet with three positions, a drawer with a peek state.

Confirming before committing

confirmValueChange above is doing something worth noticing: it's a veto. Returning false refuses the state change and springs the row back, which is how you gate a destructive swipe behind a confirmation:

rememberSwipeToDismissBoxState(
    confirmValueChange = { value ->
        when (value) {
            SwipeToDismissBoxValue.EndToStart -> { pendingDelete = item; false }  // show a dialog
            SwipeToDismissBoxValue.StartToEnd -> { archive(item); true }          // just do it
            else -> false
        }
    },
)

Archive is reversible, so it commits immediately. Delete isn't, so the swipe opens the dialog from Day 39 and the row springs back — the gesture becomes a request rather than an action.

The alternative pattern, and often the better one, is to commit immediately and offer an undo snackbar (Day 35). Users prefer an undo over a confirmation for anything they do frequently, because the confirmation taxes every action to protect against the rare mistake.

Draggable, for the continuous case

Not everything has anchors. A slider, a rotary dial, a pan gesture — those stay continuous:

val dragState = rememberDraggableState { delta -> offsetX += delta }

Modifier.draggable(
    state = dragState,
    orientation = Orientation.Horizontal,
    onDragStopped = { velocity ->
        // fling to a natural stop rather than freezing
        animatable.animateDecay(velocity, exponentialDecay())
    },
)

Modifier.draggable sits above raw pointer input the way clickable does — it adds the touch slop handling, the interaction source, and the accessibility hooks. Day 72's argument, applied to drags.

The fling is the piece people forget. A drag that stops dead when the finger lifts feels wrong; animateDecay with the release velocity is what makes it feel physical, and it's two lines.

Nested gestures

A horizontal swipe inside a vertical list is the common conflict, and it works because of Day 71's consumption model: the horizontal drag consumes horizontal movement and leaves vertical alone, so the list still scrolls.

Where it breaks is same-axis nesting — a horizontal carousel inside a horizontal pager. That's a nested-scroll problem rather than a gesture problem, and it's Day 75.

How to prove it

The flick is the behaviour to check, and it's a manual test with a clear pass condition: swipe fast and short. If the row snaps back, velocity isn't in the decision.

For automation, the test framework can inject velocity:

@Test fun fastFlickDismisses() = runComposeUiTest {
    setContent { MessageRow(sample, onDelete = { deleted = true }) }
    onNodeWithTag("row").performTouchInput {
        swipeLeft(startX = right, endX = right - 100f, durationMillis = 50)   // fast, short
    }
    assertTrue(deleted)
}

durationMillis is what makes it a flick rather than a drag — a short duration over a short distance is high velocity. Against the threshold-only implementation, that test fails, which is the bug written down.

What this generalizes to

The idea is model the destinations, not the position. A continuous offset with a threshold check is a lossy encoding of "the user is choosing between these states", and everything hard — velocity, animation, multiple detents, reporting the current choice — becomes easy once the states are named.

That's the same move as Day 63's transition over derived booleans, and Day 12's sealed hierarchy over nullable fields. Naming the discrete thing is repeatedly what turns a pile of conditionals into a small amount of configuration.

It also makes the design conversation tractable. "Should a half-swipe reveal or dismiss" is answerable by pointing at the anchors; the same question against a threshold constant becomes an argument about a magic number nobody can defend.

Tomorrow, Day 74: multi-touch — pinch, zoom and rotate, and the transform gesture that handles all three at once.


Day 73 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Drag, swipe and fling.