본문 바로가기
리액트

[react] 리액트 전체화면 넘기기 스크롤링(full page scroll)

by jaewooojung 2021. 6. 18.

ReactJS


결과물 gif


리액트 전체화면 스크롤링

마우스 휠 움직임에 반응하여 전체화면을 스크롤링하는 코드를 구현해 보겠습니다.

 

1.

outer div와 inner div(3개)를 생성해 줍니다. inner div는 각각 위 결과물의 1,2,3페이지가 됩니다.

 

App.js

import "./App.css";

function App() {
  return (
    <div className="outer">
      <div className="inner bg-yellow">1</div>
      <div className="inner bg-blue">2</div>
      <div className="inner bg-pink">3</div>
    </div>
  );
}

export default App;

 

App.css

.outer {
}

.inner {
}

.bg-yellow {
  background-color: #f7f6cf;
}

.bg-blue {
  background-color: #b6d8f2;
}

.bg-pink {
  background-color: #f4cfdf;
}

 

2.

스크롤은 body가 아닌 outer div에서 제어하겠습니다. body 기본마진 제거 후(margin: 0), 스크롤을 막습니다(overflow-y: hidden). 그리고 outer div에 스크롤을 제어해 주기 위해 overflow-y 속성을 설정합니다. 이때, height 속성을 함께 설정해 줘야 스크롤이 작동합니다.

 

가상 클래스 -webkit-scrollbar의 display 속성을 none으로 설정해서 스크롤이 동작하지만, 눈에는 보이지 않게 해 줍니다.

body {
  margin: 0;
  overflow-y: hidden;
}

.outer {
  height: 100vh;
  overflow-y: auto;
}

/* 화면에서 스크롤바 안보이게 */
.outer::-webkit-scrollbar {
  display: none;
}

.inner {
}

.bg-yellow {
  background-color: #f7f6cf;
}

.bg-blue {
  background-color: #b6d8f2;
}

.bg-pink {
  background-color: #f4cfdf;
}

 

3.

inner div가 전체화면을 채우도록 설정합니다.

...

.inner {
  height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
  font-size: 100px;
}

...

 

4.

outer div의 스크롤을 제어를 위해 reference를 사용합니다. 이를 위해서 리액트의 useRef 훅을 호출하겠습니다. 생성된 reference로 아래처럼 outer div를 잡아줍니다. div의 ref 프로퍼티에 reference값을 설정하면 됩니다.

 

App.js

import { useRef } from "react";

import "./App.css";

function App() {
  const outerDivRef = useRef();
  return (
    <div ref={outerDivRef} className="outer">
      <div className="inner bg-yellow">1</div>
      <div className="inner bg-blue">2</div>
      <div className="inner bg-pink">3</div>
    </div>
  );
}

export default App;

 

5.

스크롤 동작을 감지하기 위해 outer에 wheel 이벤트 핸들러(handler function)를 추가합니다. wheel 이벤트는 마우스의 휠을 움직일 때마다 발생합니다. 위에서 잡아놓은 reference를 사용해서 addEventListener를 호출하면 추가할 수 있습니다.

이벤트 핸들러는 실존하는 DOM에 추가할 수 있으므로 컴포넌트 마운트 이후에 달아줄 수 있도록 useEffect 훅 내부에서 추가하겠습니다. useEffect 훅의 리턴문에서는 컴포넌트 언마운트 시 핸들러 함수를 삭제하기 위해 removeEventListener를 호출합니다. (메모리 관리. 선택사항.)
 
(wheelHandler 함수의 세부사항은 아래 6번에서 다룹니다.)

 

App.js

import { useEffect, useRef } from "react";

import "./App.css";

function App() {
  const outerDivRef = useRef();
  useEffect(() => {
    const wheelHandler = (e) => {
      e.preventDefault();
      // 스크롤 행동 구현
    };
    const outerDivRefCurrent = outerDivRef.current;
    outerDivRefCurrent.addEventListener("wheel", wheelHandler);
    return () => {
      outerDivRefCurrent.removeEventListener("wheel", wheelHandler);
    };
  }, []);
  return (
    <div ref={outerDivRef} className="outer">
      <div className="inner bg-yellow">1</div>
      <div className="inner bg-blue">2</div>
      <div className="inner bg-pink">3</div>
    </div>
  );
}

export default App;

 

6.

wheelHandler 함수
event 객체를 파라미터로 받습니다. 이 함수에서 판단, 계산해야 하는 값은 아래 두 가지입니다.

  • wheel이 움직인 방향
  • 현재 페이지

1) wheel이 움직인 방향

event 객체 내부에 있는 deltaY값으로 wheel의 움직임이 위쪽인지, 아래쪽인지 파악할 수 있습니다. 이 방향에 따라서 페이지의 이동 방향이 결정됩니다. 

deltaY 사용자의 행동 방향
양수 wheel을 아래로 굴림 아래로(다음 페이지)
음수 wheel을 위로 굴림 위로(이전 페이지)

 

2) 현재 페이지

outerDiv의 scrollTop과 window 객체의 innerHeight 값을 활용하여 wheel 이벤트 발생 시점의 현재페이지를 파악할 수 있습니다. scrollTop은 스크롤바 막대 위쪽의 위치이고, innerHeight는 브라우저 화면의 세로길이입니다. window 객체에서 값을 확인 수 있습니다.

 

wheel handler 함수의 뼈대는 아래와 같습니다.

const wheelHandler = (e) => {
  e.preventDefault();
  const { deltaY } = e;
  const { scrollTop } = outerDivRef.current; // 스크롤 위쪽 끝부분 위치
  const pageHeight = window.innerHeight; // 화면 세로길이, 100vh와 같습니다.
  
  if (deltaY > 0) {
    // 스크롤 내릴 때
    if (현재 1페이지면) {
      // 2페이지로 이동
    } else if (현재 2페이지면) {
      // 3페이지로 이동
    } else (현재 3페이지면) {
      // 3페이지로 이동
    }
  } else {
    // 스크롤 올릴 때
    if (현재 1페이지면) {
      // 1페이지로 이동
    } else if (현재 2페이지면) {
      // 1페이지로 이동
    } else (현재 3페이지면) {
      // 2페이지로 이동
    }
  }
};

 

뼈대를 세웠으니 계산할 차례입니다.

 

계산에 사용되는 값들을 다시 정리하면 다음과 같습니다.

deltaY wheel이 움직인 방향(양수 / 음수)
scrollTop 스크롤바 위쪽 끝의 위치
pageHeight 브라우저 화면의 세로 길이

 

이 값들을 활용해서 scrollTo (scrollTop 아님) 메서드를 호출하여 스크롤을 움직여주면 됩니다.


화면의 세로 길이 값인 pageHeight 만큼 움직이면 전체화면 스크롤링이 가능합니다.

const wheelHandler = (e) => {
  e.preventDefault();
  const { deltaY } = e;
  const { scrollTop } = outerDivRef.current; // 스크롤 위쪽 끝부분 위치
  const pageHeight = window.innerHeight; // 화면 세로길이, 100vh와 같습니다.

  if (deltaY > 0) {
    // 스크롤 내릴 때
    if (scrollTop >= 0 && scrollTop < pageHeight) {
      //현재 1페이지
      console.log("현재 1페이지, down");
      outerDivRef.current.scrollTo({
        top: pageHeight,
        left: 0,
        behavior: "smooth",
      });
    } else if (scrollTop >= pageHeight && scrollTop < pageHeight * 2) {
      //현재 2페이지
      console.log("현재 2페이지, down");
      outerDivRef.current.scrollTo({
        top: pageHeight * 2,
        left: 0,
        behavior: "smooth",
      });
    } else {
      // 현재 3페이지
      console.log("현재 3페이지, down");
      outerDivRef.current.scrollTo({
        top: pageHeight * 2,
        left: 0,
        behavior: "smooth",
      });
    }
  } else {
    // 스크롤 올릴 때
    if (scrollTop >= 0 && scrollTop < pageHeight) {
      //현재 1페이지
      console.log("현재 1페이지, up");
      outerDivRef.current.scrollTo({
        top: 0,
        left: 0,
        behavior: "smooth",
      });
    } else if (scrollTop >= pageHeight && scrollTop < pageHeight * 2) {
      //현재 2페이지
      console.log("현재 2페이지, up");
      outerDivRef.current.scrollTo({
        top: 0,
        left: 0,
        behavior: "smooth",
      });
    } else {
      // 현재 3페이지
      console.log("현재 3페이지, up");
      outerDivRef.current.scrollTo({
        top: pageHeight,
        left: 0,
        behavior: "smooth",
      });
    }
  }
};

 

7.

조금 더 추가해 보겠습니다.
 
scrollTo 메서드로 인한 스크롤의 움직임은 실제 인자로 입력한 값보다 미세하게 작게 움직입니다. 정확한 이유는 모르겠습니다(아시는 분은 댓글로 알려주시면 감사하겠습니다). 예를 들어 인자로 입력한 화면의 세로길이(pageHeight)가 754px 일 때, 실제 스크롤의 이동은 753.555px정도가 됩니다.

 

보통은 큰 문제는 아니지만, scrollTop의 위치에 따라서 현재페이지를 판단하는 위의 조건문에는 경우에 따라 치명적인 문제가 될 수 있습니다. 이를 해결하기 위해 페이지와 페이지 사이에 빈 공간(divider)을 추가하고 오차허용범위를 넓혀주도록 하겠습니다.

 

App.js

import { useState, useEffect, useRef } from "react";

import "./App.css";

function App() {
  const DIVIDER_HEIGHT = 5;
	
  // ... 생략
  
  return (
    <div ref={outerDivRef} className="outer">
      <div className="inner bg-yellow">1</div>
      <div className="divider"></div>
      <div className="inner bg-blue">2</div>
      <div className="divider"></div>
      <div className="inner bg-pink">3</div>
    </div>
  );
}

export default App;

 

App.css

.divider {
  width: 100%;
  height: 5px;
  background-color: gray;
}

 

wheelHandler 함수에서도 divder의 길이 5px를 포함하여 스크롤링하도록 수정합니다.

const wheelHandler = (e) => {
  e.preventDefault();
  const { deltaY } = e;
  const { scrollTop } = outerDivRef.current; // 스크롤 위쪽 끝부분 위치
  const pageHeight = window.innerHeight; // 화면 세로길이, 100vh와 같습니다.

  if (deltaY > 0) {
    // 스크롤 내릴 때
    if (scrollTop >= 0 && scrollTop < pageHeight) {
      //현재 1페이지
      console.log("현재 1페이지, down");
      outerDivRef.current.scrollTo({
        top: pageHeight + DIVIDER_HEIGHT,
        left: 0,
        behavior: "smooth",
      });
    } else if (scrollTop >= pageHeight && scrollTop < pageHeight * 2) {
      //현재 2페이지
      console.log("현재 2페이지, down");
      outerDivRef.current.scrollTo({
        top: pageHeight * 2 + DIVIDER_HEIGHT * 2,
        left: 0,
        behavior: "smooth",
      });
    } else {
      // 현재 3페이지
      console.log("현재 3페이지, down");
      outerDivRef.current.scrollTo({
        top: pageHeight * 2 + DIVIDER_HEIGHT * 2,
        left: 0,
        behavior: "smooth",
      });
    }
  } else {
    // 스크롤 올릴 때
    if (scrollTop >= 0 && scrollTop < pageHeight) {
      //현재 1페이지
      console.log("현재 1페이지, up");
      outerDivRef.current.scrollTo({
        top: 0,
        left: 0,
        behavior: "smooth",
      });
    } else if (scrollTop >= pageHeight && scrollTop < pageHeight * 2) {
      //현재 2페이지
      console.log("현재 2페이지, up");
      outerDivRef.current.scrollTo({
        top: 0,
        left: 0,
        behavior: "smooth",
      });
    } else {
      // 현재 3페이지
      console.log("현재 3페이지, up");
      outerDivRef.current.scrollTo({
        top: pageHeight + DIVIDER_HEIGHT,
        left: 0,
        behavior: "smooth",
      });
    }
  }
};

 


1차 완성입니다. 아래에서는 결과물 우측 중앙에 있는 점 3개(Dots)를 구현합니다.

 

배경이 투명한 Dot 3개를 만들고, 현재 페이지의 위치에 맞는 Dot만 배경색을 칠하도록 구현하겠습니다.

 

8.

Dots 컴포넌트와 Dot 컴포넌트를 생성합니다.

Dot 컴포넌트 3개는 Dots 컴포넌트에 종속되므로 같은 js파일 안에 구현하겠습니다. 여기서는 css파일을 따로 생성하지 않고 편의상 inline style을 적용시켜 주겠습니다.

 

Dots.js

const Dot = ({ num, currentPage }) => {
  return (
    <div
      style={{
        width: 10,
        height: 10,
        border: "1px solid black",
        borderRadius: 999,
        backgroundColor: currentPage === num ? "black" : "transparent",
        transitionDuration: 1000,
        transition: "background-color 0.5s",
      }}
    ></div>
  );
};

const Dots = ({ currentPage }) => {
  return (
    <div style={{ position: "fixed", top: "50%", right: 100 }}>
      <div
        style={{
          display: "flex",
          flexDirection: "column",
          justifyContent: "space-between",
          alignItems: "center",
          width: 20,
          height: 100,
        }}
      >
        <Dot num={1} currentPage={currentPage}></Dot>
        <Dot num={2} currentPage={currentPage}></Dot>
        <Dot num={3} currentPage={currentPage}></Dot>
      </div>
    </div>
  );
};

export default Dots;

 

Dots 컴포넌트는 currentPage라는 prop을 전달받습니다. currentPage는 현재 페이지 정보를 담고 있는 변수입니다. 스크롤에 따라 변하는 값이기 때문에 App 컴포넌트에서 state으로 선언하겠습니다.

 

9.

useState 훅을 사용해 줍니다.

 

App.js

import { useState, useEffect, useRef } from "react";

import Dots from "./Dots";

import "./App.css";

const DIVIDER_HEIGHT = 5;

function App() {
  const outerDivRef = useRef();
  const [currentPage, setCurrentPage] = useState(1);
  
  // ...
  
  return (
    <div ref={outerDivRef} className="outer">
      <Dots currentPage={currentPage} />
      <div className="inner bg-yellow">1</div>
      <div className="divider"></div>
      <div className="inner bg-blue">2</div>
      <div className="divider"></div>
      <div className="inner bg-pink">3</div>
    </div>
  );
}

export default App;

currentPage의 초기값은 1로 설정했습니다.

 

10.

wheelHandler 함수. 스크롤 동작 후 현재페이지 값을 currentPage에 저장합니다.

const wheelHandler = (e) => {
  e.preventDefault();
  const { deltaY } = e;
  const { scrollTop } = outerDivRef.current; // 스크롤 위쪽 끝부분 위치
  const pageHeight = window.innerHeight; // 화면 세로길이, 100vh와 같습니다.

  if (deltaY > 0) {
    // 스크롤 내릴 때
    if (scrollTop >= 0 && scrollTop < pageHeight) {
      //현재 1페이지
      console.log("현재 1페이지, down");
      outerDivRef.current.scrollTo({
        top: pageHeight + DIVIDER_HEIGHT,
        left: 0,
        behavior: "smooth",
      });
      setCurrentPage(2);
    } else if (scrollTop >= pageHeight && scrollTop < pageHeight * 2) {
      //현재 2페이지
      console.log("현재 2페이지, down");
      outerDivRef.current.scrollTo({
        top: pageHeight * 2 + DIVIDER_HEIGHT * 2,
        left: 0,
        behavior: "smooth",
      });
      setCurrentPage(3);
    } else {
      // 현재 3페이지
      console.log("현재 3페이지, down");
      outerDivRef.current.scrollTo({
        top: pageHeight * 2 + DIVIDER_HEIGHT * 2,
        left: 0,
        behavior: "smooth",
      });
    }
  } else {
    // 스크롤 올릴 때
    if (scrollTop >= 0 && scrollTop < pageHeight) {
      //현재 1페이지
      console.log("현재 1페이지, up");
      outerDivRef.current.scrollTo({
        top: 0,
        left: 0,
        behavior: "smooth",
      });
    } else if (scrollTop >= pageHeight && scrollTop < pageHeight * 2) {
      //현재 2페이지
      console.log("현재 2페이지, up");
      outerDivRef.current.scrollTo({
        top: 0,
        left: 0,
        behavior: "smooth",
      });
      setCurrentPage(1);
    } else {
      // 현재 3페이지
      console.log("현재 3페이지, up");
      outerDivRef.current.scrollTo({
        top: pageHeight + DIVIDER_HEIGHT,
        left: 0,
        behavior: "smooth",
      });
      setCurrentPage(2);
    }
  }
};

 

완성

 

👏완성

수고하셨습니다.

 

아래 전체 코드를 첨부합니다.


App.js

import { useEffect, useRef, useState } from "react";
import "./App.css";

import Dots from "./Dots";

function App() {
  const DIVIDER_HEIGHT = 5;
  const outerDivRef = useRef();
  const [currentPage, setCurrentPage] = useState(1);
  useEffect(() => {
    const wheelHandler = (e) => {
      e.preventDefault();
      const { deltaY } = e;
      const { scrollTop } = outerDivRef.current; // 스크롤 위쪽 끝부분 위치
      const pageHeight = window.innerHeight; // 화면 세로길이, 100vh와 같습니다.

      if (deltaY > 0) {
        // 스크롤 내릴 때
        if (scrollTop >= 0 && scrollTop < pageHeight) {
          //현재 1페이지
          console.log("현재 1페이지, down");
          outerDivRef.current.scrollTo({
            top: pageHeight + DIVIDER_HEIGHT,
            left: 0,
            behavior: "smooth",
          });
          setCurrentPage(2);
        } else if (scrollTop >= pageHeight && scrollTop < pageHeight * 2) {
          //현재 2페이지
          console.log("현재 2페이지, down");
          outerDivRef.current.scrollTo({
            top: pageHeight * 2 + DIVIDER_HEIGHT * 2,
            left: 0,
            behavior: "smooth",
          });
          setCurrentPage(3);
        } else {
          // 현재 3페이지
          console.log("현재 3페이지, down");
          outerDivRef.current.scrollTo({
            top: pageHeight * 2 + DIVIDER_HEIGHT * 2,
            left: 0,
            behavior: "smooth",
          });
        }
      } else {
        // 스크롤 올릴 때
        if (scrollTop >= 0 && scrollTop < pageHeight) {
          //현재 1페이지
          console.log("현재 1페이지, up");
          outerDivRef.current.scrollTo({
            top: 0,
            left: 0,
            behavior: "smooth",
          });
        } else if (scrollTop >= pageHeight && scrollTop < pageHeight * 2) {
          //현재 2페이지
          console.log("현재 2페이지, up");
          outerDivRef.current.scrollTo({
            top: 0,
            left: 0,
            behavior: "smooth",
          });
          setCurrentPage(1);
        } else {
          // 현재 3페이지
          console.log("현재 3페이지, up");
          outerDivRef.current.scrollTo({
            top: pageHeight + DIVIDER_HEIGHT,
            left: 0,
            behavior: "smooth",
          });
          setCurrentPage(2);
        }
      }
    };
    const outerDivRefCurrent = outerDivRef.current;
    outerDivRefCurrent.addEventListener("wheel", wheelHandler);
    return () => {
      outerDivRefCurrent.removeEventListener("wheel", wheelHandler);
    };
  }, []);
  return (
    <div ref={outerDivRef} className="outer">
      <Dots currentPage={currentPage} />
      <div className="inner bg-yellow">1</div>
      <div className="divider"></div>
      <div className="inner bg-blue">2</div>
      <div className="divider"></div>
      <div className="inner bg-pink">3</div>
    </div>
  );
}

export default App;

 

App.css

body {
  margin: 0;
  overflow-y: hidden;
}

.outer {
  height: 100vh;
  overflow-y: auto;
}

/* 화면에서 스크롤바 안보이게 */
.outer::-webkit-scrollbar {
  display: none;
}

.inner {
  height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
  font-size: 100px;
}

.bg-yellow {
  background-color: #f7f6cf;
}

.bg-blue {
  background-color: #b6d8f2;
}

.bg-pink {
  background-color: #f4cfdf;
}

.divider {
  width: 100%;
  height: 5px;
  background-color: gray;
}

 

Dots

const Dot = ({ num, currentPage }) => {
  return (
    <div
      style={{
        width: 10,
        height: 10,
        border: "1px solid black",
        borderRadius: 999,
        backgroundColor: currentPage === num ? "black" : "transparent",
        transitionDuration: 1000,
        transition: "background-color 0.5s",
      }}
    ></div>
  );
};

const Dots = ({ currentPage }) => {
  return (
    <div style={{ position: "fixed", top: "50%", right: 100 }}>
      <div
        style={{
          display: "flex",
          flexDirection: "column",
          justifyContent: "space-between",
          alignItems: "center",
          width: 20,
          height: 100,
        }}
      >
        <Dot num={1} currentPage={currentPage}></Dot>
        <Dot num={2} currentPage={currentPage}></Dot>
        <Dot num={3} currentPage={currentPage}></Dot>
      </div>
    </div>
  );
};

export default Dots;


        
답변을 생성하고 있어요.