Intersection Observer로 문서 뷰어의 Page Navigation 기능 구현하기

kec0130·2022년 11월 15일
0
post-thumbnail

회사 서비스에서 환자의 재활 운동 리포트를 문서 뷰어처럼 볼 수 있는 컴포넌트를 개발하게 되었다. 전체 페이지와 현재 페이지 번호를 보여주고, 전후 페이지로 이동할 수 있는 버튼이 있는 형태였다. Intersection Observer API를 활용하여 해당 기능을 구현한 방법을 정리해보았다.

예시 코드 전체 보기: https://codesandbox.io/s/page-navigation-with-intersection-observer-pbz9cs

현재 페이지 번호 추적하기: Intersection Observer

문서 뷰어에서 가장 핵심적인 기능 중 하나는 현재 화면에 보이는 페이지가 몇 번째 페이지인지 트래킹하는 것이다. 페이지를 이동할 때마다 현재 화면에 나오는 페이지의 번호를 알아야 한다. 이렇게 화면에서 특정 요소의 가시성을 관찰해야 하는 경우 유용하게 사용할 수 있는 것이 JavaScript의 Intersection Observer API다. Intersection Observer는 특정 element가 상위 element 혹은 최상단 viewport와 얼마나 교차하는지 관찰하고, 일정 퍼센트 이상 교차할 때 콜백 함수를 실행한다. 문서 뷰어에서는 뷰어 영역 안에 몇 번째 페이지가 얼마나 교차하는지 관찰하면 된다.

1. 전체 페이지 수만큼 Refs 생성하기

우선 데이터를 받아온 후 전체 페이지 수만큼 refs를 생성해준다. 하나의 특정 요소가 아니라 모든 페이지를 관찰해야 하기 때문이다. multiple refs를 생성하는 방법을 구글링해보면 여러 가지가 나오는데, 아래와 같이 refs를 저장한 배열을 state로 관리하는 방식이 props로 전달하기도 편하고 index로 특정 ref를 조회하기도 편한 것 같다.

const [pageRefs, setPageRefs] = useState([]);

useEffect(() => {
  setPageRefs((refs) =>
    Array(totalPages)
      .fill()
      .map((_, i) => refs[i] || createRef())
  );
}, [totalPages]);

2. Viewer 영역과 Report 컴포넌트에 Refs 전달하기

추후 관찰 영역과 관찰 대상을 지정하기 위해 refs를 걸어주는 작업이 필요하다. 우선 페이지가 보이는 영역인 viewer element에 ref를 걸어준다. 그리고 위에서 생성한 pageRefs를 map으로 렌더링되는 각 리포트에 하나씩 전달하고, 페이지 번호도 dataset property로 전달해준다.

const observerRef = useRef();

return (
  <div className="viewer" ref={observerRef}>
    {reportList.map((report, i) => (
      <Report
        key={uuidv4()}
        ref={pageRefs[i]}
        page={i + 1}
        report={report}
      />
    ))}
  </div>
);
const Report = forwardRef((props, ref) => {
  return (
    <section className="report" ref={ref} data-page={props.page}>
	  {/* 리포트 내용 */}
    </section>
  );
});

3. Intersection Observer로 모든 페이지 관찰하기

IntersectionObserver를 생성하고 콜백 함수와 옵션을 인자로 전달한다. optionsroot에는 관찰 영역이 될 viewer element를 지정하고, threshold에는 root와 target이 몇 퍼센트 교차할 때 콜백을 실행할지 설정한다. 콜백 함수에서는 현재 화면에 보이는 visiblePage의 번호를 가져와 currentPage를 업데이트해준다.

마지막으로 pageRefs를 순회하며 observe() 메서드를 사용하여 모든 페이지를 관찰 target으로 지정한다. 이렇게 하면 IntersectionObserver가 viewer 안에 어떤 페이지가 얼마나 보이는지 모니터링하여 페이지 번호를 업데이트하게 된다.

const [currentPage, setCurrentPage] = useState(1);

useEffect(() => {
  if (totalPages === 0) return;

  const options = {
    root: observerRef.current,
    threshold: 0.4
  };

  const observer = new IntersectionObserver((entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        const visiblePage = Number(entry.target.dataset.page);
        if (currentPage !== visiblePage) setCurrentPage(visiblePage);
      }
    });
  }, options);

  pageRefs.forEach((ref) => observer.observe(ref.current));
}, [pageRefs, currentPage, totalPages]);

버튼 클릭 시 페이지 스크롤하기: scrollIntoView

1. 페이지를 이동하는 onClick 함수 정의하기

scrollIntoView() 메서드를 사용하면 특정 element가 화면에 보이도록 컨테이너가 스크롤 된다. 여기에 behavior: "smooth" 옵션을 주어 전후 페이지로 부드럽게 스크롤 되는 효과를 적용한다.

이때, 페이지 번호는 1부터 시작하지만 배열의 index는 0부터 시작한다는 점에 유의하여 이동할 페이지의 index를 설정해야 한다. 그리고 첫 페이지와 마지막 페이지에서는 실행되지 않도록 예외 처리를 해준다.

const onClickPrev = () => {
  if (currentPage === 1) return;
  pageRefs[currentPage - 2].current.scrollIntoView({ behavior: "smooth" });
};

const onClickNext = () => {
  if (currentPage === totalPages) return;
  pageRefs[currentPage].current.scrollIntoView({ behavior: "smooth" });
};

2. PageNavigation 컴포넌트 구현하기

아래와 같이 PageNavigation 컴포넌트를 구현하고 필요한 props를 넘겨주면 완성된다.

<PageNavigation
  currentPage={currentPage}
  totalPages={totalPages}
  onClickPrev={onClickPrev}
  onClickNext={onClickNext}
/>
const PageNavigation = (props) => {
  if (props.totalPages === 0) return null;

  return (
    <div className="page-navigation__background">
      <div className="page-navigation__buttons">
        <button onClick={props.onClickPrev}>
          <HiOutlineChevronUp />
        </button>
        <div>
          <span>{props.currentPage}</span> / {props.totalPages}
        </div>
        <button onClick={props.onClickNext}>
          <HiOutlineChevronDown />
        </button>
      </div>
    </div>
  );
};

Tip. 여러 개의 Refs를 Forwarding 하기

회사 서비스가 container - presenter 디자인 패턴을 사용하고 있어서 container 컴포넌트에서 생성한 refs를 presenter 컴포넌트로 전달하는 일이 빈번했다. 그런데 forwardRef((props, ref) => {})에서는 ref 인자를 하나만 받을 수 있어 여러 개일 경우 어떻게 전달해야 하는지 궁금했다.

답은 아주 간단하다. ref 자체가 object 형태이기 때문에 아래와 같이 nested object 형태로 묶어서 전달하고, 자식 컴포넌트에서는 key를 붙여서 사용하면 된다.

const ParentComponent = () => {
  const observerRef = useRef();
  const printRef = useRef();

  return <ChildComponent ref={{ observerRef, printRef }} />;
};
const ChildComponent = forwardRef((props, ref) => {
  return (
    <div ref={ref.observerRef}>
      <div ref={ref.printRef}></div>
    </div>
  );
});
profile
정리 장인 && FE 개발자

0개의 댓글