React Native 칸반보드 드래그 앤 드롭 구현기 (3)

Published2026.01.25
Read Time29 min read
Related Project: Fly:On

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 유지
  • 원본 카드 처리: 투명도 조절로 자연스러운 시각적 피드백 제공

다음 편 예고

다음 편에서는 화면 경계에서 자동으로 스크롤되는 기능을 구현해보겠다.