While the finger is down, the element follows it; when the finger lifts, an animation settles on a decision. Animatable serves both phases (snapTo jumps, animateTo animates), and detectDragGestures inside pointerInput delivers the movement. BookNest's cart lets you swipe a book away:
val offsetX = remember { Animatable(0f) }
val scope = rememberCoroutineScope()
val currentOnRemove by rememberUpdatedState(onRemove)
var width by remember { mutableIntStateOf(1) }
Box(modifier.fillMaxWidth().onSizeChanged { width = it.width }
.offset { IntOffset(offsetX.value.roundToInt(), 0) }
.pointerInput(Unit) {
detectDragGestures(
onDragEnd = {
val x = offsetX.value
scope.launch {
if (abs(x) > width * 0.4f) {
Log.d(TAG, "released at ${x.roundToInt()} of $width px: remove")
offsetX.animateTo(width * sign(x), tween(150))
currentOnRemove()
} else {
Log.d(TAG, "released at ${x.roundToInt()} of $width px: spring back")
offsetX.animateTo(0f, spring(dampingRatio = Spring.DampingRatioMediumBouncy))
Log.d(TAG, "settled at ${offsetX.value}")
}
}
},
) { change, dragAmount ->
change.consume() // this drag is ours
scope.launch { offsetX.snapTo(offsetX.value + dragAmount.x) } // follow the finger
}
}) { content() }Three details make it correct. The lambda-based offset { } reads the value in the layout phase, so dragging doesn't recompose. rememberUpdatedState keeps pointerInput(Unit), which never restarts, calling the latest onRemove. And the cart's items(..., key = { it.id }) with Modifier.animateItem() slides the remaining rows up when one leaves. adb shell input swipe performs real drags, a short one and then a long one:
adb -s emulator-5556 shell input swipe 200 352 500 352 400
adb -s emulator-5556 shell input swipe 150 352 950 352 400
adb -s emulator-5556 logcat -d -v tag -s BookNestAnim:D--------- beginning of main D/BookNestAnim: released at 265 of 996 px: spring back D/BookNestAnim: settled at 0.0 D/BookNestAnim: released at 675 of 996 px: remove
The detector reported 265 px of the first 300 px swipe; it ignores movement until the finger has passed the touch slop, so short drags always read a little short. For flings, pass a VelocityTracker's speed as initialVelocity. Material 3 15,533 also ships this exact pattern as SwipeToDismissBox.