[React] How does Animation work with React? - FLIP animation

Darcy Daeseok YU ·2025년 5월 21일

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;

profile
React, React-Native https://darcyu83.netlify.app/

0개의 댓글