React Native 칸반보드 드래그 앤 드롭 구현기 (2)
React Native 칸반보드 드래그 앤 드롭 구현기(2)
서론
1편에서는 왜 라이브러리 없이 직접 구현하게 되었는지, 그리고 PanResponder를 "절대 좌표 전달 도구"로 사용하기로 한 이유를 설명했다.
이번 편에서는 실제 구현 코드를 살펴보자. 핵심은 3개의 커스텀 훅으로 역할을 분리한 것이다.
훅 구조 설계
드래그 앤 드롭 로직을 하나의 거대한 컴포넌트에 넣으면 금방 스파게티 코드가 된다. 그래서 관심사별로 훅을 분리했다.
각 훅은 독립적으로 테스트할 수 있고, 필요한 곳에서만 조합해서 사용한다.
// TravelKanban/index.tsx
export const TravelPlanKanban = () => {
// 1. 자동 스크롤
const {
scrollViewRef,
scrollOffsetRef,
handleScroll,
handleAutoScrollForDrag,
stopAutoScroll,
} = useAutoScroll({ scrollSpeed: 15, threshold: 40 });
// 2. 레이아웃 측정
const {
scrollViewLayout,
containerRef,
dayRefs,
measureScrollViewPosition,
measureDay,
measureCard,
remeasureDayLayouts,
getDropTarget,
} = useLayoutMeasurement(scrollOffsetRef);
// 3. 드래그 앤 드롭
const {
draggingItem,
handleDragStart,
handleDragMove,
handleDragEnd,
} = useDragDrop({
scrollOffsetRef,
measureScrollViewPosition,
remeasureDayLayouts,
getDropTarget,
stopAutoScroll,
FloatingPortalContext,
});
// ... 렌더링
};
useLayoutMeasurement: 레이아웃 측정의 핵심
1편에서 강조했듯이, 이 구현의 핵심은 절대 좌표다. 모든 요소의 위치를 화면 기준 절대 좌표로 측정해야 한다.
measureInWindow 사용하기
React Native에서 절대 좌표를 얻으려면 measureInWindow를 사용한다.
// 일반적인 onLayout은 부모 기준 상대 좌표
<View onLayout={(e) => {
const { x, y } = e.nativeEvent.layout; // 부모 기준!
}} />
// measureInWindow는 화면 기준 절대 좌표
viewRef.current.measureInWindow((x, y, width, height) => {
// x, y는 화면 좌상단 기준 절대 좌표
});
측정해야 할 것들
칸반보드에서는 3가지를 측정해야 한다:
- ScrollView 영역 - 자동 스크롤 경계 판단용
- 각 Day 컬럼 - 어느 Day에 드롭할지 판단용
- 각 카드 - 몇 번째 위치에 드롭할지 판단용
export const useLayoutMeasurement = (
scrollOffsetRef: MutableRefObject<number>
) => {
const [dayLayouts, setDayLayouts] = useState<DayLayouts>({});
const [cardLayouts, setCardLayouts] = useState<CardLayouts>({});
const dayRefs = useRef<{ [key: string]: View }>({});
// Day 컬럼 레이아웃 측정
const measureDay = useCallback((dayId: string, event: any) => {
const { x, y, width, height } = event.nativeEvent.layout;
setDayLayouts(prev => ({
...prev,
[dayId]: { x, y, width, height, originalY: y }
}));
}, []);
// 카드 레이아웃 측정
const measureCard = useCallback((dayId: string, index: number, event: any) => {
const { x, y, width, height } = event.nativeEvent.layout;
setCardLayouts(prev => {
const dayCards = [...(prev[dayId] || [])];
dayCards[index] = { x, y, width, height, index };
return { ...prev, [dayId]: dayCards };
});
}, []);
// ...
};
드래그 시작 시 재측정이 필요한 이유
여기서 중요한 포인트가 있다. 드래그를 시작할 때 모든 Day의 레이아웃을 다시 측정해야 한다.
왜냐하면:
- 사용자가 스크롤을 했을 수 있음
onLayout으로 저장한 값은 콘텐츠 기준 상대 좌표- 드롭 타겟 계산에는 현재 화면 기준 절대 좌표가 필요
const remeasureDayLayouts = useCallback(() => {
return new Promise<void>((resolve) => {
const dayIds = Object.keys(dayRefs.current);
// 빈 배열이면 바로 resolve (이거 빠뜨리면 Promise가 영원히 pending됨)
if (dayIds.length === 0) {
resolve();
return;
}
const currentScrollOffset = scrollOffsetRef.current;
let measured = 0;
dayIds.forEach((dayId) => {
const dayRef = dayRefs.current[dayId];
if (dayRef?.measureInWindow) {
dayRef.measureInWindow((x, y, width, height) => {
// 스크롤 오프셋을 더해서 콘텐츠 기준 좌표로 변환
const contentY = y + currentScrollOffset;
setDayLayouts(prev => ({
...prev,
[dayId]: { x, y: contentY, width, height }
}));
measured++;
if (measured >= dayIds.length) resolve();
});
}
});
});
}, [scrollOffsetRef]);
드롭 타겟 계산: getDropTarget
레이아웃을 측정했으니, 이제 "손가락이 어디에 있는지"를 바탕으로 "어디에 드롭해야 하는지"를 계산할 수 있다.
로직 설명
- 현재 터치 Y 좌표 + 스크롤 오프셋 = 콘텐츠 기준 Y 좌표
- 각 Day 컬럼을 순회하며 해당 Y가 어느 Day 범위에 있는지 확인
- 해당 Day의 카드들을 순회하며 몇 번째 위치에 넣을지 계산
const getDropTarget = useCallback((pageY: number): {
dayId: string;
insertIndex: number
} | null => {
const currentScrollOffset = scrollOffsetRef.current;
const contentY = pageY + currentScrollOffset;
for (const dayId of Object.keys(dayLayouts)) {
const dayLayout = dayLayouts[dayId];
if (!dayLayout) continue;
const dayTop = dayLayout.y;
const dayBottom = dayLayout.y + dayLayout.height;
// 여유값 50px을 줘서 경계 근처에서도 인식되게
if (contentY >= dayTop - 50 && contentY <= dayBottom + 50) {
const cards = cardLayouts[dayId] || [];
// 빈 Day면 0번 인덱스
if (cards.length === 0) {
return { dayId, insertIndex: 0 };
}
// 각 카드의 중간점을 기준으로 삽입 위치 결정
for (let i = 0; i < cards.length; i++) {
const card = cards[i];
const cardCenterY = dayLayout.y + card.y + card.height / 2;
if (contentY < cardCenterY) {
return { dayId, insertIndex: i };
}
}
// 모든 카드보다 아래면 마지막에 삽입
return { dayId, insertIndex: cards.length };
}
}
return null;
}, [dayLayouts, cardLayouts, scrollOffsetRef]);
왜 카드 중간점을 기준으로 할까?
드래그 중인 카드가 다른 카드 위에 있을 때:
- 중간점보다 위에 있으면 → 그 카드 앞에 삽입
- 중간점보다 아래에 있으면 → 그 카드 뒤에 삽입
이게 사용자 입장에서 가장 직관적이다.
useDragDrop: 드래그 상태 관리
이제 실제 드래그 로직을 담당하는 훅을 살펴보자.
드래그 시작
const handleDragStart = useCallback(async (
item: Plan,
dayId: string,
index: number,
cardLayout: { x: number; y: number; width: number; height: number },
initialPosition: { x: number; y: number }
) => {
// 1. 드래그 중인 아이템 정보 저장
setDraggingItem({ item, sourceDay: dayId, sourceIndex: index });
// 2. 레이아웃 재측정 (스크롤 위치 반영)
measureScrollViewPosition();
await remeasureDayLayouts();
// 3. Floating Card 생성
if (!cardLayout.width || !cardLayout.height || !floatingPortal) return;
floatingPortal.createFloatingCard(
item, dayId, index, cardLayout, initialPosition, { dx: 0, dy: 0 }
);
// 4. 페이드인 애니메이션
Animated.timing(floatingPortal.floatingOpacity, {
toValue: 0.9,
duration: 150,
useNativeDriver: false,
}).start();
}, [/* deps */]);
드래그 종료 & 데이터 업데이트
드롭 시 핵심 로직은 같은 Day 내 이동인지 다른 Day로 이동인지에 따라 다르다.
const handleDragEnd = useCallback((
y: number,
dayData: any,
setDayData: (updater: (prev: any) => any) => void
) => {
// 1. 드래그 종료 시 자동 스크롤 중지
stopAutoScroll();
// 드래그 중인 아이템이 없으면 early return
if (!draggingItem) return;
// 2. 현재 손가락 위치(y)를 기반으로 드롭할 위치 계산
const dropTarget = getDropTarget(y);
if (dropTarget) {
const { dayId: targetDay, insertIndex } = dropTarget;
const { item, sourceDay, sourceIndex } = draggingItem;
if (targetDay === sourceDay) {
// ============================================
// Case 1: 같은 Day 내에서 순서 변경
// ============================================
// 위치가 실제로 변경되는 경우에만 업데이트
// - insertIndex === sourceIndex: 제자리
// - insertIndex === sourceIndex + 1: 바로 아래칸 (실질적으로 제자리)
if (insertIndex !== sourceIndex && insertIndex !== sourceIndex + 1) {
setDayData((prevData) => {
const newDayData = { ...prevData };
const plans = [...newDayData[sourceDay].plans];
// 원본 위치에서 아이템 제거
const [movedItem] = plans.splice(sourceIndex, 1);
// ⚠️ 인덱스 보정 (중요!)
// 아이템을 제거하면 뒤쪽 인덱스가 모두 1씩 줄어듦
// 예: [A,B,C,D]에서 B(1)를 제거 → [A,C,D]
// 원래 D 뒤(insertIndex=4)에 넣으려면 → 실제로는 3에 삽입
const finalIndex = insertIndex > sourceIndex
? insertIndex - 1
: insertIndex;
// 계산된 위치에 아이템 삽입
plans.splice(finalIndex, 0, movedItem);
newDayData[sourceDay].plans = plans;
return newDayData;
});
}
} else {
// ============================================
// Case 2: 다른 Day로 이동
// ============================================
setDayData((prevData) => {
const newDayData = { ...prevData };
// 원본 Day에서 아이템 제거
const [movedItem] = newDayData[sourceDay].plans.splice(sourceIndex, 1);
// 새 Day에 맞게 아이템 정보 업데이트
const newItem = {
...movedItem,
day: targetDay,
// ⚠️ key 재생성 필수!
// React가 같은 key를 다른 리스트에서 발견하면
// 예기치 않은 렌더링 문제가 발생할 수 있음
key: `${targetDay}-${Date.now()}`
};
// 타겟 Day의 계산된 위치에 삽입
newDayData[targetDay].plans.splice(insertIndex, 0, newItem);
return newDayData;
});
}
}
// 3. Floating Card 정리 (페이드아웃 애니메이션 등)
// ...
}, [/* deps */]);
인덱스 보정이 필요한 이유
같은 Day 내에서 아이템을 이동할 때 주의할 점이 있다:
그래서 insertIndex > sourceIndex일 때는 1을 빼줘야 한다.
다음 편에서
지금까지 레이아웃 측정과 드롭 타겟 계산, 기본적인 드래그 상태 관리를 구현했다.
다음 편에서는:
- Floating Card: Portal 패턴으로 드래그 중인 카드를 최상위에 렌더링하기
- 자동 스크롤: 화면 경계에서 자동으로 스크롤되게 하기
- 트러블슈팅: 구현하면서 만났던 함정들
을 다룰 예정이다.
기대부탁 만반잘부!