리액트에서 마퀴를 구현할 상황이 생겨서 간단하게 구현해보았다.
내가 원하는 효과는 다음과 같았다.
필요한 package들은 다음과 같다.
yarn add framer-motion
yarn add @emotion/styled @emotion/react
yarn add react-use-measure
스타일 파일과 로직 파일을 구분해서 구현하였다.
// 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 };
/** 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;
주요 포인트는 아래와 같다.
요 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에서 애니메이팅 하게 도와주는 머~ 그런 역할이라고 생각하면 될 것 같다.
계속 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를 직접 올려주는 방법으로 수정하였다.
이것도 어떻게 구현할까 고민이 많았는데~ 내가 생각했을 때 가장 정확한 방법은 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;
}
다만 이 방법은 몇 번 굴려보니까 조금 문제가 있더라요.
mix-blend-mode
가 적용이 돼서, 그 전까진 white-black이 그대로 나타난다.multiply
를 하는 거라, 만약 단색이 아니라면 조금 이상하게 보일 수도 있다.그래서 그냥~ 유연하지 않게 transparent
로 처리했다.
background: linear-gradient(90deg, transparent, white);
MarqueeText 구현 끝!