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;