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.

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.

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.