Together the two libraries keep the whole loop, touch to pixels, on the UI thread: gesture callbacks are worklets that write shared values, and animated styles read them. Here a book cover can be picked up, dragged and flicked; it grows slightly while held and coasts to a stop when released:
const maxX = useWindowDimensions().width - W - 32; // screen width minus cover and padding
const x = useSharedValue(0), y = useSharedValue(0);
const startX = useSharedValue(0), startY = useSharedValue(0);
const lifted = useSharedValue(1);
const drag = Gesture.Pan() // callbacks are worklets on the UI thread
.onStart(() => {
startX.value = x.value; startY.value = y.value;
lifted.value = withSpring(1.1);
})
.onUpdate((e) => { // keep x inside the screen
x.value = Math.min(Math.max(startX.value + e.translationX, 0), maxX);
y.value = startY.value + e.translationY;
})
.onEnd((e) => {
lifted.value = withSpring(1);
scheduleOnRN(say, 'released at', Math.round(x.value), 'vx', Math.round(e.velocityX));
x.value = withDecay({ velocity: e.velocityX, clamp: [0, maxX] },
() => scheduleOnRN(say, 'came to rest at x =', Math.round(x.value)));
y.value = withSpring(Math.max(0, y.value)); // never above the top edge
});
// ...an animated style turns x, y and lifted into translateX, translateY and scaleOutput
LOG released at 79 vx 0 LOG came to rest at x = 79
The pattern is general: remember the start, add the translation, hand the release velocity to withDecay() or withSpring(). Swipes from adb shell input swipe arrived here with a velocity of 0, so the cover stopped where it was dropped. Clamp in onUpdate: an earlier version clamped only the decay, and a cover dropped past the left edge stayed at x = -26, because withDecay() never pulls a value back into range.