0. 풀페이지 스크롤 애니메이션을 구현하고 싶었다.
KUIT 온보딩 페이지를 어떻게 만들까 고민을 시작했을 때, 가장 먼저 떠올랐던 것이 풀페이지 스크롤 애니메이션이었다. 스크롤 한 번 내리면 화면이 하나씩 내려가는 애니메이션을 구현하고 싶었다. PC에서 휠로 스크롤을 하거나, 모바일로 위로 스와이프를 하면 화면이 바뀌는 애니메이션을 구현하고 싶었다. fullPage.js와 같은 라이브러리를 쓰는 것을 먼저 고려해봤지만, 라이센스 비용이 있다는 말을 듣고(비영리면 무료라고 하긴 하지만) 포기했다.
그 대신 Framer-motion을 사용해서 한 번 구현해보기로 했다. 어떻게 해야할지 감은 잘 잡히진 않았지만, 내게는 Claude가 있으니까. 계속 질문하고, 수정하고. 내가 원하는 결과가 나오기까지 반복했다. 결국에는 구현하는데 성공했고, 이제 코드를 분석해보며 어떻게 구현했는지 살펴보려 한다.
구조는 이렇게 되어있다.
1. MainContent.tsx
'use client';
import { useState, useCallback } from 'react';
import { PageLayout } from './layout/PageLayout';
import HomeContent from '@/components/home/HomeContent';
import IntroduceContent from '@/components/introduce/IntroduceContent';
import StudyContent from './study/StudyContent';
import ProjectsContent from './projects/ProjectsContent';
import StaffContent from './staff/StaffContent';
const MainContent = () => {
// 현재 페이지 상태 관리
const [currentPage, setCurrentPage] = useState(0);
// 애니메이션 진행 중 상태 관리
const [isAnimating, setIsAnimating] = useState(false);
// 페이지 변경 핸들러
const handlePageChange = useCallback(
(newPage: number) => {
if (isAnimating) return; // 애니메이션 중이면 페이지 변경 무시
setIsAnimating(true);
setCurrentPage(newPage);
},
[isAnimating]
);
// 애니메이션 완료 핸들러
const handleAnimationComplete = useCallback(() => {
setIsAnimating(false);
}, []);
return (
<PageLayout
currentPage={currentPage}
onPageChange={handlePageChange}
onAnimationComplete={handleAnimationComplete}
isAnimating={isAnimating}
>
<HomeContent />
<IntroduceContent />
<StudyContent />
<ProjectsContent />
<StaffContent />
</PageLayout>
);
};
export default MainContent;
MainContent는 여러 페이지 컴포넌트(HomeContent, IntroduceContent, StudyContent,ProjectsContent, StaffContent)를 PageLayout 컴포넌트 안에 배치한다.
currentPage(현재 표시중인 페이지 인덱스), isAnimating(페이지 애니메이션 진행 여부) 상태와
handlePageChange(새로운 페이지로 전환 실행), handleAnimationComplete(애니메이션 완료 후 상태 리셋) 함수를
PageLayout 컴포넌트에 전달한다.
2. usePageNavigation.ts
usePageNavigation 훅은 PageLayout.tsx로 부터 totalPages(전체 페이지 수), onPageChange(페이지 변경 함수), isAnimating(애니메이션 진행 여부)를 받는다.
이 훅에는 handlePageTransition, handleScroll, handleTouchStart, handleTouchMove, 총 4가지 함수가 있다.
하나씩 살펴보자.
1. handlePageTransition
const handlePageTransition = useCallback(
(newDirection: 'up' | 'down') => {
if (isAnimating || isTransitioning.current) return;
const now = Date.now();
if (now - lastEventTime.current < 500) return;
lastEventTime.current = now;
let nextPage: number;
if (newDirection === 'down') {
nextPage = Math.min(totalPages - 1, currentPage + 1);
} else {
nextPage = Math.max(0, currentPage - 1);
}
if (nextPage !== currentPage) {
isTransitioning.current = true;
setDirection(newDirection);
setCurrentPage(nextPage);
onPageChange(nextPage);
setTimeout(() => {
isTransitioning.current = false;
}, 1000);
}
},
[totalPages, currentPage, onPageChange, isAnimating]
);
페이지 전환 로직을 담당하고 있는 함수다.
- 현재 애니메이션 중이거나 전환 중이면 함수를 즉시 종료
- 마지막 이벤트로부터 500ms가 지나지 않았다면 함수를 종료
- 새로운 페이지 인덱스를 계산합니다
- 페이지가 실제로 변경되면 상태를 업데이트하고 전환 애니메이션을 시작
- 1초 후에 전환 상태를 리셋
2. handleScroll
const handleScroll = useCallback(
(event: WheelEvent) => {
event.preventDefault();
const now = Date.now();
const timeSinceLastScroll = now - lastScrollTime.current;
if (timeSinceLastScroll > 500 && Math.abs(event.deltaY) > 50) {
const newDirection = event.deltaY > 0 ? 'down' : 'up';
handlePageTransition(newDirection);
lastScrollTime.current = now;
}
},
[handlePageTransition]
);
마우스 휠 이벤트를 처리하는 함수이다.
1. 기본 스크롤 동작을 막는다. (event.preventDefault()).
2. 스크롤 이벤트 간의 시간 간격을 체크하고 마지막 스크롤 이벤트로부터 500ms 이상 지났는지 확인한다. →너무 빈번한 페이지 전환을 방지
3. Math.abs(event.deltaY) > 50 - 스크롤의 강도가 일정 수준 이상일 때만 반응한다.
→ 작은 스크롤에 의한 의도치 않은 페이지 전환을 방지
4. event.deltaY > 0이면 'down', 그렇지 않으면 'up' 방향으로 설정한다.
5. 조건을 만족하면 handlePageTransition 함수를 호출하여 실제 페이지 전환을 수행한다.
6. 페이지 전환 후 lastScrollTime을 현재 시간으로 업데이트한다.
3. handleTouchStart
const handleTouchStart = useCallback((event: TouchEvent) => {
touchStartY.current = event.touches[0].clientY;
touchStartX.current = event.touches[0].clientX;
}, []);
터치 시작점의 좌표를 기록하는 함수이다.
→ 세로 방향(Y축)과 가로 방향(X축)의 시작점을 저장한다.
4. handleTouchMove
const handleTouchMove = useCallback(
(event: TouchEvent) => {
const touchCurrentY = event.touches[0].clientY;
const touchCurrentX = event.touches[0].clientX;
const deltaY = touchStartY.current - touchCurrentY;
const deltaX = touchStartX.current - touchCurrentX;
const isHorizontalSwipe = Math.abs(deltaX) > Math.abs(deltaY);
const minSwipeDistance = window.innerHeight * 0.15;
if (!isHorizontalSwipe) {
event.preventDefault();
}
if (!isHorizontalSwipe && Math.abs(deltaY) > minSwipeDistance) {
const newDirection = deltaY > 0 ? 'down' : 'up';
handlePageTransition(newDirection);
touchStartY.current = touchCurrentY;
event.preventDefault();
}
},
[handlePageTransition]
);
터치 이벤트를 처리하여 수직 스와이프에 따른 페이지 전환을 관리하는 함수이다.
- 터치의 현재 위치(Y, X 좌표)를 가져온다.
- 터치 시작점과 현재 위치의 차이(deltaY, deltaX)를 계산한다.
- 스와이프 방향 감지:
- isHorizontalSwipe 변수로 수평 스와이프인지 판단한다.
- Math.abs(deltaX) > Math.abs(deltaY)를 통해 수평/수직 방향을 결정한다.
- 최소 스와이프 거리:
- minSwipeDistance를 화면 높이의 15%로 설정한다.
- 의도치 않은 작은 움직임에 의한 페이지 전환을 방지한다.
- 수직 스와이프 시 event.preventDefault()를 호출하여 기본 스크롤 동작을 막는다.
- 페이지 전환 조건:
- 수직 스와이프((!isHorizontalSwipe))이고,
- 스와이프 거리가 최소 거리를 초과할 때(Math.abs(deltaY) > minSwipeDistance) 페이지 전환을 트리거한다.
- deltaY > 0이면 'down', 그렇지 않으면 'up' 방향으로 설정한다.
- handlePageTransition 함수를 호출하여 페이지를 전환한다.
- touchStartY를 현재 Y 좌표로 업데이트하여 연속적인 스와이프 처리를 가능하게 한다.
3. PageTransition.tsx
const pageVariants = {
enter: (direction: 'up' | 'down') => ({
y: direction === 'down' ? '100%' : '-100%',
opacity: 0,
}),
center: {
y: 0,
opacity: 1,
},
exit: (direction: 'up' | 'down') => ({
y: direction === 'down' ? '-100%' : '100%',
opacity: 0,
}),
};
- enter: 페이지가 화면에 진입할 때의 상태를 정의합니다.
- 방향에 따라 화면 아래(100%)나 위(-100%)에서 시작, 투명한 상태
- center: 페이지가 화면 중앙에 위치할 때의 상태
- 수직 위치는 0이고, 불투명
- exit: 페이지가 화면에서 사라질 때의 상태를 정의
- 진입할 때와 반대 방향으로 이동하며, 다시 투명해짐
const pageTransition = {
type: 'tween',
ease: 'anticipate',
duration: 0.8,
};
- type: ‘tween’
- '트윈(tween)'은 "in-between"의 줄임말
- 시작 지점과 끝 지점 사이의 모든 중간 단계를 자동으로 계산해주는 애니메이션 방식
- 예를 들어, 물체를 A 지점에서 B 지점으로 이동시킬 때, 그 사이의 모든 위치를 자동으로 계산해 부드러운 이동을 만들어 줌
- ease: ‘anticipate’
- '이징(easing)'은 애니메이션의 속도 변화를 조절하는 방법
- 'anticipate' 이징은
- 애니메이션이 시작될 때 물체가 살짝 반대 방향으로 움직였다가
- 그 후에 목표 방향으로 빠르게 움직이는 효과를 만들어줌
- 마치 달리기 선수가 출발 전에 살짝 뒤로 몸을 젖혔다가 앞으로 달리기 시작하는 것과 비슷
- duration: 0.8: 애니메이션의 지속 시간을 0.8초로 설정
export const PageTransition = ({
children,
currentPage,
direction,
onAnimationComplete,
}: PageTransitionProps) => (
<AnimatePresence
initial={false}
custom={direction}
mode="sync"
onExitComplete={onAnimationComplete}
>
<motion.div
key={currentPage}
custom={direction}
variants={pageVariants}
initial="enter"
animate="center"
exit="exit"
transition={pageTransition}
className="h-full w-full absolute top-0 left-0"
>
{children}
</motion.div>
</AnimatePresence>
);
- AnimatePresence: 컴포넌트가 DOM에서 제거될 때도 애니메이션을 적용
- initial={false}: 초기 마운트 시 애니메이션을 비활성화
- custom={direction}: 현재 전환 방향을 variants에 전달
- mode = “sync”이전 자식이 사라지고 새 자식이 나타나는 애니메이션을 동기화
- onExitComplete={onAnimationComplete}: exit 애니메이션이 완료되면 콜백을 실행
4. PageLayout.tsx
const {
currentPage,
direction,
handleScroll,
handleTouchStart,
handleTouchMove,
} = usePageNavigation(totalPages, onPageChange, isAnimating);
PageLayout이 usePageNavigation에게 주는 것 (입력):
- totalPages: 전체 페이지 수 → React.Children.count(children)를 통해 계산
- onPageChange: MainContent에서 전달받은 페이지 변경 함수
- isAnimating: 현재 애니메이션 진행 중인지 여부
usePageNavigation이 PageLayout에게 반환하는 것 (출력):
- currentPage: 현재 활성화된 페이지의 인덱스
- direction: 페이지 전환 방향 ('up' 또는 'down')
- handleScroll: 스크롤 이벤트 처리 함수
- handleTouchStart: 터치 시작 이벤트 처리 함수
- handleTouchMove: 터치 이동 이벤트 처리 함수
// 스크롤 및 터치 이벤트 리스너 설정
useEffect(() => {
const container = containerRef.current;
if (container) {
container.addEventListener('wheel', handleScroll, { passive: false });
container.addEventListener('touchstart', handleTouchStart, {
passive: true,
});
container.addEventListener('touchmove', handleTouchMove, {
passive: false,
});
}
return () => {
if (container) {
container.removeEventListener('wheel', handleScroll);
container.removeEventListener('touchstart', handleTouchStart);
container.removeEventListener('touchmove', handleTouchMove);
}
};
}, [handleScroll, handleTouchStart, handleTouchMove]);
이 useEffect는 스크롤과 터치 이벤트 리스너를 설정한다.
- containerRef 연결된 DOM 요소에 이벤트 리스너를 추가한다.
- 'wheel' 이벤트: 마우스 휠 스크롤을 감지한다.
- passive: false로 설정하여 기본 스크롤 동작 방지
3. touchstart' 이벤트: 터치 시작을 감지한다.
- passive: true로 설정하여 성능을 최적화
4. 'touchmove' 이벤트: 터치 이동을 감지한다.
- passive: false로 설정하여 필요시 기본 스크롤 동작 방지
5. 컴포넌트 언마운트 시 모든 이벤트 리스너를 제거한다.
return (
<div
ref={containerRef}
className="h-screen overflow-hidden relative bg-black"
>
<PageTransition
currentPage={currentPage}
direction={direction}
onAnimationComplete={handleAnimationComplete}
>
{children[currentPage]}
</PageTransition>
<PageIndicator
currentPage={currentPage}
totalPages={totalPages}
isMobile={isMobile}
/>
<NavigationArrows
currentPage={currentPage}
totalPages={totalPages}
isMobile={isMobile}
showChevron={showChevron}
/>
</div>
);
};
<div
ref={containerRef}
className="h-screen overflow-hidden relative bg-black"
>
containerRef를 통해 이 div에 직접 접근 → 스크롤 및 터치 이벤트 리스너를 연결하는데 사용
<PageTransition
currentPage={currentPage}
direction={direction}
onAnimationComplete={handleAnimationComplete}
>
{children[currentPage]}
</PageTransition>
현재 페이지의 전환 애니메이션을 처리
- currentPage와 direction prop으로 현재 페이지와 전환 방향 전달
- onAnimationComplete 콜백으로 애니메이션 완료 시점 감지
- children[currentPage]로 현재 페이지의 내용만 렌더링
위와 같은 코드들을 통해 풀페이지 애니메이션을 구현할 수 있었다. 생각보다는 쉽지 않은 작업이었다. 이 글을 작성하기 전에는 각 코드가 무슨 역할을 하는지 명확하게 알지 못하고 헷갈렸었는데, 글로 정리하다보니 무엇이 어떻게 구성되어 있는지 명확히 알 수 있었다.
다른 프로젝트에서도 wheelEvent, scrollEvent, touchEvent는 위와 같이 전부 따로 설정해야 하는 것인가 하는 궁금증도 생겼다. 마우스를 쓸 때와 손가락으로 스와이프를 할 때를 따로 생각해줘야한다니. 프론트엔드 개발은 고려할점이 많다.
'KUIT' 카테고리의 다른 글
[kuit_onboarding] Next.js 프로젝트에 strapi로 데이터를 쉽게 추가해보자 (0) | 2024.08.16 |
---|---|
[KUIT] 2024-1 웹 파트장 후기 (29) | 2024.06.06 |
[KUIT] 10주차 보충 - 토큰 저장 위치, 세션 인증 방식과 JWT, .env 파일 (0) | 2024.06.06 |
[KUIT] 9주차 워크북 보충 - API endpoint, Axios와 Fetch의 차이, GraphQL (0) | 2024.06.05 |
[KUIT] 8주차 워크북 보충 - 전역 상태 관리 라이브러리 정리 (0) | 2024.06.05 |