Skip to main content
← Blog

Auto-Scroll During Drag: Harder Than It Looks

VauDium ·

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 onScroll events 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

  1. Don’t mix JS and UI threads for frame-sensitive work. If you need drag + scroll sync, do everything in worklets.

  2. Shared values are synchronous between worklets. scrollToscrollY.value update → translateY update all happens within one frame.

  3. Handle scroll boundaries. Edge cases at scroll limits need explicit guards.

  4. contentOffset doesn’t fire events. Use programmatic scrollTo for 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.