[FLIP] React Animation - hook

Darcy Daeseok YU ·2026년 6월 26일

FLIP Animation for React

with QuerySelector

import React, { useLayoutEffect, useRef } from 'react';

const FLIP_MS = 220;

/**
 * 재정렬 시 카드들이 점프하지 않고 부드럽게 자리를 찾도록 하는 FLIP 훅.
 * react-drag-listview는 드롭 시 배열만 재정렬 → 카드가 즉시 점프하는데,
 * 이동 전/후 위치를 측정해 역변환 후 transition으로 미끄러뜨린다.
 *
 * @param containerRef [data-pid] 카드들을 자식으로 갖는 컨테이너 ref
 * @param orderKey     순서가 바뀔 때마다 달라지는 키(effect 재실행 트리거)
 */
function useReorderFlip(containerRef: React.RefObject<HTMLElement>, orderKey: string) {
  // 이전 렌더의 위치 저장소. useRef → 리렌더에도 유지되고, 갱신해도 리렌더를 유발하지 않음.
  const prevRects = useRef<Map<string, DOMRect>>(new Map());

  useLayoutEffect(() => {
    const container = containerRef.current;
    if (!container) return;

    const nodes = Array.from(container.querySelectorAll<HTMLElement>('[data-pid]'));
    const nextRects = new Map<string, DOMRect>();
    const rafIds: number[] = [];

    nodes.forEach((node) => {
      const id = node.dataset.pid;
      if (id == null) return;

      // 직전 애니메이션이 안 끝났을 수 있으니, 측정 전 잔존 transform 제거(좌표 오염 방지).
      node.style.transition = 'none';
      node.style.transform = '';

      // [Last] 새 위치 측정
      const rect = node.getBoundingClientRect();
      nextRects.set(id, rect);

      // [First] 기억해둔 이전 위치
      const old = prevRects.current.get(id);
      if (!old) return;          // 처음 등장한 카드는 스킵
      const dx = old.left - rect.left;
      const dy = old.top - rect.top;
      if (!dx && !dy) return;    // 안 움직였으면 스킵

      // [Invert] 새 위치에서 옛 위치로 순간 되돌림(transition 없이)
      node.style.transform = `translate(${dx}px, ${dy}px)`;

      // [Play] 다음 프레임에 transition 켜고 transform 제거 → 제자리로 미끄러짐
      rafIds.push(
        requestAnimationFrame(() => {
          node.style.transition = `transform ${FLIP_MS}ms ease`;
          node.style.transform = '';
        })
      );
    });

    // 이번 위치를 다음 비교를 위해 저장
    prevRects.current = nextRects;

    // 언마운트/연속 재정렬 시 예약된 rAF 취소(stale 노드 조작 방지)
    return () => rafIds.forEach((rafId) => cancelAnimationFrame(rafId));
  }, [containerRef, orderKey]);
}

// ───────────────────────────────────────────────────────────
// 사용 예시
// ───────────────────────────────────────────────────────────

interface Process {
  process_id: number;
  process_name: string;
}

function ProcessPreviewList({ processes }: { processes: Process[] }) {
  const gridRef = useRef<HTMLDivElement>(null);

  // orderKey가 바뀔 때(=순서 변경) effect 재실행 → FLIP 발동
  useReorderFlip(
    gridRef,
    processes.map((p) => p.process_id).join(',')
  );

  return (
    <div ref={gridRef} style={{ display: 'flex', flexWrap: 'wrap', gap: 12 }}>
      {processes.map((p) => (
        // data-pid: FLIP이 "어디→어디로 갔는지" 매칭하는 안정 키(순서가 섞여도 불변).
        <div key={p.process_id} data-pid={p.process_id}>
          {p.process_name}
        </div>
      ))}
    </div>
  );
}

export default ProcessPreviewList;
profile
React, React-Native https://darcyu83.netlify.app/

0개의 댓글