공부 및 정리/프론트엔드

[React] 숫자 증가 애니메이션 만들기 (with. TypeScript)

언호 2022. 7. 7. 15:36

여러 웹 서비스를 이용하다 보면 숫자를 강조하는 방법 중 하나로 숫자가 빠르게 올라가는 애니메이션을 확인할 수 있습니다.

리액트를 이용하여 어떻게 구현할지에 대해 작성해보도록 하겠습니다.

 

 

❗️ 조건

 

1. 숫자는 0부터 시작해서 증가해야 합니다.
2. 숫자들이 주어진 시간 동안 증가하는 애니메이션을 보여주어야 합니다.
3. 시간이 흐를수록 숫자 증가량이 줄어들어야 합니다. (easeOut 효과)

 

 

📖 컴포넌트 및 변수 구성

컴포넌트와 변수는 해당 포스트 작성을 위해 임의로 작성된 내용입니다.

최종적으로 보여질 숫자와 텍스트를 변수로 저장하였습니다.

부모 컴포넌트를 하나 생성하고, 그 아래에 방문수, 좋아요, 댓글수가 보여질 컴포넌트를 반복문을 이용하여 생성하였습니다.

type TyContents = [string, number, string];

// 화면에 출력할 데이터 및 목표 숫자
const contents: TyContents[] = [
  ['방문수', 4782, '명'],
  ['좋아요', 3208, '개'],
  ['댓글수', 278, '개'],
];

// 텍스트가 보여질 컴포넌트
function Item({ item }: { item: TyContents; }) {
  return (
    <div>
      <p>{item[0]}</p>
      <p>
        {item[1]} {item[2]}
      </p>
    </div>
  );
}

// 3개(방문수, 좋아요, 댓글수) 컴포넌트를 담을 컨테이너
function Number() {
  return (
    <div style={ /* ... */ }>
      {contents.map((c: TyContents) => (
        <Item key={c[0]} item={c} />
      ))}
    </div>
  );
}

 

 

📘 숫자 증가 애니메이션

💡 접근 방식

숫자가 증가하도록 보이게 하기 위해서는 시간을 일정한 간격으로 분할하여 값을 변경 시켜 주어야겠다고 생각했습니다.

만약에 목표 숫자 500을 1초 동안 증가하도록 보여주어야 한다면, 1초를 0.1초씩 분할하면 10개의 구간이 나타납니다.

이때 각 구간마다 숫자를 50씩 증가시킨다면, 1초 후에는 50씩 10번 증가하여 500이 됩니다.

 

💻 코드

애니메이션을 시작할 때, new Date().getTime() 을 이용하여 현재 시간에 해당하는 값을 저장합니다.

이후 애니메이션이 진행하는 동안 시간을 비교하며 애니메이션을 계속 진행할지 여부를 판단하였습니다.

interface InParameter {
  startMS: number;
  duration: number;
  step?: number;
}

// 숫자 증가 커스텀 훅
function useNumberCount({
  startMS,                              // 애니메이션이 시작했을 때의 시간
  duration,                             // 애니메이션을 보여줄 총 시간 (ms 단위)
  step = Math.round(duration / 50),     // 시간을 분할할 간격 (ms 단위)
}: InParameter) {
  const [number, setNumber] = useState(0);
  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);

  useEffect(() => {
    // 현재 시간 - 시작 했을 때 시간 => 경과 시간
    const elapse = new Date().getTime() - startMS;

    if (elapse < duration) {
      timer.current = setTimeout(() => {
        setNumber(elapse);
      }, step);
    } else {
      setNumber(duration);
    }

    return () => {
      if (timer.current) {
        clearTimeout(timer.current);
      }
    };
  }, [number]);

  return number;
}

 

🏃‍♂️ 애니메이션 적용하기

위에서 제작한 커스텀 훅을 적용하는 방법에는 크게 두가지가 있습니다.

1. 숫자가 올라가는 각 컴포넌트에서 사용하기
    장점 - 컴포넌트 마다 애니메이션 시간을 다르게 적용 가능
    단점 - 컴포넌트 개수만큼 커스텀 훅이 중복적으로 발생하여 성능 이슈 발생 가능

2. 컴포넌트를 갖고 있는 컨테이너에서 사용하기
    장점 - 컴포넌트 하나에서 실행이 되어 중복적으로 실행되는 성능 이슈 X
    단점 - 컴포넌트 마다 애니메이션 시간을 다르게 적용 불가

 

현재 작성하는 포스트에는 3개의 숫자가 동일한 시간에 끝나야하므로 두 번째 방법을 적용하였습니다.

function Item({
  item,
  startNumber,
  num,
}: {
  item: TyContents;
  startNumber: number;
  num: number;
}) {
  return (
    <div>
      <p>{item[0]}</p>
      <p>
        {Math.round(startNumber + (item[1] * num) / duration)}{' '}
        {item[2]}
      </p>
    </div>
  );
}

function Number() {
  const num = useNumberCount({ startMS, duration, step: 60 });

  return (
    <div style={/* ... */}>
      {contents.map((c: TyContents) => (
        <Item key={c[0]} item={c} startNumber={0} num={num} />
      ))}
    </div>
  );
}

 

위의 과정을 통해 일정한 속도로 숫자가 증가하는 애니메이션을 완성하였습니다.

 

 

📕 easeOut 효과 적용하기

일정한 속도로 숫자가 증가하는 애니메이션을 완성하였으니, 시간이 흐를수록 천천히 증가하는 easeOut 효과를 적용해보겠습니다를

easeOut 또는 easeIn 과 같은 시간 흐름에 따른 변화량을 구하기 위해서는 계산 공식을 이용해야합니다.

Easing 함수 차트 시트를 접속하여 원하는 효과를 선택하고 적용하기 위한 함수 내용이 있습니다.

함수 내용을 참고하여 구현할 수 있습니다.

const easeOutQuart = (ms: number, duration: number) =>
  1 - (1 - ms / duration) ** 8;

function Item({
  item,
  startNumber,
  num,
}: {
  item: TyContents;
  startNumber: number;
  num: number;
}) {
  return (
    <div>
      <p>{item[0]}</p>
      <p>
        {Math.round(startNumber + item[1] * easeOutQuart(num, duration))}{' '}
        {item[2]}
      </p>
    </div>
  );
}

 

 

🔥 성능 개선

위 과정들을 통해 easeOut 효과가 적용된 숫자 증가 애니메이션이 구현되었습니다.

앞의 과정 중 커스텀 훅을 어느 컴포넌트에서 적용할지 선택을 하며 성능 이슈를 피할 수 있었습니다.

그러나 크롬의 리액트 개발자 도구를 이용하여 확인해보면 값이 변하지 않는 정적인 텍스트도 함께 재렌더링 되는것을 확인할 수 있습니다.

 

현재 예시의 경우에는 값이 변하지 않는 텍스트가 많지 않아 괜찮지만, 정적인 텍스트나 이미지가 존재하는 경우 성능 문제가 발생할 수 있습니다.

숫자가 증가하는 부분만 재렌더링 하기 위해 컴포넌트 재구성과 useMemo를 이용하겠습니다.

function Item({
  item
  startNumber,
  num,
}: {
  item: number;
  startNumber: number;
  num: number;
}) {
  return (
    <>{Math.round(startNumber + item * easeOutQuart(num, duration))} </>
  );
}

function Number() {
  const num = useNumberCount({ startMS, duration, step: 60 });

  return (
    <div style={/* ... */}>
      {contents.map((c: TyContents) => (
        <div>
          {useMemo(() => c[0], [])}
          <br />
          <Item key={c[0]} item={c[1]} startNumber={0} num={num} />
          {useMemo(() => c[2], [])}
        </div>
      ))}
    </div>
  );
}

 

약간의 컴포넌트 수정과 useMemo를 이용하여 불필요한 재렌더링을 제거하였습니다.

 

 

📚 마치며

실제 만화가 제작되기 위해서 그림 하나하나가 연속되어 나열되어 있고, 이를 빠르게 전환하면 부드러운 애니메이션이 제작 됩니다.

이런 방식을 이용하여 접근하여 구현 자체는 수월하게 진행이 되었습니다.

 

그러나 부드럽게 숫자를 보여주기 위해서는 빠르고 많은 상태 값 변화가 필요하였는데, 이렇게 된다면 성능 이슈가 발생할 수 있어 고민이 많았습니다.

여러 숫자의 증가를 하나의 컴포넌트에서 관리하여 useEffect 를 최소한으로 실행이 필요하였고, 불필요한 재렌더링을 방지하여 성능 이슈가 발생하지 않도록 구현하는 것을 목표로 하였고, 많은 고민을 했습니다.