FLIP
First / Last / Invert / Play
First: UI 레이아웃 변경전의 위치와 크기를 저장(prevRect : el.getBoundingClient();) 애니메이션의 시작위치이다.
React에서 구현: 주로 useLayoutEffect를 사용하여 items (props)가 변경되기 전의 DOM 위치를 캡처합니다. 이때, useRef를 사용하여 이전 items 배열의 값을 추적하는 것이 일반적입니다.
Last : UI 레이아웃 변경후의 위치와 크기를 저장(currRect : el.getBoundingClient();)
애니메이션의 끝위치이다.
React에서 구현: 주로 useLayoutEffect 내부에서, items 배열이 업데이트된 후 (items prop 변화 감지) getBoundingClientRect()를 호출하여 현재 DOM 요소들의 위치를 측정합니다.
Invert : 'Last' 위치를 캡처한 직후, 브라우저가 화면을 그리기 전에 실행됩니다.
React에서 구현: Last에서 캡처한 현재 위치와 First에서 캡처한 이전 위치 간의 차이(델타 값: dx, dy)를 계산합니다.
이 델타 값을 사용하여 CSS transform: translate() 속성을 적용, UI 요소를 'First' 위치에 다시 위치시킵니다.
이때 transition: none을 적용하여 이 '뒤집기' 과정이 사용자에게 보이지 않고 즉시 일어나도록 합니다.
목표: UI 요소가 "움직이지 않은 것처럼" 보이게 착시 효과를 주는 것입니다. 사용자는 실제로는 요소가 Last 위치로 점프했지만, 그 즉시 First 위치로 되돌려졌기 때문에 그 점프를 인지하지 못합니다.
Play : 'Invert' 과정이 완료된 후, 다음 브라우저 애니메이션 프레임에서 실행됩니다.
React에서 구현: requestAnimationFrame을 사용하여 이 단계를 지연시킵니다 (보통 requestAnimationFrame을 두 번 중첩).
transform: translate(0, 0)으로 변경하여 UI 요소를 다시 'Last' 위치로 이동시킵니다.
이때 transition 속성을 다시 활성화하여 'First'에서 'Last'로의 부드러운 애니메이션을 만듭니다.
목표: 착시 효과가 아닌, 실제 부드러운 애니메이션을 사용자에게 보여주는 것입니다.
Hook
import { useLayoutEffect, useRef } from "react";
/**
* FLIP 애니메이션 훅
* 1. First: 애니메이션 시작 전 요소의 초기 위치와 크기를 저장
* 2. Last: 상태 변경 후 요소의 새로운 위치와 크기를 계산
* 3. Invert: 요소가 처음 위치에서 마지막 위치로 이동하는 데 필요한 변환을 계산
* 4. Play: 변환을 적용하고 애니메이션을 재생
*/
function useReorderAnimation<T, K extends string | number>(
array: T[],
keyExtractor: (item: T) => K
) {
const firstRectRefs = useRef<Map<K, DOMRect>>(new Map());
const itemRefs = useRef<Map<K, HTMLDivElement>>(new Map());
// store first rects
const storePreviousRects = () => {
const map = new Map<K, DOMRect>();
itemRefs.current.forEach((el, key) => {
if (el) map.set(key, el.getBoundingClientRect());
});
firstRectRefs.current = map;
};
// useReorderAnimation.tsx
const createAnimatedAction = <A extends unknown[]>(
stateUpdateFn: (...args: A) => void | Promise<void>
) => {
return {
run: (...args: A) => {
// 새로운 함수를 반환
storePreviousRects();
void stateUpdateFn(...args);
},
};
};
// Last , Invert , Play
useLayoutEffect(() => {
const currentRects = new Map<K, DOMRect>();
itemRefs.current.forEach((el, key) => {
if (el) currentRects.set(key, el.getBoundingClientRect());
});
itemRefs.current.forEach((el, key) => {
const firstRect = firstRectRefs.current.get(key);
const lastRect = currentRects.get(key);
if (!firstRect || !lastRect) return;
const dx = firstRect.left - lastRect.left;
const dy = firstRect.top - lastRect.top;
if (dx !== 0 || dy !== 0) {
el.style.transition = "transform 0s";
el.style.transform = `translate(${dx}px, ${dy}px)`;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
el.style.transition = "transform 300ms ease";
el.style.transform = "translate(0, 0)";
});
});
}
});
console.log("currentRects ==== ", currentRects);
}, [array]);
return {
firstRectRefs,
itemRefs,
createAnimatedAction,
storePreviousRects,
};
}
export default useReorderAnimation;
실사용 코드
import { useState } from "react";
import useReorderAnimation from "./hooks/useReorderAnimation";
import { Button } from "../../components/ui/button";
interface IProps {}
function FlIPList(props: IProps) {
const [items, setItems] = useState([
{ id: 100, text: "A" },
{ id: 2200, text: "B" },
{ id: 3300, text: "C" },
]);
const { itemRefs, createAnimatedAction } = useReorderAnimation(
items,
(item) => item.id
);
const reverseOrder = () => {
createAnimatedAction(() => {
setItems([...items].reverse());
}).run();
};
const moveItemToTop = (idx: number) => {
const nextItems = [...items];
const [movedItem] = nextItems.splice(idx, 1);
nextItems.unshift(movedItem);
createAnimatedAction(() => {
setItems(nextItems);
}).run();
};
const rotateItemBackward = () => {
createAnimatedAction(() => {
setItems((prev) => {
if (prev.length === 0) return prev;
const [first, ...rest] = prev;
return [...rest, first];
});
}).run();
};
const rotateItemsForward = () => {
createAnimatedAction(() => {
setItems((prev) => {
if (prev.length === 0) return prev;
const last = prev[prev.length - 1];
return [last, ...prev.slice(0, -1)];
});
}).run();
};
return (
<div style={{}}>
<Button
variant={"outline"}
// onClick={() => setItems([...items].reverse())}
onClick={reverseOrder}
>
Reverse
</Button>
<Button
variant={"outline"}
// onClick={() => setItems([...items].reverse())}
onClick={rotateItemBackward}
>
rotateItems(역방향)
</Button>
<Button
variant={"outline"}
// onClick={() => setItems([...items].reverse())}
onClick={rotateItemsForward}
>
rotateItems(순방향))
</Button>
<div className="relative">
<div style={{}}>
{items.map((item, index) => (
<div
key={item.id}
ref={(el) => {
if (el) {
itemRefs.current.set(item.id, el);
} else {
itemRefs.current.delete(item.id);
}
}}
style={{
padding: "12px",
margin: "4px 0",
background: "#eee",
borderRadius: "6px",
}}
>
<Button
onClick={() => {
moveItemToTop(index);
}}
>
moveItemToTop
</Button>
{item.text}
</div>
))}
</div>
</div>
</div>
);
}
export default FlIPList;