Auto-Scroll During Drag: Harder Than It Looks
Implementing auto-scroll while dragging inside a ScrollView. Three failures and one success.
Auto-Scroll During Drag: Harder Than It Looks
In Fecit’s timetable, you can drag schedule blocks to change their time. But what if you want to move a block to a time that’s off-screen? The scroll view needs to scroll while you’re dragging.
Sounds simple. It wasn’t.
Why It’s Hard
In React Native, dragging and scrolling live in different worlds.
- Drag (Pan Gesture): Runs on the UI thread (worklet). Tracks finger position every frame.
- Scroll (ScrollView): Reports position via
onScrollevents on the JS thread.
There’s at least a one-frame delay between these two threads. When the scroll view scrolls during a drag, the block separates from your finger.
Attempt 1: setInterval + scrollTo
Used setInterval on the JS thread to check the block’s position every 16ms and call scrollTo when near the edge.
Result: Scrolling worked, but the block flew away from the finger. translateY is relative to the gesture start point, so when the scroll view scrolls, the block’s screen position shifts.
Attempt 2: scrollOffset prop
Calculated a scroll compensation value in the parent and passed it as a prop to the draggable block.
Result: The prop → useEffect → shared value path is asynchronous, causing a one-frame delay. The block jittered, and on drop it snapped back to its original position before jumping to the correct one.
Attempt 3: useScrollViewOffset + shared value
Used Reanimated’s useScrollViewOffset to get scroll position as a shared value and passed it directly to the block.
Result: The shared value approach was right, but auto-scrolling was still done via runOnUI + setInterval, causing sync issues. Scrolling didn’t work at all, or the block jumped on first drag.
Attempt 4: Everything in the worklet
Handled everything inside the Pan gesture’s onUpdate worklet.
onUpdate → calculate block screen position → detect edge → scrollTo → accumulate scrollCompensation → apply to translateY
Everything runs on the UI thread within a single frame. No JS thread involved.
Key Insight
const doAutoScroll = (gestureTranslationY: number) => {
'worklet';
const scrollDelta = scrollY.value - startScrollY.value;
const blockScreenY = block.top + gestureTranslationY + scrollDelta - scrollY.value;
if (blockScreenY < EDGE_ZONE) {
const speed = Math.max(1, Math.round(SCROLL_SPEED * (1 - blockScreenY / EDGE_ZONE)));
const newScrollY = Math.max(0, scrollY.value - speed);
const actualDelta = newScrollY - scrollY.value;
if (actualDelta !== 0) {
scrollTo(scrollRef, 0, newScrollY, false);
scrollCompensation.value += actualDelta;
}
}
};
scrollCompensation keeps track of how much auto-scrolling has occurred. This is added to translateY so the block stays under the finger.
Scroll Boundary
One more issue: when the scroll reaches the top or bottom, scrollCompensation kept accumulating even though no actual scrolling happened. Fixed by only adding the actualDelta (difference between requested and actual scroll position).
Initial Scroll Position
useScrollViewOffset doesn’t pick up the initial scroll position set via the contentOffset prop — it only updates on scroll events. Removing contentOffset and using scrollTo on mount triggers an onScroll event naturally.
Lessons
-
Don’t mix JS and UI threads for frame-sensitive work. If you need drag + scroll sync, do everything in worklets.
-
Shared values are synchronous between worklets.
scrollTo→scrollY.valueupdate →translateYupdate all happens within one frame. -
Handle scroll boundaries. Edge cases at scroll limits need explicit guards.
-
contentOffsetdoesn’t fire events. Use programmaticscrollTofor initial scroll position when you need the scroll offset tracked.
Three failures before the solution. In hindsight, doing everything in the worklet was obvious. Getting there was the hard part.