서론
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>
);
}
댓글