React로 Typing Animation 만들기

김용현·2024년 2월 29일

LESSER 개발일지

목록 보기
4/7
post-thumbnail

개발 배경

프로젝트의 의도 재밌게 전달하기 위해 Typing Animation을 넣어보자!

프로젝트의 UI/UX를 구상하던 중 어떻게 하면 우리의 프로젝트 의도가 담긴 문장을 사람들이 집중해서 보게할까에 대해 고민했었고, "Typing Animation을 넣어서 시선을 끌어보자!"라는 결론을 얻었습니다.

아이디어를 떠올렸을 당시에는 굉장히 재밌다고 생각했지만, 막상 이를 구현하려 하니 어떻게 구현해야 할지, 그리고 어떻게 좋은 방법으로 구현해야 할지를 떠올리는 게 굉장히 막막했습니다. 이어지는 글의 내용은 제가 애니메이션 기능을 무작정 개발해보고 그 안에서 문제점들을 발견/개선해나가는 과정들을 담았습니다.
(결과물을 원하시는 분은 마지막 최종 코드 항목을 참조해주세요)

개발 목표

1. Typing Animation을 만들기

한 글자씩 차례대로 출력
타이핑 애니메이션의 가장 기본인 한 글자씩 화면에 출력되도록 만들어야 합니다.

복수의 줄에 대해 애니메이션을 적용
일반적인 Typing Animation 코드와 다른 점은 (다른 태그에 있는)복수의 문장에 애니메이션을 적용해야 합니다. 특히, 앞 문장이 끝난 이후 다음 문장의 애니메이션이 시작하는 기능이 있어야 합니다.

2. 재사용성을 고려하기

컴포넌트화하여 여러 상황에서 사용할 수 있도록 하자
애니메이션을 단순히 목표하는 화면에만 적용하는 것이 아니라, 컴포넌트와 같은 구조로 만들어 많은 개발자들이 다양한 상황에서 사용할 수 있는 코드를 개발하고 싶었습니다. 이를 위해 아래의 조건을 만족해야 합니다.

  • 애니메이션이 출력하는 구조를 쉽게 사용할 수 있어야 한다.
  • 사용자가 텍스트 타이핑 되는 시간(애니메이션 프레임)을 조절 할 수 있어야 한다.

3. 성능 최적화를 고려할 것

코드가 동작하는 동안 불필요한 연산과 렌더링을 가능한한 최소화하여 애니메이션이 너무 많은 사용자의 리소스를 잡아먹지 않기를 바랐습니다.

개발 과정

0. 구현 방식 정하기

화면에 보이는 문장에 변화를 주기 위해 일정 시간(프레임)마다 텍스트를 저장하는 상태(state)에 글자를 하나씩 넣어주는 방법으로 구현하고자 했습니다.

const [typingText, setTypingText] = useState<string>("");

// 특정 프레임동안 반복해서 typingText state에 text를 추가하는 코드
...

return (
  <div>
  	// typingText에 글자가 하나씩 추가될 때마다, 화면이 새롭게 렌더링
    {typingText}
  </div>
 )

1. setInterval을 이용한 애니메이션

그렇다면 어떻게 state값에 변화를 주어야 할까요? 가장 먼저 생각한 방법은 setInterval을 통해서 일정한 시간을 통해서 state 값을 바꾸는 방법입니다.

  • useEffect를 통해 setInterval을 등록하고 setInterval에 등록된 시간이 흐르면 typingText의 상태에 새로운 단어를 하나씩 추가하는 방식으로 작성해보았습니다.
// useEffect를 통해 setInterval을 등록하는 코드
useEffect(() => {
  let timer = setInterval(() => {
    setTypingText((state) => {
      if (text.length <= textIndex.current) return state;
      const newState = text.slice(0, textIndex.current) + "⏐";
      textIndex.current += 1;
      return newState;
    });
  }, frame);
  return () => clearInterval(timer);
}, [text, timer]);

코드의 문제점
1. setInterval은 현재 실행되고 있는 환경에 의존하기 때문에, 개발자가 설정한 frame에 맞추어 정확하게 동작하지 않을 수 있습니다.
2. 애니메이션이 끝나고 난 후에도, setInterval이 계속해서 동작하고 있습니다. 이는 사용자의 불필요한 리소스를 사용하게 됩니다.

애니메이션이 끝났지만 콘솔창에서 setInterval이 실행되고 있음을 알 수 있다.

2. requestAnimationFrame 적용하기

앞선, setInterval을 사용한 코드의 문제를 개선하기 위해 requestAnimationFrame을 사용하여 상태값을 바꾸어보기로 했습니다.

requestAnimationFrame을 사용하는가?
1. requestAnimationFrame을 사용하면 재귀적으로 수행하여, 만약 추가적으로 실행할 필요가 없을 경우 재귀를 탈출하여 실행을 중단할 수 있다
→ 백그라운드에서 불필요한 함수 실행을 멈출 수 있다.
2. requestAnimationFrame은 repaint가 일어날 때 콜백이 호출되도록 동작하기 때문에, setInterval과 달리 call stack이 빌때까지 대기하는 등의 영향을 받지 않는다.
→ 상대적으로 정확한 frame 단위로 동작하게 만들 수 있다.
3. 또한, setInterval은 화면을 보고 있지 않는 순간에서도 코드가 백그라운드에서 호출되어 실행되지만, requestAnimationFrame은 화면이 repaint되지 않는다면, 대기하기 때문에 사용자의 resource 관점에서 더욱 효율적이다.

  • requestAnimationFrame에 파라미터로 전달할 콜백 함수가 실행될때마다 TypingText 상태값을 변화시키는 방식으로 구현했습니다.
  • TypingText에 더 이상 넣을 단어가 없다면, 콜백 함수의 재귀를 종료하도록 하였습니다.
// requestAnimationFrame에 넣을 콜백을 구현
const animationCallback = () => {
  setTypingText((state) => {
    const newState = text.slice(0, textIndex.current) + "⏐";
    textIndex.current += 1;
    return newState;
  });

  // 더 이상 넣을 단어가 없다면 재귀 종료
  if (textIndex.current >= text.length) return;
  requestAnimationFrame(animationCallback);
};

// useEffect를 통해 애니메이션 실행
useEffect(() => {
  let animeId = requestAnimationFrame(animationCallback);

  return () => {
    cancelAnimationFrame(animeId);
  };
}, [text]);

코드의 문제점

  • 현재 코드는 requestAnimationFrame을 통해 구현되었기 때문에, 화면이 repaint 될 때마다 글자가 더해지게 됩니다. 애니메이션이 너무 빠르게 적용되는 문제와 함께, 개발자가 원하는 프레임으로 조절할 수가 없습니다.

3. 애니메이션 프레임 조절하기

처음에는 애니메이션의 프레임을 조절하기 위해 재귀함수로 동작하는 콜백함수의 파라미터를 활용하려 했습니다. 하지만 막상 구현하려 보니, 저희가 requestAnimationFrame에 전달하는 콜백 함수에는 파라미터를 설정할 수가 없었습니다. 그렇다면 우리는 어떻게 애니메이션 프레임을 조절할 수 있을 까요?

콜백 함수에는 requestAnimationFrame()이 콜백 함수들의 실행을 시작할 시점을 나타내는 performance.now() (en-US) 에 의해 반환되는 것과 유사한 DOMHighResTimeStamp (en-US) 단일 인수가 전달됩니다.
출처 : MDN Window: requestAnimationFrame() method

requestAnimationFrame에 전달되는 콜백함수는 파라미터로 시작한 시점의 값(이하, timeStamp)을 전달 받을 수 있습니다. 이 timeStamp를 이용하여 일종의 스로틀을 구현한다면 저희가 원하는 프레임마다 애니메이션을 동작하도록 할 수 있습니다.

  1. 애니메이션이 실행된 timeStamp를 기록해둡니다(이하, lastTimeStamp).
  2. 콜백 함수가 실행될 때마다 전달 받은 timeStamp와 lastTimeStamp 사이의 시간 차를 구합니다.(이하 elapsedTime)
  3. elapsedTime이 frame과 같거나 크면, 애니메이션을 실행합니다.
  4. 더 출력할 단어가 없을 때까지, 1~3 과정을 반복합니다.
// callback 함수
const lastTimeStamp = useRef<number | null>(null);

const animationCallback = (timeStamp: number) => {
  if (lastTimeStamp.current === null) {
    lastTimeStamp.current = timeStamp;
  }

  const elapsedTime = timeStamp - lastTimeStamp.current;
	
  // timeStamp를 측정해서, frame만큼 시간이 지날 경우 코드를 실행
  if (elapsedTime > frame) {
    lastTimeStamp.current = timeStamp;
    setTypingText((state) => {
      const newState = state + text[textIndex.current];
      textIndex.current += 1;
      return newState;
    });
  }

  if (textIndex.current >= text.length - 1) return;
  requestAnimationFrame(animationCallback);
};

코드의 문제점

  • 이 코드를 그대로 사용한다면, 복수의 문장들이 순서대로 출력되지 않고 한번에 출력되게 될 것입니다. 그렇기 때문에, 복수의 문장의 애니메이션을 차례로 동작할 수 있도록 설계해야 합니다.

4. 복수의 문장을 차례대로 출력하도록(1)

복수의 문장을 출력하기 위해 가장 먼저 떠올린 방법은, 특정 시간 후에 애니메이션이 동작하도록 하는 방법이었습니다. 이를 위해 Typing Text의 파라미터에 startTime 값을 전달하고, 이 시간이 흐른 후에 애니메이션을 시작하는 방식으로 구현해보았습니다.

  1. useEffect로 startTime의 시간만큼 대기하는 delayingAnimationCallback 함수를 작성합니다.
  2. startTime의 시간이 흐르면 delayingAnimationCallback 함수는 내부에서 애니메이션을 적용하는 콜백인 animationCallback을 실행합니다.
// startTime까지 대기하도록 하는 콜백함수
const delayingAnimationCallback = (timeStamp: number) => {
  if (lastTimeStamp.current === null) {
    lastTimeStamp.current = timeStamp;
  }

  const elapsedTime = timeStamp - lastTimeStamp.current;
	
  // 대기시간을 기다리고 난 후, animationCallback 함수를 실행
  if (elapsedTime >= startTime) {
    lastTimeStamp.current = null;
    requestAnimationFrame(animationCallback);
  } else {
    // 시간이 충분히 지나지 않았을 경우, 다시 대기
    requestAnimationFrame(delayingAnimationCallback);
  }
};

// useEffect에선 delayingAnimationCallback을 먼저 실행
useEffect(() => {
  let animeId = requestAnimationFrame(delayingAnimationCallback);

  return () => {
    cancelAnimationFrame(animeId);
  };
}, [text]);

코드의 문제점

  • 개발자가 앞선 문장의 종료시간을 예측해서 입력하는 구조이다 보니, 일일이 대기 시간을 문장마다 넣어주어야 하는 불편함이 있습니다. 특히, 문장이 많아질 수록 이러한 불편함은 더욱 누적될 것입니다.
  • 화면에 애니메이션이 동작하지 않더라도, 대기 시간을 계산하는 과정 때문에 불필요한 사용자 리소스를 소모합니다. 위의 코드를 예시로 생각해보면, "First Line"이라는 문장만 애니메이션이 출력되더라도 "Second Line" 컴포넌트와 "Third Line" 컴포넌트는 delayingAnimationCallback이 실행되어 대기 시간을 계산하고 있기 때문에 불필요한 리소스 소모가 발생합니다.
    첫번째 문장 애니메이션 중 두번째/세번째 문장도 대기 시간을 계산하고 있다.

5. 복수의 문장을 차례대로 출력하도록(2)

앞선 문제점을 해결하기 위해 "대기시간을 계산하는 것 대신에, 애니메이션이 끝나면 상태값을 바뀌는 플래그를 같이 반환하면 어떨까?"라는 아이디어를 떠올렸습니다.

이제 저의 코드는 단순히 애니메이션이 적용된 컴포넌트 뿐만 아니라, 애니메이션이 끝났음을 알리기 위한 플래그값을 가진 상태도 전달해야 합니다. 그렇기 때문에 이 두가지를 생성해서 개발자에게 전달하는 리액트 커스텀 훅으로 개발 방향을 바꾸게 되었습니다.

  1. 커스텀 훅 useTypingAnime는 파라미터로, 기존의 컴포넌트가 전달받던 파라미터와 함께 애니메이션의 시작을 알려주는 bool 타입의 플래그 상태(이하 flag)를 전달합니다.
  2. useTypingAnime는 내부에 애니메이션이 끝났는지를 알려주는 플래그 상태인 animeFinishFlag와 이 값을true로 바꾸어주는 setAnimeFinished 함수를 작성합니다.
  3. useTypingAnimeTypingText에 기존의 text, frame와 함께 setAnimeFinishedflag를 전달합니다.
// text : 애니메이션으로 적고 싶은 글귀
// frame : 애니메이션이 동작하는 프레임
// flag : 앞선 애니메이션이 종료되었는가 ? true : false
const useTypingAnime = (text: string, frame: number, flag?: boolean) => {
  // 생성한 컴포넌트의 애니메이션이 끝났는지를 외부에 전달하기 위한 상태값
  const [animeFinishFlag, setAnimeFinishFlag] = useState<boolean>(false);
  const setAnimeFinished = () => {
    setAnimeFinishFlag(true);
  };
  
  // 애니메이션을 구현할 컴포넌트 생성
  const TypingTextDiv = () => {
    return TypingText({ text, frame, setAnimeFinished, flag });
  };
  // 생성한 컴포넌트와 함께 컴포넌트의 애니메이션 종료 플래그를 같이 반환!
  return { animeFinishFlag, TypingTextDiv };
};
  1. TypingText의 애니메이션을 실행하는 useEffect의 dependency에 flag를 두어, flagtrue가 된다면 애니메이션을 실행하도록 코드를 작성했고, 애니메이션이 끝나는 마지막 재귀 코드에서 setAnimeFinished를 실행시키게 됩니다.
// TypingText 컴포넌트
useEffect(() => {
  let animeId: number;
  // 자체 애니메이션이 끝났을 경우 더 이상 동작하지 않게 하기 위한 조건문
  if (animeFinishFlag) {
    setTypingText(text);
  } else if (flag) {
    // 앞 문장의 애니메이션이 끝나면, 애니메이션을 시작하게 하기 위한 조건문
    animeId = requestAnimationFrame(animationCallback);
  }

  return () => {
    cancelAnimationFrame(animeId);
  };
// dependency에 flag를 설정
}, [flag]);

이 코드를 사용할 때에는 개발자가 원하는 문구, 애니메이션 프레임, 그리고 앞에서 먼저 수행되는 애니메이션의 종료 플래그(없다면 넣지 않아도 됩니다)을 커스텀 훅에 전달하는 것으로 간단하게 연속적인 애니메이션을 만들 수 있습니다.

최종 코드

// 예시 코드는 codepen을 통해 작성되었습니다.

import React from "https://esm.sh/react";
import ReactDOM from "https://esm.sh/react-dom";

const TypingTextComponent = ({
  text,
  frame,
  setAnimeFinished,
  animeFinishFlag,
  flag = true,
}: {
  text: string;
  frame: number;
  setAnimeFinished: () => void;
  animeFinishFlag: boolean;
  flag?: boolean;
}) => {
  const [typingText, setTypingText] = React.useState<string>("");
  const textIndex = React.useRef<number>(0);
  const lastTimeStamp = React.useRef<number | null>(null);

  const animationCallback = (timeStamp: number) => {
    if (lastTimeStamp.current === null) {
      lastTimeStamp.current = timeStamp;
    }

    const elapsedTime = timeStamp - lastTimeStamp.current;

    if (elapsedTime > frame) {
      lastTimeStamp.current = timeStamp;
      setTypingText((state) => {
        const newState = state + text[textIndex.current];
        textIndex.current += 1;
        return newState;
      });
    }

    if (textIndex.current >= text.length) {
      setAnimeFinished();
      console.log("finish!");
      return;
    }
    requestAnimationFrame(animationCallback);
  };

  React.useEffect(() => {
    let animeId: number;
    if (animeFinishFlag) {
      setTypingText(text);
    } else if (flag) {
      animeId = requestAnimationFrame(animationCallback);
    }

    return () => {
      cancelAnimationFrame(animeId);
    };
  }, [flag]);
  return <p>{typingText}</p>;
};

const useTypingAnime = (text: string, frame: number, flag?: boolean) => {
  const [animeFinishFlag, setFlageState] = React.useState<boolean>(false);
  const setAnimeFinished = () => {
    setFlageState(true);
  };

  const TypingTextDiv = () => {
    return TypingTextComponent({ text, frame, setAnimeFinished, animeFinishFlag, flag });
  };

  return { animeFinishFlag, TypingTextDiv };
};


const App = () => {
  const { animeFinishFlag : firstFlag, TypingTextDiv : FirstText } = useTypingAnime("First Line", 100)
    const { animeFinishFlag : secondFlag, TypingTextDiv : SecondText } = useTypingAnime("Second Line", 100, firstFlag)
      const { TypingTextDiv : ThirdText } = useTypingAnime("Third Line", 100, secondFlag)
  return (
    <div>
      <FirstText />
      <SecondText />
      <ThirdText />
    </div>
  )
}

ReactDOM.render(<App />, document.querySelector(".container"));

마무리

더 개선할 점들

  • 다음 문장의 애니메이션이 끝날 때, 이미 완료된 문장도 리페인트가 발생하고 있습니다. 이런 화면 렌더링의 불필요한 요소를 덜어낼 필요가 있습니다.

참고자료

리액트 타이핑효과 커스텀 훅 만들기
MDN: Window: requestAnimationFrame() method

profile
함께 일하고 싶은 개발자가 되기위해 노력 중입니다.

0개의 댓글