가시성을 다루는 여러 방법들

Einere·2022년 8월 6일
0

원글 링크: https://kjwsx23.tistory.com/673

필요성

FE 개발을 하다 보면 특정 요소의 가시성(화면에 보이느냐, 보이지 않느냐)에 따라 여러가지 동작을 해야 하는 요구사항이 생기곤 합니다. 예를 들어, 스크롤을 내리다가 특정 요소가 보여지는 시점에 애니메이션이 동작해야 하거나 라는 식으로 말이죠.

const someElement = document.querySelector("...");
someElement.addEventListener("scroll", onScrollHandler);

기존에는 각 요소에 scroll 관련 이벤트를 달아서, 스크롤 이벤트가 발생할 때 마다 콜백을 실행시켜 조작을 하는 방식으로 구현을 했었습니다. 하지만 이 방식은 scroll 이벤트를 등록한 요소가 늘어날수록 엄청난 성능 저하를 일으킬 수 있습니다.
어떻게 하면 이런 구닥다리 방식 말고, 우아하게 요구사항을 구현할 수 있을까요?

Intersection Observer API

🚨 글을 작성하는 현재 시점에선 해당 API는 실험적인 기능임을 명심하자!

특정 DOM 요소(이하 타겟 요소)와 그 상위 요소, 혹은 최상위 도큐먼트인 viewport(이하 루트 요소)와의 교챠영역에 대한 변화비동기적으로 감지할 수 있는 API입니다.
보통 스크롤이 어느 위치에 도달했을 때, 애니메이션 혹은 특정 로직을 실행하기 위한 장치로 많이 사용합니다.
new IntersectionObserver 로 생성된 IntersectionObserver 인스턴스 객체는 설정된 비율 만큼의 가시성을 계속 감시합니다. 이 생성자 함수는 다음과 같은 세가지 옵션을 설정할 수 있습니다.

  • root
    • 디폴트는 viewport. 교챠영역의 기준이 되는 요소.
  • rootMargin
    • 디폴트는 0, 0, 0, 0. 교챠영역을 계산할 때 루트 요소에 더할 값.
  • threshold
    • 타겟 요소에 대한 루트 요소의 교챠 영역의 비율(0 ~ 1) . 즉, 루트 요소 영역에 타겟 요소의 영역이 얼만큼 포함되어 있는지 나타내는 값. (배열도 가능하다.)
    • 교차 영역 비율이 해당 값을 지나갈 때(이상, 이하 둘 다), 콜백이 호출된다.
    • 0이라면 교차 영역이 1px이라도 넘으면 콜백을 호출한다는 뜻이며, 1이라면 루트 요소가 타겟 요소를 전부 포함해야 콜백을 호출한다는 뜻이다.

타입들

interface DomRectReadOnly {
  bottom: number,
  height: number,
  left: number,
  right: number,
  top: number,
  width: number,
  x: number,
  y: number,
}
interface IntersectionObserverEntry {
  boundingClientRect: DomRectReadOnly, // observable의 전체 레이아웃 정보
  intersectionRatio: number, // intersection root와 observable이 얼마나 교차하는지. (0 ~ 1)
  intersectionRect: DomRectReadOnly, // intersection root와 observable이 교차한 영역에 대한 레이아웃 정보
  isIntersecting: boolean, // intersection root와 교차 여부
  isVisible: boolean, 
  rootBounds: DomRectReadOnly, // intersection root의 레이아웃 정보
  target: HTMLElement, // observable 요소
  time: number, // IntersectionObserver 인스턴스가 생성된 시점을 기준으로, 이벤트가 일어난 시간
}

각 인터페이스에 대한 자세한 정보는 하단의 참고 섹션을 참고해주세요.

전체적인 흐름

  1. 옵저버 객체를 생성한다.
const observer = new IntersectionObserver((entries) => {
  const target = entries[0];
});
  1. 옵저버 객체로 감시할 대상을 등록한다.
const target = document.querySelector('#target');
observer.observe(target);
  1. 콜백 함수를 로직에 맞게 수정한다.

일반적인 경우(option.threshold 가 0인 경우. 대상 요소가 완전히 벗어나거나 완전히 들어온 경우 트리거)에는 다음과 같은 사항만 체크하면 됩니다.

  • intersectionRatio1 인지 0 인지
  • isIntersectingtrue 인지 false 인지
  • isVisibletrue 인지 false 인지

Navigation Bar 숨기기, 보이기

💡 해당 섹션은 단일 요소를 관찰하는 방법에 대해 설명합니다.

스크롤의 위치에 따라 네비게이션 바가 보여지고 숨겨지는 로직을 구현해보도록 하겠습니다.
Next.js 에서는 컴포넌트 함수 내부에서는 document 객체에 접근할 수 없기 때문에, useEffect 내부에서 DOM 객체에 접근해야 합니다.

useEffect(() => {
  // document 객체는 컴포넌트 함수 내에서 접근할 수 없다는 것을 명심!
  const preHeader = document.querySelector('#pre-header');
  const observer = new IntersectionObserver((entries) => {});

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

감시할 대상을 얻은 뒤, observer를 만듭니다. 클린업 함수에서 감시를 해제하는 것도 잊지 마세요!

function isHTMLElement(target: any): target is HTMLElement {
  return target instanceof HTMLElement;
}

useEffect(() => {
  ...
  if (isHTMLElement(preHeader)) {
    observer.observe(preHeader);
  }
  ...
}, []);

타입가드 함수를 이용해 타입체크를 한 뒤, 감시합니다.

const observer = new IntersectionObserver((entries) => {
    const target = entries[0];

    if (isHTMLElement(headerRef.current)) {
      if (target.intersectionRatio === 0) {
        headerRef.current.classList.add('scroll-down');
      }
      if (target.intersectionRatio > 0) {
        headerRef.current.classList.remove('scroll-down');
      }
    }
  });

입맛에 맞게 콜백 함수를 수정합니다.

결과

스크롤을 내리면 경계선과 그림자가 생기며, 다시 스크롤이 맨 위로 붙으면 사라집니다.

One Page Scroll

💡 해당 섹션은 다중 요소를 관찰하는 방법에 대해 설명합니다.

💡 해당 기능은 scroll-snap-type 속성과 scroll-snap-align 속성으로 쉽게 구현할 수 있습니다.

이번에는 One Page Scroll (혹은 Full Page Scroll) 을 구현해보도록 하겠습니다.
먼저, Body 컴포넌트를 구현합니다.

import React, { useEffect } from "react";

export function Body() {
  return (
    <div>
      <div className={"screen lightpink"}>first screen</div>
      <div className={"screen lightblue"}>second screen</div>
      <div className={"screen lightgreen"}>third screen</div>
      <div className={"screen lightcoral"}>fourth screen</div>
      <div className={"screen lightsteelblue"}>fifth screen</div>
    </div>
  );
}

여기서 각 스크린 요소가 100vh 를 가지도록 하는 것이 중요합니다.

.screen {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100vw;
  height: 100vh;
  font-size: 4rem;
}

.lightpink {
  background-color: lightpink;
}

.lightblue {
  background-color: lightblue;
}

.lightgreen {
  background-color: lightgreen;
}

.lightcoral {
  background-color: lightcoral;
}

.lightsteelblue {
  background-color: lightsteelblue;
}

그리고 obserber 객체를 생성합니다.

useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {},
      {
        threshold: 0.1,
      }
    );

    return () => {
      observer.disconnect();
    };
  });

이제 TS를 위한 유틸 함수를 정의합니다.

function isHTMLElement(target: any): target is HTMLElement {
  return target instanceof HTMLElement;
}

function isNodeList(target: any): target is NodeList {
  return target instanceof NodeList;
}

useEffect 내에 관찰 로직을 구현합니다.

const targets = document.querySelectorAll(".screen");
if (isNodeList(targets)) {
  const nodeArray = Array.from(targets);
  const isAllDiv = nodeArray.every((node) => node.tagName === "DIV");

  if (isAllDiv) {
    nodeArray.forEach((node) => observer.observe(node));
  }
}

콜백 함수에 스크롤 기능을 구현합니다.

(entries) => {
  const [observable] = entries.filter(
    (observable) => observable.isIntersecting
  );

  if (observable && isHTMLElement(observable.target)) {
    const offsetTop = observable.target.offsetTop;
    window.scroll({
      left: 0,
      top: offsetTop,
      behavior: "smooth",
    });
  }
}

결과

스크롤 하면 한 페이지 단위로 착착 달라 붙도록 동작합니다. (가끔 미세하게 어긋나긴 하네요..)

scroll-snap을 이용한 방법

번외로, CSS의 scroll-snap 을 이용해, One Page Scroll을 더 편하게 구현할 수 있습니다.
먼저, 캐러셀처럼 크기가 동일한 자식 요소들과, 이것들을 담는 부모 요소를 정의합니다.

<div className={"screen-container"}>
  <div className={"screen lightpink"}>first screen</div>
  <div className={"screen lightblue"}>second screen</div>
  <div className={"screen lightgreen"}>third screen</div>
  <div className={"screen lightcoral"}>fourth screen</div>
  <div className={"screen lightsteelblue"}>fifth screen</div>
</div>

여기서 중요한 점은, 부모 요소가 스크롤을 가질 수 있도록 하는 것입니다. 즉, 부모요소에 overflow 가 발생하면서, 해당 css 속성이 visible 같은 것이 아니여야 된다는 뜻이죠.

.screen-container {
  overflow: scroll; /* visible 같은 속성은 피해주세요 */
  width: 100vw; 
  height: 100vh;
  scroll-snap-type: y mandatory; /* y축, 맨데토리 */
}

.screen {
  width: 100vw; /* 캐러셀처럼, 부모와 자식의 크기가 동일해야 보기 좋습니다. */
  height: 100vh;
  scroll-snap-align: start; /* 스냅 위치를 지정합니다. 자세히는 모르겠어요.. */
}

이렇게 구성하면 구현 끝!

scroll-snap test video

참고

MDN - IntersectionObserver
MDN - DOMRectReadOnly
MDN - IntersectionObserverEntry

profile
지속가능한 웹 개발자를 지향합니다. 경험의 공유를 통해 타인에게 도움이 되는 것을 좋아합니다. 사용자에게 가치를 제공하는 것에 기쁨을 느낍니다.

0개의 댓글