FE & BE

9. 원 페이지 스크롤 구현 [MyHomepage: 개인 홈페이지 만들기]

zeroiron0 2022. 11. 20. 18:14

안녕하세요! 개발자 최영철입니다.

 

이번 포스트에서는 많은 홈페이지에서 사용하고 있는 원 페이지 스크롤을 직접 구현해보려고 합니다.

 

원 페이지 스크롤의 구현은 gtbowurrs님이 올린 게시글 https://codingbroker.tistory.com/128를 참고해서 진행했습니다.

 

목차

1. useRef

2. Wheel & Resize Event Handler

3. One Page Scroll

4. 모달창이 열려있을 때 이벤트 방지

5. 마무리

원 페이지 스크롤 적용 모습

1. useRef

 useRef는 React에서 제공해주는 기본 훅 중 하나로 컴포넌트를 선언할 때 ref를 같이 사용하면 해당 컴포넌트의 속성을 참조, 수정할 수 있게 합니다. 이때 컴포넌트의 리렌더링은 일어나지 일으키지 않습니다. 다만 사용 순서를 주의해야하는데 

function Foo() {
  const compRef = useRef();
  console.log(compRef.current)
  return (
    <div
      ref={compRef}
    >
      테스트 입니다.
    </div>
  )
}

 다음과 같은 경우 컴포넌트가 렌더링 할 때 compRef에 해당 컴포넌트가 할당되는데 console.log의 호출 시점은 렌더링 전이므로 null.current로 인식돼 오류가 발생하게 됩니다. 따라서 Ref를 제대로 사용하고 싶다면 예외 처리를 제대로 하거나 useEffect 같은 컴포넌트 렌더링 후 실행되는 함수 안에서 사용해야 합니다.

 원 페이지 스크롤을 구현하기 위해 Ref.current를 통해 해당 컴포넌트의 scrollTop을 할 것입니다. scrollTop 속성은 해당 컴포넌트가 수직으로 얼마만큼 내려갔는지 나타내는 속성입니다. 이 값을 뷰포트의 높이와 비교해 현재 어느 페이지를 보고있는지 비교합니다.

 

2. Wheel & Resize Event Listener

 원 페이지 스크롤을 구현하기 위해 Wheel 이벤트와 Resize 이벤트를 사용합니다.

Wheel은 말그대로 마우스 휠을 움직이는 이벤트입니다. 

Resize 이벤트는 브라우저의 크기를 조절해서 화면의 크기가 변경되면 발생하는 이벤트입니다.

function Foo() {
  const viewRef = useRef();
  const [tab, setTab] = useState(0);
  const tabRef = useRef([]);
  useEffect(() => {
    const curr = viewRef.current;
    
    const resizeAction = resizeHandler(curr, setTab, tabRef);
    const scrollAction = onePageScrollHandler(curr, setTab, tabRef);
    window.addEventListener("resize", resizeAction);
    curr.addEventListener("wheel", scrollAction);

    return () => {
      window.removeEventListener("resize", resize);
      curr.removeEventListener("wheel", onePageScroll);
    };
  }, []);
  
  return (
    <TopComponent ref={viewRef}>
      <Child ref={(e) => tabRef.current[0] = e} />
      <Child ref={(e) => tabRef.current[1] = e} />
      <Child ref={(e) => tabRef.current[2] = e} />
    </TopComponent>
  )
}

# TopComponent CSS
TopComponent = styled.div`
  overflow-y: hidden;
  overflow-x: hidden;
`;

 위 코드에서 이벤트 리스너를 추가할 때 window와 curr 두 개를 사용하는 것을 볼 수 있습니다. 리사이즈 이벤트에서 window를 사용한 이유는 유저가 무슨 페이지를 보고 있던지 브라우저의 사이즈를 변경하면 홈페이지에도 영향을 미치기 때문에 뷰포트가 움직여 페이지 사이의 중간에 위치하는 경우가 있으므로 이를 방지하기 위함입니다. 이와 달리 curr를 사용하면 유저가 홈페이지를 보고 있지 않으면 다른 탭에서 마우스 휠을 움직여도 이벤트를 인식하지 않습니다.

 useEffect의 return에서 이벤트 리스너 삭제 함수를 넣은 이유는 변화가 생겨서 컴포넌트를 리렌더링을 할 때 useEffect가 다시 실행되게 되는데 만약 제거하지 않으면 이벤트 리스너가 계속해서 증가하게 됩니다. 예를 들어 2번째 렌더링일 때 각각의 이벤트 리스너가 두 개가 되고 3번째 렌더링 일때 세 개가 됩니다. 이는 메모리 누수로 이어지므로 반드시 리스너를 삭제해야합니다.

 그리고 중요한 점으로 부모 컴포넌트의 overflow-y 속성을 hidden으로 설정해야하고 페이지로 표현할 컴포넌트를 모두 자식 컴포넌트로 넣어야한다는 점입니다. 이렇게 해야 부모 컴포넌트를 뷰포트로 사용해 scrollTop 값을 사용할 수 있고 직접 스크롤하는 것을 막을 수 있습니다.

 tabRef의 경우 <Child ref={(e) => tabRef.current[i] = e} />처럼 사용하면 원하는 컴포넌트를 리스트로 참조할 수 있습니다. 이를 활용해서 원하는 페이지로 이동하도록 만들어 보겠습니다.

 

3. One Page Scroll

function resizeHandler(curr, setTab, tabRef) {
  const handler = (e) => {
    e.preventDefault();
    const innHeight = window.innerHeight;
    const { scrollTop } = curr;
    if (scrollTop < innHeight * 0.5) {
      tabScrollIntoView(tabRef, setTab)(0)
    } else if (scrollTop < innHeight * 1.5 && scrollTop >= innHeight * 0.5) {
      tabScrollIntoView(tabRef, setTab)(1)
    } else if (scrollTop < innHeight * 2.5 && scrollTop >= innHeight * 1.5) {
      tabScrollIntoView(tabRef, setTab)(2)
    } else {
      tabScrollIntoView(tabRef, setTab)(3)
    }
  };
  return handler;
}

function fullPageHandler(curr, setTab, tabRef) {
  const handler = (e) => {
    e.preventDefault();
    const innHeight = window.innerHeight;
    const { deltaY } = e;
    const { scrollTop } = curr;
    
    if (deltaY > 0) {
      if (scrollTop < innHeight * 0.95) {
        tabScrollIntoView(tabRef, setTab)(1);
      } else if (scrollTop < innHeight * 1.9) {
        tabScrollIntoView(tabRef, setTab)(2);
      } else {
        tabScrollIntoView(tabRef, setTab)(3);
      }
    } else {
      if (scrollTop >= innHeight * 2.85) {
        tabScrollIntoView(tabRef, setTab)(2);
      } else if (scrollTop >= innHeight * 1.9) {
        tabScrollIntoView(tabRef, setTab)(1);
      } else {
        tabScrollIntoView(tabRef, setTab)(0);
      }
    }
  };

  return handler;
}

function tabScrollIntoView(tabRef, setTab) {
  const handler = (idx) => {
    tabRef.current[idx].scrollIntoView({ behavior: "smooth" });
    setTab(idx);
  };

  return handler;
}

 tabRef를 통해 페이지로 사용하는 모든 컴포넌트의 위치를 알 수 있으므로 tabScrollIntoView라는 함수를 만들어 인덱스를 받도록 했습니다. 받은 인덱스를 이용해서 current[idx]를 참조해 해당 페이지로 이동하고 현재 무슨 페이지를 보고 있는지 업데이트 하도록 setTab을 넣었습니다.

보고 있는 페이지의 탭이 굵어진 모습

 tab 상태값과 헤더를 연결시키면 위와 같이 보고 있는 페이지의 탭의 스타일을 변경시킬 수 있습니다.

 각각의 이벤트 핸들러의 경우 curr를 통해 부모 컴포넌트의 scrollTop의 값을 받아오고 이를 뷰포트의 높이인 window.innerHeight와 비교해 해당 페이지가 몇 번째 페이지인지 확인하도록 했습니다.

 

4. 모달창이 열려있을 때 이벤트 방지

 그런데 이렇게까지만 구현하고 모달창을 연 뒤 마우스 휠을 움직이면 페이지 스크롤이 일어나는 것을 볼 수 있습니다. 그 이유는 이벤트 리스너가 작동하고 있기 때문입니다. 그렇다면 모달창이 열려있을 때 스크롤 이벤트 리스너가 없어야하는데 어떻게 할 수 있을까요? 바로 모달창을 열기 위해 사용했던 isModal 상태값과 && 연산자를 사용하면 쉽게 구현할 수 있습니다.

useEffect(() => {
    const curr = viewRef.current;

    const resizeAction = resizeHandler(curr, setTab, tabRef);
    const scrollAction = !isModal && onePageScrollHandler(curr, setTab, tabRef);
    window.addEventListener("resize", resizeAction);
    !isModal && curr.addEventListener("wheel", scrollAction);

    return () => {
      window.removeEventListener("resize", resizeAction);
      !isModal && curr.removeEventListener("wheel", scrollAction);
    };
  }, [isModal]);

 다음과 같이 !isModal &&을 사용하면 모달창이 닫혀 있을 때만 스크롤 이벤트가 발생하도록 할 수 있습니다. isModal 값이 변하면 useEffect를 다시 사용해야하므로 deps에 isModal을 넣어줬습니다.

 

5. 마무리

 이렇게 우리는 원 페이지 스크롤을 구현할 수 있었습니다. 나머지 프론트엔드 부분은 기존 지식의 재활용이거나 단순히 <div> 글 </div>를 통한 구현이므로 여기에서 프론트엔드 구현 이야기를 마치겠습니다! 프론트엔드는 github.io을 이용해 자동 배포하므로 다음 포스트는 AWS와 gunicorn, nginx를 이용해 백엔드 서버를 배포해보겠습니다.

 

감사합니다!