NextJS; FullPage.js를 직접 컴포넌트로 만들어보기

    서론

    Page들 각각의 전체가 스크롤 되는 기능을 구현할 것이므로 FullScroll로 컴포넌트를 네이밍함.

     

    컴포넌트 제작 전 구상

    • 컴포넌트의 자식요소로 페이지(Section)들을 받아서 Slide처럼 나타낼 예정.
    • 모바일에서는 스크롤할 수 없으므로 터치이벤트를 통해 기능 구현 예정.
    • 각 섹션은 부모 컨테이너의 너비와 높이를 100% 차지해야 한다.
    • 휠 스크롤과 터치 스크롤로 섹션을 자연스럽게 이동할 수 있어야 한다.
    • 한 번에 하나의 섹션만 이동해야 한다.
    • 섹션 이동 시 body의 스크롤을 비활성화하여 흔들림을 방지해야 한다.

     

    직면한 문제

    문제 1: 트랙패드에서 여러 섹션이 이동

    • 트랙패드에서 휠 이벤트가 연속적으로 발생하여 여러 섹션이 이동하는 문제 발생.
    • 해결:
      • deltaY 값이 일정 범위 이상일 때만 스크롤 이동.
      • isScrolling 플래그로 한 번에 하나의 섹션만 이동하도록 제한.

    문제 2: 모바일 터치 시 중간에 멈춤

    • 터치 이동 중 불완전한 섹션 이동으로 섹션 중간에 멈추는 문제가 발생.
    • 해결:
      • 터치 이동 거리를 누적(touchDeltaY)하여 이동 거리 기준 이상일 때만 섹션 이동.
      • 터치 종료 시 섹션 이동을 초기화.

    문제 3: 스크롤 시 body가 흔들림

    • 스크롤 이벤트 발생 시 body도 스크롤되어 화면이 흔들리는 문제 발생.
    • 해결:
      • document.body.style.overflow = "hidden"으로 body의 스크롤을 비활성화.

     

    문제 해결

    • 각 섹션은 부모 컨테이너의 100%를 차지하며, 화면에 하나씩 표시됨.
    • 휠과 터치 입력으로 부드럽게 섹션 전환이 가능.
    • body 스크롤 비활성화로 흔들림 없이 스크롤.

     

    FullScroll 컴포넌트

    1. 컴포넌트 구조

    FullScroll
    ├── index.tsx
    └── styles.module.css

    2. 컴포넌트 코드

    "use client";
    
    import React, {
        useRef,
        useEffect,
        useState,
        useCallback,
        ReactNode,
    } from "react";
    import styles from "./styles.module.css";
    
    interface FullScrollProps {
        children: ReactNode;
        scrollCooldown?: number;
    }
    
    export default function FullScroll({
        children,
        scrollCooldown = 1000,
    }: FullScrollProps) {
        const containerRef = useRef<HTMLDivElement>(null);
        const outerRef = useRef<HTMLDivElement>(null);
        const [currentSection, setCurrentSection] = useState(0);
        const isScrolling = useRef(false); // 스크롤 중인지 여부
        const touchStartY = useRef(0);
        const touchDeltaY = useRef(0); // 터치 이동 누적 거리
    
        const childrenArray = Array.isArray(children) ? children : [children];
        const sectionHeight =
            typeof window !== "undefined"
                ? outerRef.current?.getBoundingClientRect().height || 0
                : 0;
    
        // 특정 섹션으로 스크롤
        const scrollToSection = useCallback(
            (index: number) => {
                if (!containerRef.current) return;
    
                const offset = index * sectionHeight;
                containerRef.current.style.transform = `translateY(-${offset}px)`;
            },
            [sectionHeight]
        );
    
        // 스크롤 방향 처리
        const handleScroll = useCallback(
            (direction: number) => {
                if (isScrolling.current) return;
    
                const newSection = Math.max(
                    0,
                    Math.min(currentSection + direction, childrenArray.length - 1)
                );
    
                if (newSection !== currentSection) {
                    isScrolling.current = true;
                    setCurrentSection(newSection);
                    scrollToSection(newSection);
    
                    setTimeout(() => {
                        isScrolling.current = false;
                    }, scrollCooldown);
                }
            },
            [currentSection, childrenArray.length, scrollCooldown, scrollToSection]
        );
    
        // 스크롤 방지 함수
        const preventBodyScroll = useCallback((enable: boolean) => {
            if (enable) {
                document.body.style.overflow = "hidden";
                document.body.style.height = "100%";
            } else {
                document.body.style.overflow = "";
                document.body.style.height = "";
            }
        }, []);
    
        // 스크롤 이벤트 처리
        useEffect(() => {
            const onWheel = (e: WheelEvent) => {
                if (isScrolling.current) return;
    
                e.preventDefault();
                const delta = e.deltaY;
    
                if (Math.abs(delta) > 50) {
                    handleScroll(delta > 0 ? 1 : -1);
                }
            };
    
            window.addEventListener("wheel", onWheel, { passive: false });
    
            return () => {
                window.removeEventListener("wheel", onWheel);
            };
        }, [handleScroll]);
    
        // 터치 이벤트 처리
        useEffect(() => {
            const onTouchStart = (e: TouchEvent) => {
                if (isScrolling.current) return;
    
                touchStartY.current = e.touches[0].clientY;
                touchDeltaY.current = 0; // 터치 이동 거리 초기화
            };
    
            const onTouchMove = (e: TouchEvent) => {
                if (isScrolling.current) return;
    
                e.preventDefault();
                const currentTouchY = e.touches[0].clientY;
                touchDeltaY.current += touchStartY.current - currentTouchY;
                touchStartY.current = currentTouchY;
    
                // 누적 이동 거리가 섹션 높이의 1/4 이상일 때 섹션 이동
                if (Math.abs(touchDeltaY.current) > sectionHeight / 4) {
                    handleScroll(touchDeltaY.current > 0 ? 1 : -1);
                    touchDeltaY.current = 0; // 이동 후 초기화
                }
            };
    
            const onTouchEnd = () => {
                touchDeltaY.current = 0; // 터치 종료 시 초기화
            };
    
            window.addEventListener("touchstart", onTouchStart);
            window.addEventListener("touchmove", onTouchMove, { passive: false });
            window.addEventListener("touchend", onTouchEnd);
    
            return () => {
                window.removeEventListener("touchstart", onTouchStart);
                window.removeEventListener("touchmove", onTouchMove);
                window.removeEventListener("touchend", onTouchEnd);
            };
        }, [handleScroll, sectionHeight]);
    
        // 초기 로드 시 현재 섹션으로 이동 및 스크롤 방지 활성화
        useEffect(() => {
            preventBodyScroll(true);
    
            return () => {
                preventBodyScroll(false); // 컴포넌트 언마운트 시 스크롤 방지 해제
            };
        }, [preventBodyScroll]);
    
        useEffect(() => {
            scrollToSection(currentSection);
        }, [currentSection, scrollToSection]);
    
        return (
            <div ref={outerRef} className={styles.outer}>
                <div ref={containerRef} className={styles.container}>
                    {childrenArray.map((child, idx) => (
                        <section key={idx} className={styles.section}>
                            {child}
                        </section>
                    ))}
                </div>
            </div>
        );
    }
    

    적용

    import FullScroll from "@/components/FullScroll";
    
    export default function TestPage() {
        return (
            <FullScroll scrollCooldown={1000}>
                <div
                    className="size-full text-black"
                    style={{ backgroundColor: "#f7f6cf" }}
                >
                    Page 1
                </div>
                <div
                    className="size-full text-black"
                    style={{ backgroundColor: "#b6d8f2" }}
                >
                    Page 2
                </div>
                <div
                    className="size-full text-black"
                    style={{ backgroundColor: "#f4cfdf" }}
                >
                    Page 3
                </div>
                <div
                    className="size-full text-black"
                    style={{ backgroundColor: "#D0E8C5" }}
                >
                    Page 4
                </div>
                <div
                    className="size-full text-black"
                    style={{ backgroundColor: "#A594F9" }}
                >
                    Page 5
                </div>
                <div
                    className="size-full text-black"
                    style={{ backgroundColor: "#FFB26F" }}
                >
                    Page 6
                </div>
            </FullScroll>
        );
    }

    댓글