Skip to main content
← 블로그

드래그 중 자동 스크롤, 생각보다 어렵다

VauDium ·

ScrollView 안에서 드래그 드랍하면서 자동 스크롤을 구현하는 과정. 세 번 실패하고 네 번째에 성공한 이야기.

드래그 중 자동 스크롤, 생각보다 어렵다

Fecit의 타임테이블에서 일정 블록을 드래그해서 시간을 바꿀 수 있습니다. 그런데 화면에 보이지 않는 시간대로 옮기려면? 드래그하면서 스크롤이 되어야 합니다.

간단해 보였습니다. 안 간단했습니다.

왜 어렵냐면

React Native에서 드래그와 스크롤은 다른 세계에 살고 있습니다.

  • 드래그 (Pan Gesture): UI 스레드(worklet)에서 실행됩니다. 손가락 위치를 매 프레임 추적합니다.
  • 스크롤 (ScrollView): JS 스레드에서 onScroll 이벤트로 위치를 알려줍니다.

이 두 스레드 사이에는 최소 1프레임의 지연이 있습니다. 드래그 중에 스크롤이 되면 블록이 손가락에서 떨어집니다.

첫 번째 시도: setInterval + scrollTo

JS 스레드에서 setInterval로 16ms마다 블록 위치를 확인하고, 가장자리에 가까우면 scrollTo를 호출했습니다.

결과: 스크롤은 됐지만 블록이 손가락에서 분리되어 날아갔습니다. translateY는 제스처 시작점 기준이라 스크롤이 되면 블록의 화면 위치가 어긋납니다.

두 번째 시도: scrollOffset prop

부모에서 스크롤 보정값을 계산해서 DraggableTimelineBlock에 prop으로 전달했습니다.

결과: prop → useEffect → shared value 경로가 비동기라서 1프레임 지연. 블록이 떨리고, 드롭 시 원래 위치로 튀었다가 돌아오는 현상.

세 번째 시도: useScrollViewOffset + shared value

Reanimated의 useScrollViewOffset으로 스크롤 위치를 shared value로 얻고, DraggableTimelineBlock에 직접 전달했습니다.

결과: shared value 전달은 맞았지만, 자동 스크롤을 runOnUI + setInterval로 처리해서 여전히 동기화 문제. 스크롤이 아예 안 되거나, 첫 드래그에서 블록이 튀었습니다.

네 번째 시도: worklet 안에서 전부 처리

Pan gesture의 onUpdate worklet 안에서 모든 것을 처리했습니다.

onUpdate → 블록 화면 위치 계산 → 가장자리 감지 → scrollTo → scrollCompensation 누적 → translateY에 반영

전부 UI 스레드에서 한 프레임 안에 실행됩니다. JS 스레드를 거치지 않습니다.

핵심 코드

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은 자동 스크롤로 인한 보정값입니다. 이걸 translateY에 더해서 블록이 손가락에 붙어있도록 합니다.

스크롤 끝 처리

한 가지 추가 문제가 있었습니다. 스크롤이 끝(0 또는 최대)에 도달해도 scrollCompensation이 계속 누적되어 블록이 혼자 움직였습니다. scrollTo 전후의 실제 변화량(actualDelta)만 보정하도록 수정했습니다.

초기 스크롤 위치

contentOffset prop으로 설정한 초기 스크롤 위치를 useScrollViewOffset이 감지하지 못하는 문제도 있었습니다. contentOffset을 제거하고 mount 후 scrollTo로 설정하면 onScroll 이벤트가 자연스럽게 발생해서 해결됩니다.

배운 것

  1. JS 스레드와 UI 스레드를 섞지 마세요. 드래그 + 스크롤처럼 프레임 단위 동기화가 필요한 작업은 전부 worklet에서 처리해야 합니다.

  2. shared value는 worklet 간 동기적입니다. scrollToscrollY.value 업데이트 → translateY 반영이 한 프레임 안에 일어납니다.

  3. 스크롤 끝 처리를 잊지 마세요. 경계 조건은 항상 별도로 처리해야 합니다.

  4. contentOffset은 이벤트를 발생시키지 않습니다. 프로그래밍 방식의 초기 스크롤이 필요하면 scrollTo를 쓰세요.


세 번 실패해서 좌절하기도 했지만, 결국 worklet에서 전부 처리하는 게 답이었습니다. 돌아보면 당연한 건데, 거기까지 가는 데 시간이 걸렸습니다.