[React] React로 글자 타이핑 애니메이션 구현하기(ft. Tailwind)

박기영·2022년 9월 27일
1

React

목록 보기
26/32
post-custom-banner

글자가 타이핑되는 애니메이션 효과는 라이브러리로 많이 나와있다.
라이브러리만 가져와서 무지성으로 쓰는 것보다는, 직접 구현해보는 것이 더 의미있으므로
알아본 자료들을 통해 얻은 정보를 기록해놓으려고 한다.

방법

  1. 하나의 완성된 문자열을 선언한다.
  2. 원하는 속도를 설정해서 setInterval 을 설정한다.
  3. 문자열의 인덱스에 맞춰 문자에 관련된 state를 변경한다.
  4. 문자에 관련된 state가 변경되면 count에 관련된 state를 증가시킨다.
  5. 완성된 문자열을 모두 순회할 때까지 반복한다.

글로만 보면 와닿지 않는다.
그래도, 필요한 것은 알겠다.

  1. 하나의 완성된 문자열
  2. 문자열을 보여주기 위한 state
  3. 문자열의 인덱스를 체크하기 위한 state

이 3가지를 활용하여 구현을 해보자!

타이핑 효과 구현

구현하고자 하는 문자열은 다음과 같다.

const completedTitle = "어떻게 공부해?";

이제 문자열을 담을 state를 만들어보자.

const [landingTitle, setLandingTitle] = useState("");

landingTitle 이라는 변수는 초기값이 빈 문자열이지만,
앞으로 연산을 통해 빈 문자열에서 한 글자씩 늘어날 것이다.
한 글자씩 늘어나는 것을 통해 타이핑 효과를 낼 수 있다.

이제 문자열의 인덱스를 나타낼 count를 만들자.

const [count, setCount] = useState(0);

초기값은 0으로, completedTitle 변수에 있는 문자열의 인덱스를 가르키게 될 것이다.

이제 setInterval 를 활용하여 completedTitle 에 있는 문자 하나하나를
설정한 시간 간격에 맞춰서 landingTitle 이라는 state에 넣어줄 것이다.
넣은 후에는 count 라는 state를 증가시켜 인덱스 증가를 구현한다.

참고로, React에서는 setIntervaluseState 등의 hooks가 잘 동작하지 않는다.
그 이유는 여기에 정리해놓았고, 그러한 이유로
필자는 useInterval 이라는 커스텀 hooks를 사용한다.

useInterval(() => {
  // 만약, count가 completedTitle의 길이와 같거나 커지면 반복을 멈춘다.
  if (count >= completedTitle.length) {
    return;
  }

  setLandingTitle((prev) => {
    // 빈 문자열("")은 false이므로 completedTitle의 가장 앞 글자가 result에 할당된다.
    // 그 뒤로는 landingTitle이 빈 문자열이 아니므로
    // 이전에 존재하던 것과 count번 인덱스에 존재하는 문자열을 합쳐서
    // 다시 result에 할당한다.
    let result = prev ? prev + completedTitle[count] : completedTitle[0];

    // count를 증가시킨다.
    setCount((prev) => prev + 1);

    // 연산된 result를 반환한다.
    return result;
  });
  
  // 150ms에 한번씩 연산이 진행된다.
  // 즉, 150ms에 한번씩 문자열이 늘어난다.(타이핑 효과)
}, 150);
<h1 className="text-[#e57373] text-3xl inline animate-typingCursor">
  {landingTitle}
</h1>

이제 150ms 간격으로 landingTitle이 한 글자씩 늘어나는 것을 볼 수 있을 것이다.

참고 동영상

본 캡쳐본은 중간부터 캡쳐가 되서 그런 것이니 타이핑 효과가 나타났다는 것에만 주목하자.
좋다. 이제 좀 더 UI를 가다듬자.
타이핑 효과를 좀 더 업그레이드하려면 무엇이 필요할까?
바로, 커서이다. 깜빡깜빡하는 커서가 있으면 좋을 것 같다.

커서 구현하기

커서는 간단하게 css 효과로 구현할 수 있다.
바로 border-right 속성과 animation 을 사용하는 것이다.
일반적으로 커서는 글자 오른쪽에 위치하기 때문에 right를 사용한 것이다.

필자는 Tailwind CSS를 사용하였으므로, 코드가 좀 보기 힘들 수 있지만
열심히 설명해보겠다..!

// tailwind.config.js

module.exports = {
  // ... //
  theme: {
    extend: {
      keyframes: {
        typingCursor: {
          from: {
            borderRight: "2px solid white",
          },
          to: { borderRight: "2px solid black" },
        }
      },
      animation: {
        typingCursor: "typingCursor 1s ease-in-out 0ms 2",
      },
    },
  },
};

typingCursor 라는 이름의 애니메이션을 만들었다.
그 애니메이션은 1s의 실행시간을 가지며, ease-in-out 방식으로 실행되며,
0ms의 딜레이를 가지고 시작하며, 2번 실행된다.

참고 동영상

커서처럼 깜빡이면서 글자 옆에서 착실하게 존재하는 것이 생겼다.
코드는 이미 위에서 보여줬으니 생략하도록 하겠다.

주의할 점

주의할 것은, inline 속성인 태그와 block 속성인 태그를 잘 고려해서 사용해야한다는 것이다.
block 속성은 한 줄을 전부 차지하기 때문에 화면 맨 끝에서 커서 애니메이션이 깜빡일 것이다.
이 것을 못 알아차리고 안된다고 생각하지 말고, 미리 고려하고 살펴보도록 하자.

랜더링 최적화

위 과정들은 state를 변경하기 때문에 재랜더링이 발생한다.
그런데, 위에서 completedTitle 이라는 변수를 생성한다고 했었다.
잘 생각해보자. 함수형 컴포넌트는 말그대로 함수이기 때문에 호출될 때마다 변수를 재생성한다.
그렇다면...이로 인해 발생하는 쓸모없는 낭비를 줄 일 수 있는 방법이 떠오를 것이다.
바로 useMemo 라는 hooks이다.

const completedTitle = useMemo(() => {
  return "어떻게 공부해?";
}, []);

이렇게 해주면, 이후 재랜더링 과정에서 completedTitle 을 재생성하는 것을 막을 수 있다.

여러 줄에 나눠서 보여주기

꼭 이 효과들을 한 줄에만 적용하라는 법은 없다.
여러 줄에 보여주고 싶을 때는 개인적으로 두 가지 방법이 있다고 생각한다.

  1. pre 태그 사용하기
  2. 문자열과 태그를 여러개로 나눠서 사용하기.

아래의 영상을 보자.
위에는 1번 방식으로, 아래는 2번 방식으로 작성한 것이다.

참고 동영상

1번 방식은 매끄럽게 커서 애니메이션이 움직이는데 반해
2번 방식은 커서가 따로노는 느낌이 있다.

pre 태그 방식

const completedTitle = "한국어?\n어떻게 공부해?";
<pre className="text-[#e57373] text-3xl inline animate-typingCursor">
  {landingTitle}
</pre>

pre 태그는 엔터까지도 표현해줄 수 있는 태그이기 때문에 따로 문자열을 분리할 필요가 없다.
따라서 위 영상에서는 하나의 pre 태그 내에 있는 landingTitle이 보여진 것이다.
하나의 태그이기 때문에 애니메이션도 하나만 적용이 되어 있고,
따라서 부드럽게 연결되는 느낌을 받을 수 있다.

그러나, 2번 방식처럼 줄바꿈 후 색상을 변경하는 등의 효과는 적용하기 힘들 수 있다.
하나의 태그, 하나의 문자열이기 때문이다.
굳이 바꾸려고 한다면 조건문 등으로 처리해서 바꿀 수야 있겠지만..
현재 필자의 수준에서는 명확한 방법이 떠오르지 않는다.

태그와 문자열을 여러개로 분리하는 방식

이 방법은 굉장히 단순한 방법이다.
핵심은..completedTitle을 여러개로 쪼개는 것이다.
"어떻게"와 "공부해?"로 쪼갠다치면, 각 문자열에 활용해야하는 state들, useInterval의 사용 횟수도 그에 맞춰서 개수를 늘려주기만 하면된다.
단, 애니메이션을 적용하는 부분에 있어서 딜레이나 지속시간 등을 잘 고려해야한다.
"어떻게"와 "공부해?"에 같은 애니메이션을 적용해버리면 동시에 타이핑 효과가 나타나기 때문이다.
필자는 아래와 같이 해결했다.

useInterval(() => {
  if (count2 >= completedTitle2.length) {
    return;
  }

  // "어떻게" 부분의 count1이 끝나면, 그 때부터 "공부해?" 부분이 시작된다.
  if (count1 >= completedTitle1.length) {
    setLandingTitle2((prev) => {
      let result = prev ? prev + completedTitle2[count2] : completedTitle2[0];

      setCount2((prev) => prev + 1);

      return result;
    });
  }
}, 150);

또한, "어떻게" 부분에서 커서가 계속해서 깜빡이면 이상하므로,
적절한 횟수만큼만 반복하고 사라지도록 해야한다.
필자는 아래와 같이 해결했다.

// tailwind.config.js

module.exports = {
  // ... //
  theme: {
    extend: {
      keyframes: {
        typingCursor1: {
          from: {
            borderRight: "2px solid white",
          },
          to: { borderRight: "2px solid black" },
        },
        typingCursor2: {
          from: {
            borderRight: "2px solid white",
          },
          to: { borderRight: "2px solid black" },
        },
      },
      animation: {
        typingCursor1: "typingCursor1 1s ease-in-out 0ms 2",
        typingCursor2: "typingCursor2 1s ease-in-out 450ms infinite",
      },
    },
  },
};

"어떻게" 부분은 2번만 애니메이션을 실행하고 끝낸다고 말이다.
정말 정교하게 딱 맞춰서 없애고 싶다면 시간 계산을 해서 만들어야겠지만,
그정도의 디테일까지는 없어도 될 것이라고 생각해서 이정도만 구현했다.

아무튼 이 방법은 애니메이션의 부드러운 적용을 위해 고려해야하는 시간적 계산이 늘어난다는 단점이 있다.

그러면 단점만 있느냐? 아니다.
1번 방식에 비해서 색상 변화나 크기 변화 등 줄바꿈 효과와 동시에 스타일을 다르게 적용할 수 있다는 장점이 있다.
그냥...태그를 여러 개 만들었으니까, 태그마다 스타일을 다르게 설정하면 끝난다..

참고 자료

나를 제외한 천재들님 블로그
오늘의 이유님 블로그

profile
나를 믿는 사람들을, 실망시키지 않도록
post-custom-banner

1개의 댓글

comment-user-thumbnail
2023년 5월 11일

잘 보고 도움 받고 갑니다 ㅠㅠ

답글 달기