React Marquee Text with interval

김 주현·2023년 8월 23일
0

UI Component 개발

목록 보기
1/9

리액트에서 마퀴를 구현할 상황이 생겨서 간단하게 구현해보았다.

구현내용

내가 원하는 효과는 다음과 같았다.

  • 오른쪽에서 왼쪽으로 흘러가는 마퀴
  • 일정한 간격을 두고 반복되는 텍스트가 등장
  • 뒤따라오는 텍스트가 맨 왼쪽에 다다르면 3초간 인터벌
  • 다시 애니메이션

Package 설치

필요한 package들은 다음과 같다.

yarn add framer-motion
yarn add @emotion/styled @emotion/react
yarn add react-use-measure

구현

스타일 파일과 로직 파일을 구분해서 구현하였다.

MarqueeText.styled.tsx

// src/Components/MarqueeText.styled.tsx

import styled from "@emotion/styled";

const MarqueeContainer = styled.div`
  position: relative;
  overflow: hidden;
`;

const MarqueeContent = styled.div<{ gap: number }>`
  display: flex;
  flex-direction: row;
  gap: ${({ gap }) => `${gap}px`};
`;

const MarqueeTextContent = styled.p`
  white-space: nowrap;
`;

const FadedRight = styled.div`
  position: absolute;
  top: 0;
  right: 0;
  width: 24px;
  height: 100%;
  background: linear-gradient(90deg, transparent, white);
`;

export { MarqueeContainer, MarqueeContent, MarqueeTextContent, FadedRight };

MarqueeText.tsx

/** React */
import React, { useRef } from "react";

/** Hooks */
import { useAnimationFrame } from "framer-motion";

/** Utils */
import useMeasure from "react-use-measure";

/** Styled Components */
import {
  MarqueeContainer,
  MarqueeContent,
  MarqueeTextContent,
  FadedRight,
} from "./MarqueeText.styled";

type MarqueeTextProp = {
  stopDuration?: number;
  nextPositionGap?: number;
  initialStop?: boolean;
  children: React.ReactNode;
};

const MarqueeText = ({
  stopDuration = 3,
  nextPositionGap = 48,
  initialStop = true,
  children,
}: MarqueeTextProp) => {
  // Text to be moving
  const animateRef = useRef<HTMLDivElement>(null);

  // Container and Text Width
  const [containerRef, { width: containerWidth }] = useMeasure();
  const [textRef, { width: textWidth }] = useMeasure();

  // Whether marquee is needed or not
  const isMarquee = textWidth > containerWidth;

  // Variable about stop-interval
  const isStopMoving = useRef(initialStop);
  const lastSec = useRef(0);

  // Amount of moving
  const moveAmount = useRef(0);

  useAnimationFrame((time, _) => {
    if (!isMarquee) return;

    moveAmount.current += 0.35;

    const isReachLeft =
      moveAmount.current >=
      containerWidth + (textWidth - containerWidth + nextPositionGap);

    if (isReachLeft && !isStopMoving.current) {
      isStopMoving.current = true;
      lastSec.current = time;
    }

    if (isStopMoving.current) {
      const stopDuractionValue = (time - lastSec.current) / 1000;

      if (stopDuractionValue >= stopDuration) {
        isStopMoving.current = false;
        moveAmount.current = 0;
        lastSec.current = 0;
      }
    } else {
      animateRef.current.style.transform = `translateX(-${moveAmount.current}px)`;
    }
  });

  return (
    <MarqueeContainer ref={containerRef}>
      <MarqueeContent ref={animateRef} gap={nextPositionGap}>
        <MarqueeTextContent ref={textRef}>{children}</MarqueeTextContent>
        {isMarquee && <MarqueeTextContent>{children}</MarqueeTextContent>}
      </MarqueeContent>
      <FadedRight />
    </MarqueeContainer>
  );
};

export default MarqueeText;

주요 포인트는 아래와 같다.

useAnimationFrame

요 Hooks은 Framer Motion에서 제공하는 Hook인데, 잘은 모르겠지만 javascript에서 제공하는 requestAnimationFrame과 엇비슷한 느낌이 아닐까 싶다.

requestAnimationFrame

The window.requestAnimationFrame() method tells the browser that you wish to perform an animation and requests that the browser calls a specified function to update an animation right before the next repaint. The method takes a callback as an argument to be invoked before the repaint.

useAnimationFrame

An animation loop that outputs the latest frame time to the provided callback.
Runs a callback once every animation frame

브라우저는 60프레임을 기준(모니터 주사율에 따라 다르겠지만 일반적으로)으로 애니메이팅을 하는데, 이 60프레임 안에 애니메이션이 끝나야 최적의 애니메이션을 보여줄 수 있다. 만약 60프레임이 넘어가버리면 브라우저에서는 다음 animationframe에서 마저 처리하게 되는데, 이 간극에서 끊기는 듯한 애니메이션이 발생한다.

이를 방지하기 위해 적절한 animationframe에서 애니메이팅 하게 도와주는 머~ 그런 역할이라고 생각하면 될 것 같다.

3초 인터벌

계속 x가 움직이게 하는 건 어려운 일이 아닌데, 잠깐 멈추게 만드는 것에서 약간 헤맸다. 처음에 x를 움직이게 하는 건 useAnimationFrame에서 넘겨주는 time으로 지정해주고 있었다.

const sec = time / 1000;
const movingAmount = sec * 24

이렇게 한 다음, movingAmount이 다음 텍스트가 맨 왼쪽에 다다랐는가를 검사하고, 만약 닿았다면 Stop Flag를 세워서 애니메이션을 하지 않게 하는 방법이었다.

const isReachLeft = movingAmount >= containerWidth + (textWidth - containerWidth) + positionGap

let isStopMoving = false;

if (isReachLeft) {
  isStopMoving = true;
}

그런 다음 reach했을 때의 time을 저장해두고, 현재 time과 비교하여 3초가 지났다면 다시 isStopMoving을 풀고 하는 방식이었다.

왜 이런 방식으로 했냐면,, 변수를 최대한 쓰지 않고 제공하는 변수인 time으로 계산을 끝내고 싶었다. 그리고 이 생각은 날 삽질로 이끌었고 ...

머 암튼 그래서 이 방식으론 구현할 수 없겠다라는 판단이 들어서 따로 useRef로 변수를 두고 amount를 직접 올려주는 방법으로 수정하였다.

Faded Text

이것도 어떻게 구현할까 고민이 많았는데~ 내가 생각했을 때 가장 정확한 방법은 mix-blend-mode를 쓰는 것이었다. linear하게 white-black을 가진 div를 만들고, 오른쪽에다가 multiply를 하면 자연스럽게 사라지니깐

const FadedLeft = {
  position: absolute;
  top: 0;
  right: 0;
  width: 24px;
  height: 100%;
  background: linear-gradient(90deg, white, black);
  mix-blend-mode: multiply;
}

다만 이 방법은 몇 번 굴려보니까 조금 문제가 있더라요.

  1. 완전 Render가 끝난 뒤에야 mix-blend-mode가 적용이 돼서, 그 전까진 white-black이 그대로 나타난다.
  2. 뒷 배경에 있는 모든 것들을 multiply를 하는 거라, 만약 단색이 아니라면 조금 이상하게 보일 수도 있다.

그래서 그냥~ 유연하지 않게 transparent로 처리했다.

background: linear-gradient(90deg, transparent, white);

MarqueeText 구현 끝!


profile
FE개발자 가보자고🥳

0개의 댓글