웹개발/React.js

React Scroll Button Component, 스크롤 위치에 따라 동적으로 바뀌는 스크롤 버튼 구현하기. - useRef를 활용한 스크롤 위치 컨트롤

조맹구 2024. 4. 22. 17:29

오늘 구현할 컴포넌트


테블릿화면에 쓰일 웹 사이트인데, 사용자가 물기묻은 손으로 스크롤이 어렵다고 판단되어 스크롤 탑& 바텀 버튼을 달아주기로 했다.

오랜만에 scroll관련 이벤트를 다루게 되어 기억이 가물가물했다.

그래서 열심히 구글링을 해보았는데.. 대부분 window.scrollTo 를 이용한 구현 글이 대부분이였다.
나는 window 자체가 스크롤이 되는 형태가 아닌 테이블 리스트를 담고 있는 어느 div 자체 내에서 스크롤 이벤트를 감지하고 컨트롤해야 한다.

그렇게 onScroll이라는 리액트 이벤트 핸들러를 발견했는데,공식문서의 설명을 빌리면 요소가 스크롤이 발생했을 때 핸들링하는 메서드이며
기본으로 이벤트 버블링이 일어나지 않는다.

하지만 나는 버튼을 눌렀을 때 스크롤이 일어나게 하고싶었던 것이라 용도에 맞지 않았다.
이 메서드는 다음에 스크롤할 때마다 데이터를 동적으로 불러오는 식의 애니메이션에 사용하면 좋을 듯 싶다.

일단 해당 메서드를 써서 콘솔을 찍었을 때 나온 결과를 공유하면 다음과 같다.

const onScrollTablelist = e => {
    console.log(
      'clientHeight:',
      e.target.clientHeight,
      'scrollHeight:',
      e.target.scrollHeight,
      'scrollTop:',
      e.target.scrollTop
    );
  };

  return (
    <motion.div
      className="tableList"
      initial={{ opacity: 0 }}
      animate={{ opacity: 1 }}
      transition={{ duration: 0.3 }}
      onScroll={onScrollTablelist}
    >
      {tableData?.map(table => (
        <div variants={tableItemVariant} key={table.table_id}>
          <TableItem
            key={table.table_id}
            tableData={table}
            servingData={servingData}
            errorDoorTableId={errorDoorTableId}
          />
        </div>
      ))}
      {showBottomBtn && <MoveButton direction="down" />}
    </motion.div>
  );
};

ref.current.scrollTo 이용해 구현해보기

우선, 스크롤이 얼마나 내려가도록 할 지 계산하려면 요소의 사이즈에 대해 알아야 한다.

여기서는 clientHeight, scrollHeight , scrollTop 만 알면 구현이 가능하다.

  • clientHeight: 읽기 전용 속성으로 엘리먼트의 내부 높이를 픽셀로 반환. 이 내부 높이는 padding을 포함하지만, 수평 스크롤바의 높이, 경계선, margin은 포함하지 않는다. 즉 CSS상 높이+ CSS상 내부 여백 - 수평 스크롤바의 높이(존재하는 경우)
  • scrollHeight : 읽기 전용 속성으로 요소의 내용 높이로 overflow로 보이지 않는 내용들까지 포함한 높이다.
  • scrollTop : 요소의 가장 윗 부분과 현재 보여지는 요소의 가장 윗 부분의 거리를 반환한다. 만약 요소의 내용이 스크롤바를 생성하지 않는다면 scrollTop은 0이다.

이해가 안간다면 그림을 통해 이해해보자!

이제 이걸 바탕으로 useRef와 함께 구현해보겠다.

내가 구현하려는 건 다음과 같다.

  • 일정 길이만큼 밑으로 내리는 스크롤 바텀 버튼
  • 맨 밑까지 내리면 스크롤 바텀 버튼을 탑버튼으로 바꾼다.
  • 스크롤 탑 버튼을 누르면 맨 위로 올라간다.
const tableRef = useRef(null);
  const showBottomBtn =
    tableRef.current?.scrollHeight > tableRef.current?.clientHeight;

return(
    <div  ref={tableRef}>
        {data.map ((data)=>(<Tablee/> ....} // 테이블 요소귿ㄹ
    </div>
    {showBottomBtn && <MoveButton tableRef={tableRef} />}
)

코드를 간략히 설명하면, div 태그가 스크롤로 적용된 부분이고, 그 안에 테이블이 여러개 있다.

MoveButton은 그 아래 둔 것으로 div를 참조중인 ref를 넘겨받는다.

ref에 대해서 잘 알지 못한다면, 꼭 개념을 읽어보고 따로오길 추천한다!

MoveButton 컴포넌트 구현

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

import './MoveButton.scss';
import { ReactComponent as MoveButtonIcon } from 'icons/moveButton.svg';
import { debounce } from 'lodash';

function MoveButton({ tableRef }) {
  const [scrollTop, setScrollTop] = useState(0);
  const [isScrollMostBottom, setIsScrollMostBottom] = useState(false);

  useEffect(() => {
    const curRef = tableRef.current;
    const checkScroll = debounce(() => {
      if (!curRef) return;
      const isBottom =
        curRef.clientHeight + curRef.scrollTop >= curRef.scrollHeight;
      setScrollTop(curRef.scrollTop);
      setIsScrollMostBottom(isBottom);
    }, 300);

    checkScroll();

    curRef.addEventListener('scroll', checkScroll);

    return () => {
      curRef?.removeEventListener('scroll', checkScroll);
    };
  }, [tableRef]);

  const handleScroll = () => {
    if (!tableRef.current) return;
    const newScrollTop = isScrollMostBottom ? 0 : scrollTop + 150;
    setScrollTop(newScrollTop);
    tableRef.current.scrollTo({ top: newScrollTop, behavior: 'smooth' });
  };

  return (
    <div className="moveButtonContainer">
      <MoveButtonIcon
        width="8vw"
        height="100%"
        style={{
          padding: '2vw 2vw',
          transform: isScrollMostBottom ? 'scale(-1)' : '',
        }}
        onClick={handleScroll}
      />
    </div>
  );
}

export default MoveButton;

최종 코드는 다음과 같다.

해당 컴포넌트는 부모 컴포넌트로부터 tableRef를 prop으로 받아 이를 통해 DOM요소의 참조를 얻는다.
이 참조를 사용해 사용자가 스크롤한 위치를 확인하고 스크롤이 해당 요소의 맨 아래에 도달했는지 판단한다.

스크롤 조작은 scrollTo() 메서드를 사용한다. => MDN 공식문서 참고 https://developer.mozilla.org/en-US/docs/Web/API/Window/scrollTo
이 메서드 안에 위로부터 얼마나 떨어져있을지 top을 적고, 부드러운 스크롤을 하도록 smooth를 넣어주었다.

  • useState를 사용해 scrollTop 상태와 isScrollMostBottom 상태를 관리해준다.
  • scrollTop은 현재 스크롤 위치를 isScrollmostBottom은 스크롤이 맨 아래에 있는지 여부를 판단하는 것이다.
  • useEffect 훅은 컴포넌트 첫 마운트와 tableRef가 변경될 때마다 실행되는데, debounce 를 사용한 이유는 스크롤 이벤트의 마지막만 처리하기 위함이다. debounce를 통해 연속적으로 발생한 이벤트를 그룹화하여 처리하도록 한다.
  • handleScroll은 버튼 클릭 시 스크롤 위치를 조정한다. 눌렀을 때 스크롤이 맨 아래있다면 scrollTop에 0을 주어 스크롤이 맨위로 가도록 하고 그렇지 않다면 150px씩 아래로 이동하도록 한다.
  • 버튼으로 스크롤을 이동하는 것 뿐만 아니라 마우스로 스크롤 하더라도 useEffect 내에 addEventListner를 넣었기 때문에 이 또한 scrollTop 상태 값에 반영하도록 한다.
  • curRef.clientHeight + curRef.scrollTop >= curRef.scrollHeight 이 식은 현재 보이는 부분의 높이와 맨 위에서부터 스크롤된 거리가 합친 것으로 scrollHeight보다 크거나 같다면 스크롤이 요소의 가장 아래 도달했음을 의미한다.

이해를 위한 사진.. 발그림이지만 이해에 도움되길 바란다 :)