React Native 칸반보드 드래그 앤 드롭 구현기 (3)
React Native 칸반보드 드래그 앤 드롭 구현기(3)
서론
2편에서는 레이아웃 측정과 드롭 타겟 계산, 기본적인 드래그 상태 관리를 구현했다.
이번 편에서는 드래그 앤 드롭의 시각적 완성도를 높이는 핵심 기능을 다룬다:
- Floating Card - Portal 패턴으로 드래그 중인 카드를 최상위에 렌더링
Floating Card: Portal 패턴
왜 Portal이 필요한가?
드래그 앤 드롭에서 가장 까다로운 부분 중 하나는 드래그 중인 카드를 어디에 렌더링할 것인가다.
처음에는 단순하게 생각했다:
"그냥 원래 카드를 드래그하면 되지 않나?"
하지만 실제로 해보니 여러 문제가 발생했다:
해결책: Portal 패턴
Portal은 React에서 컴포넌트를 DOM 트리의 다른 위치에 렌더링하는 패턴이다. React Native에서는 직접 구현해야 하지만, 원리는 같다:
FloatingPortal 구현하기
1단계: Context 생성
먼저, 어디서든 Floating Card를 생성/제거할 수 있도록 Context를 만든다.
// FloatingPortal.tsx
import React, { useCallback, useRef, useState } from 'react';
import { Animated, View } from 'react-native';
// ========================================
// 타입 정의
// ========================================
type Plan = {
type: string;
image?: string;
place?: string;
address?: string;
};
type Layout = {
x: number;
y: number;
width: number;
height: number;
};
type Position = {
x: number;
y: number;
};
// ========================================
// Context 생성
// ========================================
// Context를 통해 하위 컴포넌트들이 Floating Card를 제어할 수 있게 함
export const FloatingPortalContext = React.createContext<{
// Floating Card를 생성하는 함수
createFloatingCard: (
item: Plan,
dayId: string,
index: number,
layout: Layout,
initialPosition: Position,
gestureState: { dx: number; dy: number }
) => void;
// Floating Card를 제거하는 함수
removeFloatingElement: () => void;
// 드래그 이동량을 반영할 Animated 값
floatingPan: Animated.ValueXY;
// 페이드인/아웃을 위한 투명도 Animated 값
floatingOpacity: Animated.Value;
} | null>(null);
2단계: Provider 구현
이제 실제로 Floating Card를 렌더링하는 Provider를 만든다.
// FloatingPortal.tsx (계속)
export const FloatingPortalProvider = ({ children }: { children: React.ReactNode }) => {
// ========================================
// 상태 관리
// ========================================
// 현재 표시 중인 Floating Card (없으면 null)
const [floatingElement, setFloatingElement] = useState<React.ReactElement | null>(null);
// 드래그 이동량을 추적하는 Animated 값
// useRef로 감싸서 리렌더링해도 동일한 인스턴스 유지
const floatingPan = useRef(new Animated.ValueXY()).current;
// 투명도를 추적하는 Animated 값 (페이드인/아웃 효과용)
const floatingOpacity = useRef(new Animated.Value(0)).current;
// ========================================
// Floating Card 생성 함수
// ========================================
const createFloatingCard = useCallback((
item: Plan, // 드래그 중인 아이템 데이터
dayId: string, // 원래 속한 Day ID
index: number, // 원래 인덱스 (표시용)
layout: Layout, // 카드의 크기 정보
initialPosition: Position, // 터치 시작 위치 (화면 절대 좌표)
gestureState: { dx: number; dy: number } // 초기 이동량 (보통 0, 0)
) => {
// ----------------------------------------
// 핵심 포인트: 위치 계산
// ----------------------------------------
// initialPosition은 손가락의 화면 절대 좌표 (pageX, pageY)
// 카드의 중심이 손가락 위치에 오도록 배치
//
// 예시:
// - 손가락 위치: (200, 300)
// - 카드 크기: width=180, height=100
// - 카드 left: 200 - 180/2 = 110
// - 카드 top: 300 - 100/2 = 250
const floatingCard = (
<Animated.View
style={[
{
// 절대 위치로 렌더링 (부모와 무관하게 화면 기준)
position: "absolute",
// 카드 중심이 손가락 위치에 오도록
left: initialPosition.x - layout.width / 2,
top: initialPosition.y - layout.height / 2,
// 원본 카드와 동일한 크기
width: layout.width,
height: layout.height,
// 최상위에 렌더링
zIndex: 9999,
},
{
// Animated 값들을 스타일에 바인딩
opacity: floatingOpacity,
transform: [
// 드래그 이동량만큼 이동
// gestureState.dx/dy가 변할 때마다 자동으로 업데이트됨
{ translateX: floatingPan.x },
{ translateY: floatingPan.y },
],
},
]}
>
{/* 카드 UI - 원본과 동일하게 */}
<View style={styles.rowContainer}>
{/* ... 카드 내용 렌더링 ... */}
</View>
</Animated.View>
);
// state에 저장하면 렌더링됨
setFloatingElement(floatingCard);
}, [floatingOpacity, floatingPan]);
// ========================================
// Floating Card 제거 함수
// ========================================
const removeFloatingElement = useCallback(() => {
setFloatingElement(null);
}, []);
// ========================================
// 렌더링
// ========================================
return (
<FloatingPortalContext.Provider
value={{
createFloatingCard,
removeFloatingElement,
floatingPan,
floatingOpacity
}}
>
{/* 원래 앱 컨텐츠 */}
<View style={{ flex: 1 }}>
{children}
{/* Floating Layer - 항상 최상위에 렌더링 */}
{floatingElement && (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
// ⚠️ 중요: 터치 이벤트를 통과시킴
// 이게 없으면 Floating Card가 터치를 가로채서
// 아래 컴포넌트들이 터치를 받지 못함
pointerEvents: "none",
// Android에서 최상위 렌더링 보장
elevation: 9999,
}}
>
{floatingElement}
</View>
)}
</View>
</FloatingPortalContext.Provider>
);
};
useDragDrop 훅: 드래그 생명주기 관리
이제 useDragDrop 훅에서 Floating Card를 제어한다.
이 훅은 드래그 앤 드롭의 전체 생명주기를 관리하는 핵심 훅이다.
// useDragDrop.ts
export const useDragDrop = ({
scrollOffsetRef, // 현재 스크롤 위치 (좌표 보정용)
measureScrollViewPosition, // ScrollView 위치 재측정 함수
remeasureDayLayouts, // Day 레이아웃 재측정 함수
getDropTarget, // 드롭 타겟 계산 함수
stopAutoScroll, // 자동 스크롤 중지 함수
FloatingPortalContext // Floating Card 제어용 Context
}) => {
// 현재 드래그 중인 아이템 정보
const [draggingItem, setDraggingItem] = useState<DraggingItem | null>(null);
// Floating Card 데이터 (ref로 관리하여 리렌더링 방지)
const floatingCardDataRef = useRef<FloatingCardData | null>(null);
// Context에서 Floating Card 제어 함수들을 가져옴
const floatingPortal = useContext(FloatingPortalContext);
// ... 핸들러 함수들 ...
return {
draggingItem, // 외부에서 드래그 상태 확인용
handleDragStart, // PanResponder.onGrant에서 호출
handleDragMove, // PanResponder.onMove에서 호출
handleDragEnd, // PanResponder.onRelease에서 호출
};
};
이제 각 핸들러가 어떻게 Floating Card를 제어하는지 살펴보자.
handleDragStart: 드래그 시작 처리
사용자가 카드를 터치하면 호출되는 함수다. 드래그의 초기 설정을 담당한다.
const handleDragStart = useCallback(async (
item: Plan, // 드래그할 아이템 데이터
dayId: string, // 아이템이 속한 Day ID
index: number, // Day 내에서의 인덱스
cardLayout: Layout, // 카드의 크기 정보 (width, height)
initialPosition: Position // 터치 시작 위치 (pageX, pageY)
) => {
// ========================================
// Step 1: 드래그 상태 저장
// ========================================
// 어떤 아이템을 어디서 드래그 시작했는지 기록
// 나중에 드롭할 때 "어디서 왔는지" 알아야 데이터를 옮길 수 있음
setDraggingItem({
item,
sourceDay: dayId,
sourceIndex: index
});
// ========================================
// Step 2: 레이아웃 재측정
// ========================================
// 왜 필요할까?
// - 스크롤 위치가 변했을 수 있음
// - 다른 카드가 추가/삭제되어 Day 위치가 바뀌었을 수 있음
// - 정확한 드롭 타겟 계산을 위해 최신 레이아웃 정보가 필요
measureScrollViewPosition();
await remeasureDayLayouts();
// ========================================
// Step 3: Floating Card 생성
// ========================================
// 유효성 검사: 카드 크기가 없으면 렌더링 불가
if (!cardLayout.width || !cardLayout.height || !floatingPortal) return;
// ref에 데이터 저장 (리렌더링 없이 접근 가능)
floatingCardDataRef.current = {
item,
dayId,
index,
layout: cardLayout,
initialPosition,
gestureState: { dx: 0, dy: 0 }
};
// Portal을 통해 Floating Card 생성
// 이 순간 화면 최상위에 카드 복제본이 나타남
floatingPortal.createFloatingCard(
item,
dayId,
index,
cardLayout,
initialPosition,
{ dx: 0, dy: 0 } // 초기 이동량은 0
);
// ========================================
// Step 4: 페이드인 애니메이션
// ========================================
// 갑자기 나타나면 어색하니까 서서히 나타나게
// 0.9로 설정해서 원본과 살짝 구분되게 함
Animated.timing(floatingPortal.floatingOpacity, {
toValue: 0.9,
duration: 150,
useNativeDriver: true, // ✅ Native 스레드에서 실행 (60fps 보장)
}).start();
}, [scrollOffsetRef, measureScrollViewPosition, remeasureDayLayouts, floatingPortal]);
핵심 포인트:
async함수인 이유:remeasureDayLayouts()가 비동기이기 때문- ref를 사용하는 이유: 잦은 업데이트에도 리렌더링 방지
- 페이드인 효과로 자연스러운 전환 제공
원본 카드 처리
Floating Card가 나타날 때 원본 카드도 처리해줘야 한다. 그냥 두면 카드가 2개로 보이니까!
// DraggablePlanCard.tsx
const DraggablePlanCard = ({ /* ... */ isDragging }) => {
// 드래그 시작/종료에 따른 투명도 애니메이션
const cardOpacity = useRef(new Animated.Value(1)).current;
// PanResponder 내부에서...
const panResponder = useMemo(() => PanResponder.create({
onPanResponderGrant: (evt, gestureState) => {
// 드래그 시작: 원본 카드를 흐리게
Animated.timing(cardOpacity, {
toValue: 0.3, // 30% 불투명도로
duration: 150,
useNativeDriver: true,
}).start();
// ... onDragStart 호출 ...
},
onPanResponderRelease: (evt) => {
// 드래그 종료: 원본 카드 복원
Animated.timing(cardOpacity, {
toValue: 1, // 100% 불투명도로
duration: 200,
useNativeDriver: true,
}).start();
// ... onDragEnd 호출 ...
},
}), [/* deps */]);
return (
<Animated.View
style={{
opacity: cardOpacity, // Animated 값 바인딩
// 드래그 중일 때 z-index 낮춤 (Floating Card 아래로)
zIndex: isDragging ? 0 : 1,
}}
>
{/* 카드 내용 */}
</Animated.View>
);
};
Floating Card 위치 계산 원리
Floating Card가 화면에서 어떻게 위치를 잡고 이동하는지 살펴보자. 위치 계산은 초기 위치 설정과 드래그 중 위치 업데이트 두 단계로 나뉜다.
1. 초기 위치 설정 (handleDragStart → createFloatingCard)
드래그가 시작되면 Floating Card를 손가락 위치 중심에 배치한다.
코드에서의 적용:
// createFloatingCard 내부
<Animated.View
style={{
position: "absolute",
left: initialPosition.x - layout.width / 2, // 110
top: initialPosition.y - layout.height / 2, // 250
width: layout.width,
height: layout.height,
}}
>
2. 드래그 중 위치 업데이트 (handleDragMove)
손가락이 움직이면 **이동량(dx, dy)**을 transform으로 적용한다.
코드에서의 적용:
// handleDragMove 내부
floatingPortal.floatingPan.setValue({
x: gestureState.dx, // 50
y: gestureState.dy // 30
});
// createFloatingCard에서 미리 바인딩된 transform
style={{
transform: [
{ translateX: floatingPan.x }, // 50
{ translateY: floatingPan.y } // 30
]
}}
왜 초기 위치(left/top)와 이동량(transform)을 분리할까?
| 구분 | 초기 위치 (left/top) | 이동량 (transform) |
|---|---|---|
| 설정 시점 | 드래그 시작 시 한 번만 | 매 프레임마다 업데이트 |
| 값 타입 | 정적 숫자 | Animated 값 |
| 업데이트 방식 | 컴포넌트 재생성 필요 | setValue()로 즉시 반영 |
| 성능 | 리렌더링 발생 | 리렌더링 없음 (Native Driver) |
이렇게 분리하면 setValue()만 호출해서 위치를 업데이트할 수 있어서,
리렌더링 없이 60fps를 유지할 수 있다.
handleDragMove: 드래그 중 위치 업데이트
사용자가 손가락을 움직일 때마다 호출된다. Floating Card의 위치를 실시간으로 업데이트한다.
const handleDragMove = useCallback((
_x: number, // 현재 X 좌표 (사용하지 않음)
_y: number, // 현재 Y 좌표 (사용하지 않음)
gestureState: any, // PanResponder가 제공하는 제스처 정보
_evt: any, // 원본 이벤트 (사용하지 않음)
_initialPosition: Position // 초기 위치 (사용하지 않음)
) => {
// 유효성 검사
if (!gestureState || !floatingCardDataRef.current || !floatingPortal) return;
// ========================================
// 핵심: gestureState.dx/dy로 이동량 업데이트
// ========================================
//
// gestureState는 PanResponder가 자동으로 계산해주는 값:
// - dx: 드래그 시작점에서 현재까지 X축 이동량 (픽셀)
// - dy: 드래그 시작점에서 현재까지 Y축 이동량 (픽셀)
//
// 예시:
// - 시작 위치: (100, 200)
// - 현재 위치: (150, 250)
// - dx = 50, dy = 50
//
// Animated.ValueXY.setValue()를 호출하면
// Floating Card의 transform이 자동으로 업데이트됨
// → 리렌더링 없이 네이티브에서 직접 처리!
if (gestureState.dx !== undefined && gestureState.dy !== undefined) {
floatingPortal.floatingPan.setValue({
x: gestureState.dx,
y: gestureState.dy
});
}
}, [floatingPortal]);
handleDragEnd: 드래그 종료 및 드롭 처리
손가락을 떼면 호출된다. 드롭 위치를 계산하고 데이터를 이동시킨다.
const handleDragEnd = useCallback((
y: number, // 손가락을 뗀 Y 좌표 (드롭 위치 계산용)
dayData: any, // 현재 Day 데이터 (참조용)
setDayData: (updater: (prev: any) => any) => void // 데이터 업데이트 함수
) => {
// ========================================
// Step 1: 자동 스크롤 중지
// ========================================
// 화면 경계에서 자동 스크롤 중이었다면 멈춤
stopAutoScroll();
// 드래그 중인 아이템이 없으면 종료
if (!draggingItem) return;
// ========================================
// Step 2: 드롭 타겟 계산
// ========================================
// 현재 Y 좌표를 기반으로 어느 Day의 몇 번째에 드롭할지 결정
const dropTarget = getDropTarget(y);
if (dropTarget) {
const { dayId: targetDay, insertIndex } = dropTarget;
const { item, sourceDay, sourceIndex } = draggingItem;
// ========================================
// Step 3-A: 같은 Day 내에서 순서 변경
// ========================================
if (targetDay === sourceDay) {
// 의미 없는 이동 방지 (같은 위치 또는 바로 아래)
if (insertIndex !== sourceIndex && insertIndex !== sourceIndex + 1) {
setDayData((prevData: any) => {
const newDayData = { ...prevData };
const plans = [...newDayData[sourceDay].plans];
// 원래 위치에서 제거
const [movedItem] = plans.splice(sourceIndex, 1);
// 삽입 인덱스 보정
// 원래 위치보다 뒤로 이동하면, 제거로 인해 인덱스가 1 줄어듦
const finalInsertIndex = insertIndex > sourceIndex
? insertIndex - 1
: insertIndex;
// 새 위치에 삽입
plans.splice(finalInsertIndex, 0, movedItem);
newDayData[sourceDay].plans = plans;
return newDayData;
});
}
}
// ========================================
// Step 3-B: 다른 Day로 이동
// ========================================
else {
setDayData((prevData: any) => {
const newDayData = { ...prevData };
// 원래 Day에서 제거
const [movedItem] = newDayData[sourceDay].plans.splice(sourceIndex, 1);
// 새 Day에 맞게 아이템 정보 업데이트
const newItem = {
...movedItem,
day: targetDay, // Day 정보 변경
key: `${targetDay}-${Date.now()}` // 새로운 고유 키 생성
};
// 새 Day에 삽입
newDayData[targetDay].plans.splice(insertIndex, 0, newItem);
return newDayData;
});
}
}
// ========================================
// Step 4: Floating Card 정리
// ========================================
if (floatingPortal) {
// 페이드아웃 애니메이션
Animated.timing(floatingPortal.floatingOpacity, {
toValue: 0,
duration: 200,
useNativeDriver: true,
}).start(() => {
// 애니메이션 완료 후 실제로 제거
floatingPortal.removeFloatingElement();
floatingCardDataRef.current = null;
});
// 이동량 초기화 (다음 드래그를 위해)
floatingPortal.floatingPan.setValue({ x: 0, y: 0 });
}
// ========================================
// Step 5: 드래그 상태 초기화
// ========================================
setDraggingItem(null);
}, [draggingItem, getDropTarget, stopAutoScroll, floatingPortal]);
삽입 인덱스 보정이 필요한 이유:
드롭 타겟 계산 원리
getDropTarget(y) 함수가 어떻게 드롭 위치를 결정하는지 살펴보자.
(코드는 2편의 getDropTarget 섹션을 참고)
전체 흐름 정리
3편에 걸쳐 구현한 드래그 앤 드롭의 전체 흐름을 시각적으로 정리해보자.
1. 전체 아키텍처
2. 드래그 앤 드롭 생명주기
3. 핵심 컴포넌트 관계도
마치며
이번 편에서는 Portal 패턴을 활용해 Floating Card를 구현하고, 드래그 앤 드롭의 시각적 완성도를 높이는 방법을 살펴봤다.
핵심 포인트를 정리하면:
- Portal 패턴: 드래그 중인 카드를 최상위 레이어에 렌더링하여 z-index와 overflow 문제 해결
- 위치 계산 분리: 초기 위치(left/top)와 이동량(transform)을 분리하여 60fps 유지
- 원본 카드 처리: 투명도 조절로 자연스러운 시각적 피드백 제공
다음 편 예고
다음 편에서는 화면 경계에서 자동으로 스크롤되는 기능을 구현해보겠다.