far

[React + Typescript] Intersection Observer로 간단한 스크롤 애니메이션 만들기 (텍스트 가로 이동, Fade In/Out) 본문

React/기록

[React + Typescript] Intersection Observer로 간단한 스크롤 애니메이션 만들기 (텍스트 가로 이동, Fade In/Out)

Eater 2023. 3. 30. 13:44

꽤 예전에 했던 작업인데, Intersection Observer의 복습도 할 겸 메모를 해두려고 한다.

 

0. 들어가며

당시에 해외의 프론트엔드 포트폴리오 사이트를 보면 스크롤 위치에 따라 좌우로 움직이는 텍스트가 자주 나오는데 그걸 적용해보고 싶다는 생각이 들었다. 마침 Intersection Observer을 막 알게 되었을 때 였는데 이걸 사용하면 애니메이션을 쉽게 구현할 수 있을것 같았다.

그리고 하는 김에 Intersection Observer를 사용한 Fade in/out도 구현을 해보았다.

1. Intersecton Observer란?

일반적으로 Scroll 애니메이션을 사용하면 짧은 시간에 수 많은 이벤트가 동기적으로 실행되기 때문에 심각한 성능 문제로 이어지기도 한다. 그리고 위치를 계산하기 위해 getBoundingClientRect를 호출할 때도 값을 정확히 읽어들이기 위해 큐를 flush하고 스타일을 적용함으로써 다수의 reflow 를 발생 시킨다.

하지만 Intersection Observer는 루트 요소와 타겟 요소의 교차점을 관찰해 교차시 비동기적으로 실행되며 가시성 구분시 reflow 를 발생시키지 않는다.

 

참고: https://velog.io/@elrion018/

2. 애니메이션 만들기

index.tsx

import About from '../components/About';
import Canvas from '../components/Canvas';

const Home = () => {
    const [canvas, setCanvas] = useState<number>();
    const canvasRef = useRef<HTMLDivElement | null>(null);
    
    useEffect(() => {
        const observer = new IntersectionObserver((entries) => {
            const entry = entries[0];
            setCanvas(entry.boundingClientRect.height)
        });
        observer.observe(canvasRef.current as HTMLDivElement);
    }, [])
    
    return (
    	<main>
            <section ref={canvasRef} className="section-00">
                <Canvas />
            </section>
            <section id="about">
                <About canvas={canvas} />
            </section>
        </main>
    )
}

움직여야할 텍스트는 About 컴포넌트에 존재하지만 상위 섹션이 끝나는 지점에서 Intersection Observer를 인식시키기 위해 Canvas 컴포넌트에 ref를 걸어준다. 그리고 observer 변수 안에 Intersection Observer API를 불러와 setCanvas로 Height의 위치를 받아준 뒤, observer로 아까 찍어준 ref를 불러온다. canvas에 담긴 Height는 About컴포넌트에서 사용해야 하기 때문에 props로 canvas를 내려준다.

 

 

about.tsx

type Props = {
    canvas: number | undefined;
}

const About = ({ canvas }: Props) => {
    const [ElementVisible, setElementVisible] = useState<boolean>(false);
    const [amAnime, setAmAnime] = useState<number>(0);
    const myRef = useRef<HTMLDivElement | null>(null);
    
    useEffect(() => {
        // Opacity조정
        const observer = new IntersectionObserver((entries) => {
            const entry = entries[0];
            setElementVisible(entry.isIntersecting)
        });
        observer.observe(myRef.current as HTMLDivElement);
    }, [])

    // 좌우 움직임
    const abScroll = useCallback(() => {
        setAmAnime((window.scrollY - (canvas as number)) / 4)
    }, [canvas])

    useEffect(() => {
        window.addEventListener("scroll", abScroll)
        return () => {
            window.removeEventListener("scroll", abScroll);
        }
    })
    
    return (
    	<div className="am-wrapper" ref={myRef}>
            <div className={ElementVisible ? 'elem-visible am-content relative' : 'elem-invisible am-content relative'}>
                <div ref={aboutMe} className="am-aboutme">
                    <div style={{ transform: `translateX(${amAnime}px)` }}>About me</div>
                </div>
                <div className="am-about">
                   <!-- 본문 생략 -->
                </div>
            </div>
        </div>
    )
}

스크롤을 좌우로 움직이기 전에 amAnime에 window.height에서 canvas를 뺀 값을 저장한다. Canvas 컴포넌트가 차지하고 있는 Height를 빼야 About 컴포넌트를 기준으로 스크롤 애니메이션이 실행되기 때문이다. 그리고 나서 abScroll이란 함수를 만들고 이 함수를 받는 Scroll이벤트를 추가해주었다.

 

마지막으로 x좌표를 이동시켜야 하는데 나는 계산된 Height값을 직접 style에 translateX로 넣는 방법을 사용했다. 리액트에서 인라인 스타일을 지양하는 이유가 리렌더링 때문인걸로 알고 있는데 어차피 스크롤 애니메이션은 계속 로드가 되기 때문에 style로 제어해도 크게 문제가 없다고 판단했기 때문이다.

 

이제 About 컴포넌트가 화면에 보이면 애니메이션이 실행되는데, 텍스트의 속도가 너무 빨랐기에 계산된 값을 4로 나누어 속도를 늦춰 완성을 시켰다.

 

그리고 ElementVisible과 myRef는 Fade In/Out에 관한 작업이다. Intersection Observer의 isIntersecting이란 기능은 ref가 걸린 컴포넌트가 화면에 등장할 때 true 사라질 때 false를 반환해주기 때문에 이를 사용해 About 컴포넌트의 Fade In / Out을 해주었다.

 

 

about.scss

@keyframes fadeIn {
  from {
    opacity: 0;
    z-index: -1;
  }
  to {
    opacity: 1;
    z-index: -1;
  }
}
@keyframes fadeOut {
  from {
    opacity: 1;
    z-index: -1;
  }
  to {
    opacity: 0;
    z-index: -1;
  }
}

.elem-visible {
  animation-name: fadeIn;
  animation-duration: 0.4s;
  animation-timing-function: linear;
}

.elem-invisible {
  animation-name: fadeOut;
  animation-duration: 0.4s;
  opacity: 0%;
}

Fade In / Out 애니메이션

 

사실 False가 반환되었을 땐 이미 컴포넌트가 화면 밖에 나간 상태이므로 굳이 Fade Out 애니메이션을 만들 필요는 없다. 나의 경우는 원래 윗부분에 살짝 걸렸을 때 Fade Out이 될 수 있게 ref를 걸어뒀었는데, 모바일 사이즈로 창을 줄였더니 본문의 세로 길이가 길어지면서 Fade Out에 문제가 생겨서 방법을 변경한 것이기 때문에 혹시나 해서 남겨뒀다.

Comments