React Custom Hooks로 scroll animation 만들기 FadeIn편

React Custom Hooks로 scroll animation 만들기 FadeIn편

해당 글은 김주성님의 세미나를 듣고 작성한 글 입니다.

React Custom Hooks로 scroll animation 만들기 FadeIn편

작년에 React Hooks이 정식 릴리즈 된 이후 많은 사람들이 Hooks을 사용하고 있습니다.

Hooks를 사용하는 가장 큰 이유는 바로 컴포넌트로 부터 상태 관련 로직을 추상화 할 수 있고, 별도의 함수로 빼서 재사용이 가능하기 때문인데요.

상태관리 로직의 재사용 의 특징을 활용해 다양한 Custom Hooks를 만들어 사용 할 수 있습니다.

보통 웹에 Animation이 추가 된다면 한 곳에서만 사용하는 것이 아니라, 다양한 곳에 적용되게 되는데요. Custom Hook을 사용하여 Animation을 정의해 둔다면 쉽고 깔끔하게 Animation을 적용 할 수 있습니다.

유튜브
스크롤 타이밍에 따라 나타나는 DOM들

useScrollFadeIn 구현하기

스크롤 타이밍에 따라 Dom이 FadeIn 되는 animation은 크게 세가지 부분으로 나눌 수 있습니다.

  1. FadeIn Animation을 구현한다.
  2. 특정 스크롤 시점에 Animation을 실행 시키도록 트리거 이벤트를 만든다.
  3. Animation 트리거 이벤트를 DOM에 지정한다.

위 세가지 포인트를 토대로 구현하는 과정을 담아보려 합니다.

custom hook 생성하기

위에서 나눴던 포인트 중 세번째인 “Animation 트리거 이벤트를 DOM에 지정한다.” 를 구현하기 위해서는 Ref를 사용해야 합니다.

Ref는 포커스, 미디어 재생 또는 애니메이션을 직접적으로 실행 시키기 위해 외부에서 DOM(또는 React Component)을 제어 할 수 있게 도와줍니다.

hooks 폴더에 useScrollFadeIn.js 라는 파일을 생성 후 useRef를 사용하여 ref를 생성 후 return 하는 함수를 만들었습니다.

import { useRef } from 'react';

const useScrollFadeIn = () => {
  const dom = useRef();
  
  return {
    ref: dom,
  };
};

ref(dom)을 바로 return 하지 않고 Obejct로 한번 감싼 이유는 component에 적용 할 때 Spread와 추후 style 등의 속성이 추가 될 예정이기 때문입니다. 😽

해당 hook을 선언 후 사용하는 방법은 아래와 같습니다.

import React from 'react';
import { useScrollFadeIn } from '@/hooks';

const Contacts = () => {
  const animatedItem = useScrollFadeIn();
  
  return (
  	<Wrapper>
    	<Title>Contacts</Title>
      <Description>
      	Consequat interdum varius sit amet mattis vulputate enim.
      </Description>
      <Form {...animatedItem} />
    </Wrapper>
  )
}

Scroll 트리거 이벤트 만들기

Scroll 트리거 이벤트를 만들기 위해서는 스크롤을 감지 해야하고, 언제 반응 할지에 대한 조건이 필요합니다.

// 해당 방식은 퍼포먼스에 문제가 있습니다.
// 안티 패턴을 소개하기 위해 만들어진 예시 코드입니다.
// 아래 Intersection Observer를 사용한 코드를 참고해 주세요.
const useScrollFadeIn = () => {
	const dom = useRef();
  
  const handleScroll = useCallback(() => {
    const { current } = dom;
    const currentScrollPosition = window.pageYOffset;
    const currentDomScrollPosition = currentScrollPosition + current.getBoundingClientRect().top - 800; // 800은 이벤트 반응 시점을 조절하기 위해 넣은 임의의 Y position 값
  }, []); 
  
  useEffect(() => {
    if (element.current) {
      window.addEventListener('scroll', handleScroll);
    };

    return () => {
      window.removeEventListener('scroll', handleScroll);
    };
  }, [handleScroll]);
  
  return {
    ref: dom,
  };
};

위와 같이 직접 실시간으로 window의 세로 scroll position 값과, animation을 동작 하게 할 dom element의 position을 비교해서 동작하는 트리거를 만들 수 있지만, window.addEventListener 를 통해 직접 스크롤 이벤트를 핸들링 할 경우

  • scroll 이 될 때마다 이벤트 발생 (단시간에 수십~수백번 호출 됨, 디바운싱 등의 핸들링 필요)
  • getBoundingClientRect() 호출 시에 리플로우(레이아웃 전체 리렌더링) 현상 발생 (성능 저하)

로 인해 퍼포먼스에 문제가 생기게 됩니다.

위의 이슈의 대안으로 Intersection Observer 을 사용하면 좋습니다.

⚠️ 위의 방식대로 하면 퍼포먼스 문제가 생긴다. Intersection Observer를 사용할 것!

const useScrollFadeIn = () => {
  const dom = useRef();
  
  const handleScroll = useCallback(([entry]) => {
      const { current } = dom;
    	
    	if(entry.isIntersecting) {
        // 원하는 이벤트를 추가 할 것
      }
    }, []);
  
  useEffect(() => {
    let observer;
    const { current } = dom;
    
    if (current) {
      observer = new IntersectionObserver(handleScroll, { threshold: 0.7 });
      observer.observe(current);
      
      return () => observer && observer.disconnect();
    };
  }, [handleScroll]);
  
    return {
    ref: dom,
  };
}

IntersectionObserver에 동작 하게 할 함수와 Observer 세팅 값들을 넘겨 줍니다.

thresholdnumberArray<number>로 정의할 수 있으며, Default 값은 0입니다.

  • number는 TargetElement의 노출 비율을 말하는 것이며, 0.7은 70% 정도 노출 되었을 때 해당 이벤트가 실행되게 됩니다.
  • Array는 각각의 비율로 노출 될 때마다 함수가 실행됩니다.

FadeIn Animation 구현하기

FadeIn Animation은 css의 opacitytransform 그리고 transition을 사용하여 구현 할 수 있습니다.

원리는 간단한데요. 이벤트 트리거가 발생 하기 전에는 opacity: 0, transform: translate3d(0, 50%, 0)translate3d(0, 50%, 0) 으로 원하는 위치 아래에 숨겨 두었다가, 트리거가 발생 할 때, opacity:1, transform: translate3d(0, 0, 0) 과 같이 제자리에 돌려두면 됩니다.

유튜브
숨겨져 있는 element
const useScrollFadeIn = () => {
  const dom = useRef();
  
  const handleScroll = useCallback(([entry]) => {
      const { current } = dom;
    	
    	if(entry.isIntersecting) {
        current.style.transitionProperty = 'opacity transform';
        current.style.transitionDuration = '1s';
        current.style.transitionTimingFunction = 'cubic-bezier(0, 0, 0.2, 1)';
        current.style.transitionDelay = '0s';
        current.style.opacity = 1;
        current.style.transform = 'translate3d(0, 0, 0)';
      }
    }, []);
  
  useEffect(() => {
    let observer;
    const { current } = dom;
    
    if (current) {
      observer = new IntersectionObserver(handleScroll, { threshold: 0.7 });
      observer.observe(current);
      
      return () => observer && observer.disconnect();
    };
  }, [handleScroll]);
  
  return {
    ref: dom,
    style: {
      opacity: 0,
      transform: 'translate3d(0, 50%, 0)',
    }
  };
}

위와 같이 return 시 초기에 숨겨두기 위한 style object들을 설정합니다. 외부의 영향을 최소화 하기 위해 style은 직접 주입하며, 만약 기존에 opacity 등을 사용했다면 레이아웃이 깨질 수 있습니다.

응용

위에 구현되어있는 FadeIn Animation의 경우 항상 아래에서 위로 동작하며, DurationDelay를 설정 해 줄 수 없습니다.

Custom 하고자 하는 설정들을 hook을 사용 할 때 Props로 넘겨주고 지정해 준다면 좀 더 세세하고 다양한 FadeIn Animation을 적용 할 수 있습니다.

import { useRef, useEffect, useCallback } from 'react';

const useScrollFadeIn = (direction = 'up', duration = 1, delay = 0) => {
  const dom = useRef();

  const handleDirection = (name) => {
    switch (name) {
      case 'up':
        return 'translate3d(0, 50%, 0)';
      case 'down':
        return 'translate3d(0, -50%, 0)';
      case 'left':
        return 'translate3d(50%, 0, 0)';
      case 'right':
        return 'translate3d(-50%, 0, 0)';
      default:
        return;
    };
  };

  const handleScroll = useCallback(
    ([entry]) => {
      const { current } = element;
      if (entry.isIntersecting) {
        current.style.transitionProperty = 'all';
        current.style.transitionDuration = `${duration}s`;
        current.style.transitionTimingFunction = 'cubic-bezier(0, 0, 0.2, 1)';
        current.style.transitionDelay = `${delay}s`;
        current.style.opacity = 1;
        current.style.transform = 'translate3d(0, 0, 0)';
      };
    },
    [delay, duration],
  );

  useEffect(() => {
    let observer;
    const { current } = dom;

    if (current) {
      observer = new IntersectionObserver(handleScroll, { threshold: 0.7 });
      observer.observe(current);
    }

    return () => observer && observer.disconnect();
  }, [handleScroll]);

  return {
    ref: dom,
    style: {
      opacity: 0,
      transform: handleDirection(direction),
    },
  };
};

export default useScrollFadeIn;

참고자료

🌝동글동글 🌚