image

FullPage 스크롤 구현

태그
ReactJavascript
상세설명FullPage 스크롤 구현
작성일자2024.06.09

프로젝트 중 FullPage 스크롤 구현이 필요하여 라이브러리 없이 제작해보았다.

구현 포인트

  • 마우스 휠 사용 시 한 페이지 씩 넘어가기
  • 풀 페이지를 차지 하지 않는 footer까지 스크롤 가능하게 하기
  • 화면구조

    body 태그에 overflow-y: hidden; 속성을 주고 화면 높이 만큼을 가지고 있는 section 태그에 overflow-y: auto; (outer 클래스 명) 속성을 주어 사용자가 페이지 전체를 스크롤 하지 못하게 방지하며 outer 섹션 내부에서만 스크롤이 가능하게 한다.

    outer 섹션안에 fullPage를 만들고 싶은 화면 높이 만큼을 가지고 있는 section 들과 footer를 넣어준다.

    html

    <section ref={outerDivRef} className="outer">
        <section className="bg-red-100 inner"></section>
        <section className="bg-red-200 inner"></section>
        <section className="bg-red-300 inner"></section>
        <section className="bg-red-400 inner"></section>
        <FooterTest />
    </section>

    css

    body {
      overflow-y: hidden;
    }
    .outer {
      height: 100vh;
      overflow-y: auto;
    }
    .inner {
      position: relative;
      height: 100vh;
    }

    스크롤 동작

  • outerDivRef : 스크롤할 컨테이너 요소를 참조한다.
  • linear: linear 효과
  • scrollTo 함수 설명

    스크롤 할 목표 위치(top)로 주어진 duration 시간(애니메이션이 완료되는 데 걸리는 시간(밀리초)) 동안 부드럽게 스크롤 하는 애니메이션을 수행한다.

  • start: 현재 스크롤 위치를 가져옵니다(outerDivRef.current.scrollTop)
  • change: 목표 위치(top)까지의 거리입니다. 즉, 목표 위치에서 현재 위치를 뺀 값입니다.
  • startTime: 애니메이션 시작 시간을 기록합니다(performance.now())
  • animateScroll 함수 설명

  • currentTime : 현재 시간입니다. requestAnimationFrame 콜백으로 전달됩니다.
  • timeElapsed : 애니메이션이 시작된 후 경과된 시간입니다.
  • progress : 애니메이션의 진행 상황을 나타냅니다. timeElapsedduration으로 나눈 값입니다. 0에서 1 사이의 값으로, 1은 애니메이션이 끝났음을 의미합니다.
  • linear(progress) : 선형 인터폴레이션을 사용하여 진행 상황을 계산합니다. 이 예제에서는 단순히 progress 값을 반환합니다.
  • outerDivRef.current.scrollTop : 현재 스크롤 위치를 업데이트합니다. 시작 위치(start)에 목표 위치까지의 거리(change)와 진행 상황(progress)을 곱한 값을 더합니다.
  • if (timeElapsed < duration) : 애니메이션이 아직 끝나지 않았다면 requestAnimationFrame(animateScroll)을 호출하여 다음 프레임을 요청합니다.
  • const outerDivRef = useRef(null)
    const linear = (t) => t 
    
    const scrollTo = (top, duration) => {
      const start = Math.ceil(outerDivRef.current.scrollTop) // 현재 스크롤 위치
      const change = top - start // 목표 위치까지의 거리
      const startTime = performance.now() // 애니메이션 시작 시간
    
      const animateScroll = (currentTime) => {
        const timeElapsed = currentTime - startTime // 경과 시간
        const progress = Math.min(timeElapsed / duration, 1) // 진행 상황 (0에서 1 사이의 값)
        outerDivRef.current.scrollTop = start + change * linear(progress) // 스크롤 위치 업데이트
    
        if (timeElapsed < duration) { // 애니메이션이 아직 끝나지 않음
          requestAnimationFrame(animateScroll) // 다음 프레임 요청
        }
      }
    
      requestAnimationFrame(animateScroll) // 첫 번째 애니메이션 프레임 요청
    }

    wheelHandler 함수 설명

    사용자의 마우스 휠 스크롤 이벤트를 처리하여 스크롤 동작을 제어해 페이지의 특정 구역(예: 페이지나 푸터)으로 스크롤을 부드럽게 이동시키는 역할을 한다.

  • deltaY : 마우스 휠의 수직 이동 값입니다. 양수이면 아래로, 음수이면 위로 스크롤을 의미합니다.
  • scrollTop : 현재 스크롤 위치 / clientHeight : 현재 보이는 영역의 높이 / scrollHeight : 전체 스크롤 가능한 높이
  • maxScrollTop: 스크롤 가능한 최대 위치로, 전체 높이에서 보이는 영역의 높이를 뺀 값
  • maxPage : 총 페이지 수로 inner 클래스 명을 가진 section 총 갯수에서 1개 뺀 값을 적용
  • 아래로 스크롤

  • nextPage : 다음 페이지를 계산합니다. 현재 스크롤 위치를 페이지 높이로 나누고 올림한 다음, 1을 더합니다. (올림하는 이유 해상도가 낮아질 때 + 1이 안됨)
  • scrollTop + deltaY >= maxScrollTop - footerHeight : 현재 스크롤 위치에 deltaY를 더한 값이 최대 스크롤 위치에서 푸터 높이를 뺀 값보다 크거나 같은 경우, 푸터로 스크롤 한다.
  • scrollTo(maxScrollTop, scrollDuration) : 푸터 높이만큼 정확하게 스크롤 한다.
  •   if (deltaY > 0) { //아래로 스크롤하는 경우.
        nextPage = Math.ceil(scrollTop / pageHeight) + 1
        if (scrollTop + deltaY >= maxScrollTop - footerHeight) {
          scrollTo(maxScrollTop, scrollDuration)
          return
        }
      }

    위로 스크롤

  • nextPage : 다음 페이지를 계산합니다. 현재 스크롤 위치를 페이지 높이로 나누고 올림한 다음, 1을 뺀다. (올림하는 이유 해상도가 낮아질 때 - 1이 안됨)
  •  else {
        nextPage = Math.ceil(scrollTop / pageHeight) - 1
        if (nextPage < 0) {
          nextPage = 0
        }
        if (scrollTop > maxScrollTop - footerHeight) {
          scrollTo(maxScrollTop - footerHeight, scrollDuration)
          return
        }
      }

    일반적인 페이지 스크롤

    nextPage >= 0 && nextPage <= maxPage : 다음 페이지가 유효한 범위(0에서 최대 페이지) 내에 있는 경우 다음 페이지로 스크롤한다.

      if (nextPage >= 0 && nextPage <= maxPage) {
        scrollTo(nextPage * pageHeight, scrollDuration)
      }

    wheelHandler 함수 전체코드

     const wheelHandler = (e) => {
        // 수직 스크롤 동작
        e.preventDefault()
        const { deltaY } = e //deltaY 양수이면 아래로 스크롤 / 음수이면 위로 스크롤
        const { scrollTop, clientHeight, scrollHeight } = outerDivRef.current
        const pageHeight = clientHeight // 페이지 높이
        const footerHeight = 382 // footer의 높이
        const maxScrollTop = scrollHeight - clientHeight // 최대 스크롤 가능한 위치
        const maxPage = 3 // 총 페이지 수 - 1
        const scrollDuration = 600 // 스크롤 속도 조절 (밀리초 단위)
    
        let nextPage
    
        if (deltaY > 0) {
          // 아래로 스크롤
          nextPage = Math.ceil(scrollTop / pageHeight) + 1
          // 아래로 스크롤 & 마지막 페이지 경우
          if (scrollTop + deltaY >= maxScrollTop - footerHeight) {
            scrollTo(maxScrollTop, scrollDuration) // 정확히 footerHeight만큼
            return
          }
        } else {
          // 위로 스크롤
          nextPage = Math.ceil(scrollTop / pageHeight) - 1
    
          if (nextPage < 0) {
            nextPage = 0 // 페이지가 0보다 작으면 0으로 설정
          }
          // 위로 스크롤 & 마지막 페이지 경우
          if (scrollTop > maxScrollTop - footerHeight) {
            scrollTo(maxScrollTop - footerHeight, scrollDuration) // 정확히 footerHeight만큼
            return
          }
        }
        if (nextPage >= 0 && nextPage <= maxPage) {
          // 일반적인 페이지 스크롤인 경우
          scrollTo(nextPage * pageHeight, scrollDuration)
        }
      }
    

    전체코드

    'use client'
    
    import FooterTest from '@/component/footerTest'
    import { useEffect, useRef } from 'react'
    
    export default function Test() {
    
      const outerDivRef = useRef(null)
      const linear = (t) => t // linear 효과
    
      const scrollTo = (top, duration) => {
        const start = Math.ceil(outerDivRef.current.scrollTop)
        const change = top - start
        const startTime = performance.now()
    
        const animateScroll = (currentTime) => {
          const timeElapsed = currentTime - startTime
          const progress = Math.min(timeElapsed / duration, 1) // 0에서 1 사이의 값
          outerDivRef.current.scrollTop = start + change * linear(progress)
    
          if (timeElapsed < duration) {
            requestAnimationFrame(animateScroll)
          }
        }
    
        requestAnimationFrame(animateScroll)
      }
    
      const wheelHandler = (e) => {
        // 수직 스크롤 동작
        e.preventDefault()
        const { deltaY } = e //deltaY 양수이면 아래로 스크롤 / 음수이면 위로 스크롤
        const { scrollTop, clientHeight, scrollHeight } = outerDivRef.current
        const pageHeight = clientHeight // 페이지 높이
        const footerHeight = 382 // footer의 높이
        const maxScrollTop = scrollHeight - clientHeight // 최대 스크롤 가능한 위치
        const maxPage = 3 // 총 페이지 수 - 1
        const scrollDuration = 600 // 스크롤 속도 조절 (밀리초 단위)
    
        let nextPage
    
        if (deltaY > 0) {
          // 아래로 스크롤
          nextPage = Math.ceil(scrollTop / pageHeight) + 1
          // 아래로 스크롤 & 마지막 페이지 경우
          if (scrollTop + deltaY >= maxScrollTop - footerHeight) {
            scrollTo(maxScrollTop, scrollDuration) // 정확히 footerHeight만큼
            return
          }
        } else {
          // 위로 스크롤
          nextPage = Math.ceil(scrollTop / pageHeight) - 1
    
          if (nextPage < 0) {
            nextPage = 0 // 페이지가 0보다 작으면 0으로 설정
          }
          // 위로 스크롤 & 마지막 페이지 경우
          if (scrollTop > maxScrollTop - footerHeight) {
            scrollTo(maxScrollTop - footerHeight, scrollDuration) // 정확히 footerHeight만큼
            return
          }
        }
        if (nextPage >= 0 && nextPage <= maxPage) {
          // 일반적인 페이지 스크롤인 경우
          scrollTo(nextPage * pageHeight, scrollDuration)
        }
      }
    
      useEffect(() => {
        const outerDivRefCurrent = outerDivRef.current
        outerDivRefCurrent.addEventListener('wheel', wheelHandler)
        return () => {
          outerDivRefCurrent.removeEventListener('wheel', wheelHandler)
        }
      }, [])
    
      return (
        <>
          <section ref={outerDivRef} className="outer">
            <section className="bg-red-100 inner"></section>
            <section className="bg-red-200 inner"></section>
            <section className="bg-red-300 inner"></section>
            <section className="bg-red-400 inner"></section>
            <FooterTest />
          </section>
        </>
      )
    }
    

    결과