[React] 마우스 애니메이션 훅(Hooks) 만들기

2022. 9. 23. 21:51React/NFT Airdropper

Three.js 처럼은 아니어도 마우스에 따라 움직이는 UI 요소들을 하나씩 넣고 싶을 때가 종종 있다.

꼭 이 웹사이트 처럼!

 

https://www.nytimes.com/paidpost/allbirds/the-view-from-above.html

 

PAID POST by Allbirds — The View From Above

Why our future may depend on the fate of birds

www.nytimes.com

 

 

잘 보면 마우스의 움직임에 따라 저 돌이랑 솔방울들이 약간은 반대 방향으로 움직이는 걸 확인할 수 있다.

이 정도면 나도 만들 수 있지 않을까..?

 

하는 생각에 한 번 만들어봤다.

 

일단 먼저 결과물부터 보여주는 걸로

 

1. Today's TO-DO

 

올해 4월, 5월..?

한창 NFT에 관심 많았을 때에 민팅 웹페이지에 가보면 되게 다이나믹한 요소들이 뭔가 흘러가는 재미있는 웹사이트들이 정말 많았다. 그래도 나름 NFT를 다루는 웹페이지인데, 이 정도 애니메이션 정도는 괜찮지 않을까 하는 마음이랄까.

 

생각보다 간단한 hook으로 만들어줄 수 있는데, 먼저 이런 움직이는 방법의 개념부터 설명하고자 한다.

 

 

2. 접근 방법

1) mouseEventCallback 함수

일단 현재 window 객체에 대해서 현재 마우스의 위치를 알아낼 필요가 있다.

그리고 현재 브라우저의 크기에 맞게 적절하게 작게 만들어주는 조절이 필요하다.

또, 마우스의 움직임에 바로바로 따라가면 멋이 없다는 것(?)도 문제다.

 

마우스가 움직일 때 바로 붙는게 아니라 약간은 느릿~하게 붙도록 만들어줘야할 필요가 있다.

그래서 핸들러를 이렇게 넣어줘봤다.

 

const mouseSpeedHandler = useCallback((e: MouseEvent) => {
    setX(e.clientX - window.innerWidth / 2);
    setY(e.clientY - window.innerHeight / 2);
    setTargetX((prev) => prev + (x - prev) * speed);
    setTargetY((prev) => prev + (y - prev) * speed);
  }, [setX, setY, x, y, makeAnimationWork, speed]);

 

1. 마우스를 브라우저 끝에 대도 어느정도 적당한 위치까지만 움직여야 한다.
2. 마우스의 움직임에 바로 붙지 않고 약간은 느릿하게 붙어준다.

 

2) 브라우저에서 애니메이션을 실행시킬 때 꼭! requestAnimationFrame()

이렇게 한 다음에 재귀함수를 구현하고, 그 안에서 계속 targetX와 targetY로 값을 갱신하면 되지 않을까..? 라고 생각했는데 이런 애니메이션을 호출이 계속 되면 콜 스택을 너무 많이 차지하기 때문에 브라우저에게 지금 애니메이션을 위해서 실행시킨다고 noti를 줘야한다고 한다.

 

(출처: https://webisfree.com/2020-03-19/[%EC%9E%90%EB%B0%94%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8]-requestanimationframe()%EC%9D%84-%EC%82%AC%EC%9A%A9%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95-%EB%B0%8F-%EC%98%88%EC%A0%9C)

 

[자바스크립트] requestAnimationFrame()을 사용하는 방법 및 예제

자바스크립트의 내장함수인 requestAnimationFrame()에 대하여 알아봅니다. 애니메이션 구현시 꼭 필요한 방법 중 하나입니다.

webisfree.com

 

style을 ref에서 바꿔줬기 때문에 따로 콜백 함수를 하나 더 만들어주고 그 함수에서 실행시켜봤다.

 

const makeAnimationWork = useCallback(() => {
      if (!target.current) return;
      target.current.style.transform = `translate(${-(targetX) / targetsOffset}px, ${-(targetY) / targetsOffset}px)`;

    window.requestAnimationFrame(makeAnimationWork);
    }, [target, targetX, targetY, targetsOffset]);

 

자 이런 애니메이션을 쓸 때마다 컴포넌트에 이걸 다 넣어주는 건 너무나도 비효율적이기 때문에,

따로 훅(Hooks)로 빼줄 필요가 있다.

 

useMouseInteractive.ts

import React, { useState, useCallback, useEffect } from 'react';

const useMouseInteractive = (
  target:  React.RefObject<HTMLElement | SVGSVGElement>,
  targetsOffset: number,
  speed = 0.01,
) => {
  const [x, setX] = useState(0);
  const [y, setY] = useState(0);
  const [targetX, setTargetX] = useState(0);
  const [targetY, setTargetY] = useState(0);

  const makeAnimationWork = useCallback(() => {
      if (!target.current) return;
      target.current.style.transform = `translate(${-(targetX) / targetsOffset}px, ${-(targetY) / targetsOffset}px)`;

    window.requestAnimationFrame(makeAnimationWork);
  }, [target, targetX, targetY, targetsOffset]);

  const mouseSpeedHandler = useCallback((e: MouseEvent) => {
    setX(e.clientX - window.innerWidth / 2);
    setY(e.clientY - window.innerHeight / 2);
    setTargetX((prev) => prev + (x - prev) * speed);
    setTargetY((prev) => prev + (y - prev) * speed);
    makeAnimationWork();
  }, [setX, setY, x, y, makeAnimationWork, speed]);

  useEffect(() => {
    window.addEventListener("mousemove", mouseSpeedHandler, false);
    return () => {
      window.removeEventListener("mousemove", mouseSpeedHandler, false);
    }
  }, [mouseSpeedHandler]);
}

export default useMouseInteractive;

 

3) speed?

 

위의 사진을 다시 보면, 로고를 둘러싸고 있는 액자(?)와 로고가 약간 다른 속도로 붙는 걸 볼 수 있다.

또 그게 아니더라도, 액자는 마우스랑 같은 방향으로, 또 글자는 마우스의 반대 방향으로 보내고 싶은 경우가 있다.

그럴 때 speed에 적당한 숫자를 넣어주면 된다.

 

(예를 들어, 마우스에 조금 더 빨리 붙었으면 좋겠는 건 3, 마우스랑 반대방향으로 좀 느리게 갔으면 좋겠으면 -10 이렇게 넣어주면 된다.)

 

3. 사용법

 

hooks는 콜백으로 감쌀 수 없기 때문에 웬만하면 최상단의 state와 ref 정의하는 곳에서 넣어줘야한다.

안에서 useRef를 통해 마우스 핸들러도 클린업을 해주고 있기 때문에 그냥 이렇게 넣어주면 된다.

 

const WallPaper = () => {
  const navigator = useNavigate();
  const location = useLocation();
  const [splashLoadingProgress, setSplashLoadingProgress] = useState(0);
  const [imgWidth, setImgWidth] = useState(720);
  const scrollRef = useRef<HTMLDivElement>(null);
  const imgRef = useRef<HTMLImageElement>(null);
  const frameRef = useRef<SVGSVGElement>(null);
  const logoRef = useRef<SVGSVGElement>(null);
  useMouseInteractive(frameRef, 16);
  useMouseInteractive(logoRef, 9);
  ...

 

그러면 이제 끝이다. 각 요소 별로 적용이 가능하다.

음... 이 ref를 배열로 묶고, offset도 배열로 묶은 다음에 넣으면 조금 더 효율적일 것 같긴 하지만 직관성이 떨어지니 고민이다. 이렇게 끝내도 되나 싶기도 하다.

 

아무튼 끝! 수정 사항이 있거나 추가 작업이 있으면 업데이트 하도록 하는 걸로!