팀프로젝트였던 HANDTRIS를 잘 파악하지 못한 것 같아서, 여기서 백엔드 통신 부분을 제외하고, 솔로 플레이 환경에 여러가지 기술을 좀 추가해서 괜찮은 포트폴리오로 쓸 수 있게 리팩토링 에 집중된 포트폴리오로 리팩토링 중입니다.
일단은 랜딩페이지를 구성해야할 것 같아서, 랜딩 페이지를 풀페이지 스크롤 형태로 구성하고 있습니다.
먼저 풀페이지 스크롤을 구현하면서 가장 중요시했던거는 '쫀득한' 스크롤 경험입니다.
여기서 말하는 '쫀득함'이란,
스크롤이 조금 움직일때는 스크롤이 되지 않고 스크롤이 어느정도 임계값을 넘었을 때 한 페이지씩 스크롤이 내려가는 형태입니다.
CSS는 tailwindcss
를 사용했습니다.
export default function LandingPage() {
return (
<div className="h-screen overflow-hidden">
<Section fromColor="purple-600" toColor="indigo-800" flexDirection="col" />
<Section fromColor="indigo-800" toColor="blue-600" />
<Section fromColor="blue-600" toColor="cyan-500" />
<Section fromColor="teal-400" toColor="green-500" flexDirection="col" />
</div>
);
}
snap-start
는 각 섹션의 시작 부분에 정확히 스크롤이 맞춰지도록 해줍니다.풀페이지 스크롤 효과를 위해 각 섹션은 h-screen
으로 화면을 꽉 채운 뒤 snap-start
를 적용하면 간단하게 구성할 수 있습니다.
섹션이 나뉘기만 하는 것으로는 '쫀득함'이 부족했다. 현재 상태에서는 스크롤을 멈추면 페이지가 전환되지만, 미세하게 스크롤을 움직일 때도 스크롤이 반응하는 상황입니다. 제가 원하는 것은 평소에 스크롤이 반응하지 않으면서, 페이지 전환때만 스크롤이 움직이는 것이다.
이를 위해 섹션별로 sectionRefs
배열에 useRef
를 (일단은 3페이지니까 3개)저장했다.
useRef
를 이용해 섹션을 배열로 관리하고, 현재 섹션의 인덱스를 저장하는 useState
변수를 선언합니다.
const sectionRefs: RefObject<HTMLDivElement>[] = [
useRef(null),
useRef(null),
useRef(null),
];
const [currentSectionIndex, setCurrentSectionIndex] = useState(0);
이제 마우스 휠 이벤트에 따라 적절한 섹션으로 스크롤하는 로직을 추가했습니다. handleWheel
함수에서 스크롤 방향을 감지하고, 일정 시간 동안 추가 스크롤을 차단하여 한 번에 한 섹션만 이동하도록 제어합니다.
const handleWheel = (e: ReactWheelEvent<HTMLDivElement>) => {
if (isScrolling.current) return;
isScrolling.current = true;
setTimeout(() => {
isScrolling.current = false;
}, 800);
if (e.deltaY > 0) {
scrollToNextSection();
} else {
scrollToPrevSection();
}
};
1.isScrolling.current
를 통해 현재 스크롤 중인지 판단
2. 타임아웃을 800ms 걸어주어서 isScrolling.current
를 다시 false
로 바꾸어 추가적인 스크롤이 가능하게 만듭니다. 여러 섹션에 대한 이벤트가 한번에 이루어지지 않게 방지하고자 했습니다.
4. e.deltaY
값을 통해 스크롤 방향을 감지 양수면 다음섹션, 음수면 이전섹션으로 이동합니다.
이 방식으로 연속된 스크롤을 제한하고, 한 번에 한 섹션씩만 이동하게 하는 '쫀득한' 스크롤 효과를 구현할 수 있습니다.
const scrollToNextSection = () => {
scrollToSection(currentSectionIndex + 1);
};
const scrollToPrevSection = () => {
scrollToSection(currentSectionIndex - 1);
};
const scrollToSection = (index: number) => {
if (index >= 0 && index < sectionRefs.length) {
setCurrentSectionIndex(index);
sectionRefs[index].current?.scrollIntoView({ behavior: "smooth" });
}
};
최상위 div
에 onWheel
이벤트를 추가하고, 섹션마다 ref
를 넘겨주면 마우스 휠 이벤트로서 섹션간 이동을 해줄 수 있었다.
스크롤을 강하게 할 경우 한 번에 여러 섹션이 이동하는 문제를 해결하기 위해, handleWheel
함수에서 e.deltaY
값의 크기를 확인합니다. 이를 통해 스크롤이 일정 강도를 넘길 때만 섹션 이동을 허용해줄 겁니다.
const handleWheel = (e: ReactWheelEvent<HTMLDivElement>) => {
if (isScrolling.current) return;
isScrolling.current = true;
setTimeout(() => {
isScrolling.current = false;
}, 400);
if (Math.abs(e.deltaY) > 20) {
console.log("deltaY", e.deltaY);
console.log("페이지 넘기고 싶구나!");
if (e.deltaY > 0) {
scrollToNextSection();
return;
} else {
scrollToPrevSection();
return;
}
}
};
이렇게 하면 스크롤 강도(e.deltaY
)가 20 이상일 때에만 섹션 이동이 일어나고, 너무 약한 스크롤은 무시되게 했습니다.
그리고 그 스크롤이 높은 값까지 올라왔다가 순차적으로 다시 0으로 수렴하는 작동방식을 띄기 때문에 20이상일때 작동하고 페이지를 옮겨준다음 return;해서 처리를 해줬습니다.
setTimeout
의 시간을 너무 적게하면 연속으로 스크롤이 적용되다보니까, 사용자가 불편함을 느끼지 않을정도로 400ms
정도로 설정을 했다.
"use client";
import {
useRef,
useState,
RefObject,
WheelEvent as ReactWheelEvent,
} from "react";
import Section from "@/components/layout/Section";
import { ArrowDownCircle } from "lucide-react";
export default function LandingPage() {
const sectionRefs: RefObject<HTMLDivElement>[] = [
useRef(null),
useRef(null),
useRef(null),
];
const [currentSectionIndex, setCurrentSectionIndex] = useState(0);
const isScrolling = useRef(false);
const handleWheel = (e: ReactWheelEvent<HTMLDivElement>) => {
if (isScrolling.current) return;
isScrolling.current = true;
setTimeout(() => {
isScrolling.current = false;
}, 400);
if (Math.abs(e.deltaY) > 20) {
console.log("deltaY", e.deltaY);
console.log("페이지 넘기고 싶구나!");
if (e.deltaY > 0) {
scrollToNextSection();
return;
} else {
scrollToPrevSection();
return;
}
}
};
const scrollToNextSection = () => {
scrollToSection(currentSectionIndex + 1);
};
const scrollToPrevSection = () => {
scrollToSection(currentSectionIndex - 1);
};
const scrollToSection = (index: number) => {
if (index >= 0 && index < sectionRefs.length) {
setCurrentSectionIndex(index);
sectionRefs[index].current?.scrollIntoView({ behavior: "smooth" });
}
};
return (
<div onWheel={handleWheel} className="h-screen overflow-hidden">
<Section
ref={sectionRefs[0]}
fromColorClass="bg-purple-600"
toColorClass="bg-indigo-800"
flexDirection="flex-col"
>
<h1 className="text-4xl font-bold text-white mb-8">Welcome</h1>
<button
onClick={scrollToNextSection}
className="bg-white text-purple-600 px-6 py-3 rounded-full font-semibold hover:bg-opacity-90 transition-all flex items-center"
>
Learn More
<ArrowDownCircle className="w-6 h-6 ml-2 inline animate-bounce" />
</button>
</Section>
<Section
ref={sectionRefs[1]}
fromColorClass="bg-teal-400"
toColorClass="bg-green-500"
flexDirection="flex-col"
>
<h2 className="text-4xl font-bold text-white mb-8">Game Start</h2>
</Section>
<Section
ref={sectionRefs[2]}
fromColorClass="bg-red-500"
toColorClass="bg-orange-500"
flexDirection="flex-col"
>
<h2 className="text-4xl font-bold text-white mb-8">Game End</h2>
</Section>
</div>
);
}
export default function Section({
children,
fromColorClass,
toColorClass,
flexDirection = "flex-row",
ref,
}: {
children: React.ReactNode;
fromColorClass: string;
toColorClass: string;
flexDirection?: "flex-row" | "flex-col";
ref: React.LegacyRef<HTMLDivElement>;
}) {
return (
<div
ref={ref}
className={`bg-gradient-to-b ${fromColorClass} ${toColorClass} flex ${flexDirection} items-center justify-center h-screen snap-start`}
>
{children}
</div>
);
}