React Native 칸반보드 드래그 앤 드롭 구현기 (4)
React Native 칸반보드 드래그 앤 드롭 구현기(4)
서론
3편에서는 Portal 패턴으로 Floating Card를 최상위에 렌더링하여 드래그 앤 드롭의 시각적 완성도를 높이는 방법을 다뤘다.
이번 편에서는 드래그 앤 드롭의 사용성을 크게 좌우하는 기능을 다룬다:
- 자동 스크롤 - 화면 경계에서 자동으로 스크롤되게 하기
왜 자동 스크롤이 필요한가?
칸반보드에 Day가 많아지면 세로 스크롤이 생긴다. 이때 문제가 하나 생기는데:
카드를 드래그해서 Day 3에서 Day 7로 옮기고 싶은데... 화면에 Day 7이 안 보인다?
손가락을 떼면 드래그가 끝나버리니, 드래그를 유지하면서 스크롤할 방법이 없다.
해결책: 드래그 중 손가락이 화면 경계 근처에 오면 자동으로 스크롤해주면 된다!
자동 스크롤의 핵심 개념: 스크롤 존
화면의 위아래에 **스크롤 존(Scroll Zone)**이라는 가상의 영역을 만든다. 드래그 중 손가락이 이 영역에 들어오면 자동 스크롤을 시작하고, 벗어나면 멈춘다.
이 개념을 코드로 구현한 것이 useAutoScroll 훅이다.
useAutoScroll 훅 구현
설정값과 상태
interface UseAutoScrollOptions {
scrollSpeed?: number; // 한 프레임당 스크롤할 픽셀 수
threshold?: number; // 스크롤 존의 크기 (px)
}
export const useAutoScroll = (options: UseAutoScrollOptions = {}) => {
const { scrollSpeed = 15, threshold = 40 } = options;
const scrollViewRef = useRef<ScrollView>(null);
const scrollOffsetRef = useRef(0); // 현재 스크롤 위치
const isAutoScrollingRef = useRef(false); // 자동 스크롤 중인지
const autoScrollDirectionRef = useRef<'up' | 'down' | null>(null);
const autoScrollFrameId = useRef<number | null>(null); // rAF ID (정리용)
// ...
};
두 가지 설정값이 있다:
- scrollSpeed (기본값: 15) - 프레임당 스크롤할 픽셀 수. 값이 클수록 빠르게 스크롤된다.
- threshold (기본값: 40) - 스크롤 존의 크기(px). 화면 위/아래에서 이 범위 안에 손가락이 들어오면 스크롤이 시작된다.
모든 상태를 useRef로 관리하는 이유가 있다. useState를 쓰면 매 프레임마다 리렌더링이 발생하는데, 자동 스크롤은 requestAnimationFrame으로 매 프레임(약 16ms 간격)마다 실행되기 때문에 리렌더링이 발생하면 성능이 급격히 떨어진다.
스크롤 오프셋 추적
자동 스크롤을 구현하려면 먼저 현재 스크롤 위치를 항상 알고 있어야 한다.
const handleScroll = useCallback((event: ScrollEvent) => {
scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
}, []);
ScrollView의 onScroll 이벤트에 연결해서, 스크롤이 발생할 때마다 현재 오프셋을 ref에 저장한다. 이 값은 자동 스크롤 시 "어디서부터 스크롤할지"의 기준점이 된다.
// TravelKanban/index.tsx
<ScrollView
ref={scrollViewRef}
onScroll={handleScroll}
scrollEventThrottle={16} // 16ms 간격으로 이벤트 발생 (60fps)
>
scrollEventThrottle={16}을 설정해야 스크롤 이벤트가 충분히 자주 발생한다. 이걸 빠뜨리면 스크롤 위치가 부정확해져서 자동 스크롤이 뚝뚝 끊기게 된다.
자동 스크롤 시작: requestAnimationFrame 루프
자동 스크롤의 핵심은 requestAnimationFrame(rAF) 루프다.
const startAutoScroll = useCallback((direction: 'up' | 'down') => {
// 이미 같은 방향으로 스크롤 중이면 무시
if (isAutoScrollingRef.current && autoScrollDirectionRef.current === direction) {
return;
}
// 기존 스크롤 중지 후 새로 시작
stopAutoScroll();
isAutoScrollingRef.current = true;
autoScrollDirectionRef.current = direction;
const scroll = () => {
// 스크롤이 중지되었으면 루프 탈출
if (!isAutoScrollingRef.current) return;
const currentOffset = scrollOffsetRef.current;
const newOffset = direction === 'up'
? Math.max(0, currentOffset - scrollSpeed) // 위로: 오프셋 감소
: currentOffset + scrollSpeed; // 아래로: 오프셋 증가
// 맨 위에 도달하면 스크롤 중지
if (direction === 'up' && newOffset <= 0) {
stopAutoScroll();
return;
}
// 실제 스크롤 실행
if (scrollViewRef.current) {
scrollViewRef.current.scrollTo({
y: newOffset,
animated: false // ⚠️ 중요: false여야 즉시 이동
});
}
// 다음 프레임에서 다시 실행
autoScrollFrameId.current = requestAnimationFrame(scroll);
};
// 루프 시작
autoScrollFrameId.current = requestAnimationFrame(scroll);
}, [scrollSpeed, stopAutoScroll]);
왜 requestAnimationFrame인가?
자동 스크롤을 구현하는 방법은 여러 가지가 있다:
| 방법 | 장점 | 단점 |
|---|---|---|
setInterval | 구현 간단 | 프레임과 무관하게 실행, 끊김 발생 |
Animated.timing | Native Driver 사용 가능 | 스크롤 위치를 직접 제어하기 어려움 |
requestAnimationFrame | 화면 주사율에 맞춰 실행, 부드러움 | 직접 루프를 관리해야 함 |
**requestAnimationFrame(rAF)**은 브라우저/런타임이 다음 화면을 그리기 직전에 콜백을 실행한다. 즉, 화면 주사율(보통 60fps)에 정확히 맞춰서 스크롤이 실행되므로 가장 부드러운 결과를 얻을 수 있다.
animated: false가 중요한 이유
scrollViewRef.current.scrollTo({
y: newOffset,
animated: false // 이거!
});
animated: true로 하면 ScrollView가 자체적으로 스크롤 애니메이션을 실행하는데, 이게 rAF 루프와 겹치면 서로 간섭하면서 스크롤이 덜덜 떨리게 된다. animated: false로 즉시 이동시키고, rAF 루프가 부드러움을 담당하는 게 올바른 역할 분배다.
재귀적 rAF 루프 구조
scroll 함수가 자기 자신을 다음 프레임에 예약하는 재귀적 패턴이다. 멈출 때는 isAutoScrollingRef.current를 false로 바꾸면 다음 프레임에서 자연스럽게 루프가 끊긴다.
자동 스크롤 중지
const stopAutoScroll = useCallback(() => {
if (autoScrollFrameId.current) {
cancelAnimationFrame(autoScrollFrameId.current);
autoScrollFrameId.current = null;
}
isAutoScrollingRef.current = false;
autoScrollDirectionRef.current = null;
}, []);
중지는 두 가지를 동시에 한다:
- cancelAnimationFrame - 다음 프레임에 예약된 콜백을 취소
- ref 초기화 - 재귀 루프 내부에서도 체크하므로 확실하게 중지됨
왜 두 가지를 모두 해야 할까? cancelAnimationFrame만 하면 이미 실행 중인 scroll() 함수가 끝나기 전에 새 rAF를 예약할 수 있다. ref도 함께 초기화해야 scroll() 내부의 if (!isAutoScrollingRef.current) return; 체크에서 확실히 멈춘다.
드래그 위치에 따른 스크롤 판단
이제 핵심 로직이다. 드래그 중 손가락 위치를 받아서 스크롤 존에 있는지 판단하는 함수:
const handleAutoScrollForDrag = useCallback((
cursorY: number, // 손가락의 화면 Y 좌표
scrollViewLayout: ScrollViewLayout // ScrollView의 위치/크기
) => {
const scrollViewTop = scrollViewLayout.y;
const scrollViewBottom = scrollViewLayout.y + scrollViewLayout.height;
// 상단 스크롤 존에 있는가?
const isInTopScrollZone = cursorY <= scrollViewTop + threshold;
// 하단 스크롤 존에 있는가?
const isInBottomScrollZone = cursorY >= scrollViewBottom - threshold;
if (isInTopScrollZone && scrollOffsetRef.current > 0) {
// 상단 스크롤 존 + 아직 위로 스크롤할 여유가 있으면
if (!isAutoScrollingRef.current || autoScrollDirectionRef.current !== 'up') {
startAutoScroll('up');
}
} else if (isInBottomScrollZone) {
// 하단 스크롤 존이면
if (!isAutoScrollingRef.current || autoScrollDirectionRef.current !== 'down') {
startAutoScroll('down');
}
} else {
// 스크롤 존 밖이면 → 중지
if (isAutoScrollingRef.current) {
stopAutoScroll();
}
}
}, [threshold, startAutoScroll, stopAutoScroll]);
시각적으로 표현하면:
중복 실행 방지
if (!isAutoScrollingRef.current || autoScrollDirectionRef.current !== 'up') {
startAutoScroll('up');
}
이 조건이 없으면 handleAutoScrollForDrag가 호출될 때마다 (매 프레임마다!) startAutoScroll이 호출되어 rAF 루프가 무한히 생성된다. 이미 같은 방향으로 스크롤 중이면 무시하는 것이 핵심이다.
드래그 시스템과의 통합
useAutoScroll 훅은 단독으로 동작하지 않는다. TravelKanban/index.tsx에서 드래그 이벤트와 연결된다.
// TravelKanban/index.tsx
const handleEnhancedDragMove = useCallback((
x: number,
y: number,
gestureState: GestureState,
evt: GestureResponderEvent,
initialPosition: { x: number; y: number }
) => {
// 1. 자동 스크롤 처리
if (scrollViewLayoutRef.current.height) {
handleAutoScrollForDrag(y, scrollViewLayoutRef.current);
}
// 2. 드래그 이동 처리 (Floating Card 위치 업데이트)
handleDragMove(x, y, gestureState, evt, initialPosition);
}, [handleAutoScrollForDrag, handleDragMove]);
handleEnhancedDragMove가 자동 스크롤과 Floating Card 이동을 하나로 묶는 역할을 한다. PanResponder의 onPanResponderMove에서 매 프레임마다 호출되므로, 손가락이 움직일 때마다 스크롤 존 진입 여부를 자연스럽게 체크할 수 있다.
전체 흐름
드래그 종료 시 자동 스크롤 정리
한 가지 더 중요한 포인트가 있다. 드래그가 끝나면 자동 스크롤도 반드시 중지해야 한다.
// useDragDrop.ts - handleDragEnd 내부
const handleDragEnd = useCallback((y, dayData, setDayData) => {
stopAutoScroll(); // ← 드래그 종료 시 가장 먼저!
// ... 드롭 처리 ...
}, [/* deps */]);
이걸 빠뜨리면? 손가락을 떼도 스크롤이 계속 돌아간다...! rAF 루프는 PanResponder와 독립적으로 동작하기 때문에, 명시적으로 멈춰줘야 한다.
구현하면서 만났던 함정들
1. scrollEventThrottle 누락
// ❌ scrollEventThrottle 없이
<ScrollView onScroll={handleScroll}>
// ✅ scrollEventThrottle 설정
<ScrollView onScroll={handleScroll} scrollEventThrottle={16}>
scrollEventThrottle을 설정하지 않으면 iOS에서 스크롤 이벤트가 매우 드물게 발생한다. 자동 스크롤로 scrollTo를 호출해도 onScroll 이벤트가 충분히 빠르게 오지 않아서 scrollOffsetRef가 업데이트되지 않고, 다음 스크롤 계산이 틀어진다.
2. animated: true로 인한 떨림
처음에는 자연스럽게 보이라고 animated: true로 했었다.
// ❌ rAF 루프 안에서 animated: true
scrollViewRef.current.scrollTo({ y: newOffset, animated: true });
하지만 ScrollView의 내장 애니메이션과 rAF 루프가 경쟁하면서 스크롤이 앞뒤로 왔다 갔다 하는 진동 현상이 발생했다. 각자의 역할을 명확히 분리해야 한다:
- rAF 루프 → 매 프레임 새 위치 계산
- scrollTo → 계산된 위치로 즉시 이동 (animated: false)
3. 상태 관리를 useState로 했다가 성능 문제
처음에는 isAutoScrolling을 useState로 관리했다.
// ❌ 매 프레임마다 리렌더링 발생
const [isAutoScrolling, setIsAutoScrolling] = useState(false);
rAF 루프 안에서 state를 변경하면 매 프레임마다 리렌더링이 발생한다. 60fps에서 리렌더링이 겹치면 프레임 드롭이 발생하고, 드래그 중인 Floating Card의 움직임까지 버벅거리게 된다.
// ✅ ref로 관리 → 리렌더링 없이 값만 업데이트
const isAutoScrollingRef = useRef(false);
자동 스크롤의 상태는 화면에 직접 표시되는 값이 아니다. 그래서 useRef로 관리하는 것이 정답이다.
4. 방향 전환 시 이전 루프가 남아있는 문제
// ❌ 방향 전환 시 이전 루프를 정리하지 않으면
startAutoScroll('down');
// ... 손가락이 위로 이동 ...
startAutoScroll('up');
// → 두 개의 rAF 루프가 동시에 실행!
// → 아래로 15px, 위로 15px → 제자리에서 떨림
startAutoScroll 시작 시 stopAutoScroll()을 먼저 호출하는 이유가 바로 이것이다.
const startAutoScroll = useCallback((direction: 'up' | 'down') => {
// 이미 같은 방향이면 무시
if (isAutoScrollingRef.current && autoScrollDirectionRef.current === direction) {
return;
}
stopAutoScroll(); // ← 기존 루프 확실히 정리
// ... 새 루프 시작 ...
}, [scrollSpeed, stopAutoScroll]);
마치며
자동 스크롤은 구현 자체는 단순해 보이지만, 실제로 부드럽게 동작하게 만들려면 세심한 주의가 필요하다.
핵심 포인트를 정리하면:
- 스크롤 존: 화면 경계에 가상의 영역을 만들어 스크롤 트리거로 사용
- requestAnimationFrame: 화면 주사율에 맞춘 부드러운 스크롤 구현
- ref 기반 상태 관리: 매 프레임 업데이트에도 리렌더링 없이 60fps 유지
- animated: false: rAF 루프와 ScrollView 내장 애니메이션의 충돌 방지
이번 편까지 해서 드래그 앤 드롭의 핵심 기능들을 모두 다뤘다:
- [1편] PanResponder와 절대 좌표 시스템
- [2편] 레이아웃 측정과 드롭 타겟 계산
- [3편] Portal 패턴의 Floating Card
- [4편] requestAnimationFrame 기반 자동 스크롤
라이브러리 없이 직접 구현하면서 정말 많은 것을 배울 수 있었다. 특히 React Native에서 성능을 신경 쓰면서 인터랙션을 구현하는 방법에 대해 많이 깨달았던 것 같다.
글을 읽어주셔서 감사합니다!