Drag Gestures

Gesture-Driven Animation with detectDragGestures

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:

ui/BookScreens.kt: swipe to remove, or spring back (excerpt)Kotlin
  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:

A 300 px swipe, then an 800 px swipe, across the first cart rowShell
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
Output
--------- 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.