@@@ 결과 미리보기 @@@
1. 원페이지 스크롤 구현
원페이지 스크롤은 화면 1개에서 스크롤을 할 때, 아래 페이지로 넘어가듯이 스크롤하게 되는 효과를 말한다.
원페이지 스크롤은 React에서 제공하는 라이브러리로 react-full-page가 존재하는데, 매우 구리기(?) 때문에 직접 구현하려고 한다. 세련됨(?)을 느끼기에 괜찮아서 블로그에 남겨두려 한다.
전체 적인 구조는 Wrap이라는 height가 큰 컴포넌트가 존재하고, 안에는 각 페이지에 해당하는 container가 존재한다.
스크롤을 내리거나 올릴 때마다 화면에는 container가 바뀌면서 보여진다.
즉 Wrap이라는 아주 긴 페이지를 스크롤에 맞게 한 container부분만 보여주는 구조이다.
1. 기본 로직 구현
const [page, setPage] = useState(0); // 현재 페이지 상태를 0으로 초기화
const lastPage = 5; // 총 페이지 수 설정
현재 내 page와 최대 page를 state와 변수로 설정 해준다.
useEffect(() => {
setPage(0); // 컴포넌트가 마운트될 때 page를 0으로 초기화
}, []); // 빈 배열은 마운트 시 한 번만 실행됨
첫 마운트 시에는 첫번 째 페이지를 보여주고 싶기 때문에 page를 0으로 초기화해준다.
useEffect(() => { // 마운트 시 스크롤 이벤트 리스너 추가
window.addEventListener('wheel', handleScroll, { passive: false }); // 스크롤 이벤트가 스로틀링 설정에 따라 제어됨
return () => {
window.removeEventListener('wheel', handleScroll); // 컴포넌트 언마운트 시 리스너 제거
};
}, [handleScroll]); // handleScroll이 변경될 때마다 리스너 다시 등록
해당 화면이 마운트 된다면, 원래 기본적으로 작동하는 scroll기능을 직접 커스텀한 handleScroll 변수를 스크롤 동작으로 넣어준다.
다른 페이지에서는 정상적 스크롤을 원하기 때문에, 언마운트 시 handleScroll를 라스너에서 제거해 준다.
const handleScroll = useCallback( // 스크롤 이벤트 핸들러 정의
(e) => {
e.preventDefault(); // 스크롤 기본 동작 방지
if (e.deltaY > 0 && page < lastPage) { // 스크롤을 아래로, 마지막 페이지 미만일 때
setPage((prevPage) => Math.min(prevPage + 1, lastPage)); // 페이지 증가
} else if (e.deltaY < 0 && page > 0) { // 스크롤을 위로, 첫 페이지 초과일 때
setPage((prevPage) => Math.max(prevPage - 1, 0)); // 페이지 감소
}
}, //isThrottled는 뒤에서 설명
[page, lastPage, isThrottled] // 이 핸들러는 page, lastPage, isThrottled가 변경될 때마다 업데이트
);
가장 핵심적인 기능인 handleScroll이다. 스크롤 이벤트인 'wheel'이 인식되면, handleScroll이 호출되고,
e.preventDefalut();를 통해 기본적인 스크롤 방식을 해제한다.
기본 스크롤 동작을 해제 하고,
if (e.deltaY > 0 && page < lastPage) {
setPage((prevPage) => Math.min(prevPage + 1, lastPage));
} else if (e.deltaY < 0 && page > 0) {
setPage((prevPage) => Math.max(prevPage - 1, 0));
}
e.deltaY라는 스크롤 방향을 통해 page state를 범위에 벗어나지 않는 한에서 스크롤 방향에 맞게 한 페이지 씩 이동시킨다.
2. 스크롤 중첩 방지
위 로직대로 구현시에는, 스크롤을 2틱 이상 하게 되는 경우, 여러 페이지 스크롤이 되는 경향이 존재한다.
사용자가 사용할 때 보통 스크롤을 정확히 1틱만 이동하는 경우는 없기 때문에, 여러 틱 스크롤되더라도 한번 스크롤하는 효과를 보장해야 한다.
그렇기에 스크롤을 한 틱 움직이면, 잠시동안 스크롤 event 인식을 막도록 구현했다.
const [isThrottled, setIsThrottled] = useState(false); // 스크롤 제한 여부를 관리하는 상태
....
const handleScroll = useCallback( // 스크롤 이벤트 핸들러 정의
(e) => {
e.preventDefault(); // 스크롤 기본 동작 방지
if (!isThrottled) { // 스크롤 제한이 걸리지 않은 경우에만 실행
if (e.deltaY > 0 && page < lastPage) { // 스크롤을 아래로, 마지막 페이지 미만일 때
setPage((prevPage) => Math.min(prevPage + 1, lastPage)); // 페이지 증가
} else if (e.deltaY < 0 && page > 0) { // 스크롤을 위로, 첫 페이지 초과일 때
setPage((prevPage) => Math.max(prevPage - 1, 0)); // 페이지 감소
}
setIsThrottled(true); // 스크롤 제한 활성화
setTimeout(() => { // 1초 후에 스크롤 제한 해제
setIsThrottled(false);
}, 1000);
}
....
isThrottled state를 선언하여 스크롤마다 사용하는 handleScroll에서 스크롤을 인식하면, isThrottled를 true로 바꾸고 page를 한 페이지 이동시킨다.
setTimeout을 사용하여 스크롤 인식 이후 1초 이후에는 다시 false상태로 초기화하여 스크롤 인식을 받을 준비를 하도록 구성하였다.
3. 스크롤 유도 화살표 및 클릭 시에 페이지 이동
원페이지 스크롤에서 아래에 내용이 더 있다는 것을 시각적으로 보여주려면, 이처럼 스크롤 유도 표현이 중요하다.
사용자의 관점에서 아래로 내리라는 화살표가 존재하는 경우 스크롤 or 화살표를 클릭할 수 있기에, 스크롤뿐만 아니라 화살표를 눌렀을 때에도 페이지 변경이 이루어져야 한다.
const handleClickNext = () => { // 다음 페이지로 이동하는 함수
if (page < lastPage) { // 마지막 페이지 미만일 때만 이동
setPage((prevPage) => Math.min(prevPage + 1, lastPage)); // 페이지 증가
}
};
화살표 클릭 시에 다음 페이지로 이동할 수 있도록 handleCLickNext 메서드를 작성하였다.
그 후 handleCLickNext를 화살표 컴포넌트의 onCLick이벤트로 전달하기 위해 props로 전달해 주었다.
props 전달 과정은 다음과 같다.
// 전체 WRAP 페이지
return (
<Wrap $page={page} $lastPage={lastPage}>
<Container>
<MainTitle onClick={handleClickNext} />
</Container>
</Wrap>
);
....
// 각 페이지
const MainTitle = ({ onClick }) => {
....
return (
....
<ArrowWrapper>
<Arrow isMain={true} onClick={onClick} />
</ArrowWrapper>
....
);
};
//화살표 컴포넌트
export const Arrow = ({ isMain, onClick }) => {
return <ArrowContainer $isMain={isMain} onClick={onClick}></ArrowContainer>;
};
4. return문 구현
return (
<Basefiled>
<Wrap $page={page} $lastPage={lastPage}>
<Container>
<MainTitle onClick={handleClickNext} />
</Container>
<Container>
<MainIntroduce onClick={handleClickNext} />
</Container>
<Container>
<JobInfo onClick={handleClickNext} />
</Container>
<Container>
<LegalChatInfo onClick={handleClickNext} />
</Container>
<Container>
<MapInfo onClick={handleClickNext} />
</Container>
<Container>
<Contact />
</Container>
</Wrap>
</Basefiled>
);
리턴문을 확인하면,
- <Wrap> 안에 각 페이지에 해당하는 컴포넌트를 <Container>로 감싸고 있음.
- <Wrap>에 $page와 $lastPage를 전달.
- 각 페이지에 화살표를 클릭하면 아래 페이지로 이동할 수 있는 handleClickNext를 전달.
을 알 수 있다.
5. 중요 CSS 구현
const Wrap = styled.div`
position: relative;
top: ${({ $page }) => `-${$page * 100}vh`};
transition: top 1s ease-in-out;
display: flex;
flex-direction: column;
height: ${($lastPage) => `${$lastPage * 100}vh`};
`;
page state에 맞게 현제 화면의 위치를 변경시킨다. 이때 자연스러운 스크롤을 유도하기 위해, transition 1s ease-in-out을 적용한다.
최대 화면의 높이는 lastPage에 맞게 설정한다.
여기에서 꿀팁은 HTML로 page state를 그대로 전달해 주게 되면, DOM요소에 접근한다는 경고가 발생할 수 있다.
변수명 앞에 $을 붙이면 DOM요소에 접근하지 않는다는 것을 명시적으로 선언하는 효과가 있어 변수 명을 $page로 설정하였다.
6. 각 페이지 크기 및 중요 CSS
아래 styled-components의 css코드는 <Wrap> 컨테이너에 감싸진 한 페이지이다.
const Background = styled.div`
width: 100%;
height: calc(100vh - 81px);
position: relative;
background-image: url(${MainBackground}); //tawatchai07 - FREEPIK
background-size: cover;
pointer-events: none;
`;
기본적으로 화면을 채우려면 width는 100%로 유지되어야 하며,
필자는 헤더가 모든 화면 위에 존재하기 때문에, 고정적인 헤더의 크기를 제외한 높이인 calc(100vh - 81px)로 설정하였다.
<Wrap> 컨테이너는 100vh높이로 설정되어 있기 때문에, 결과화면에서 스크롤할 때 중간에 조금 빈화면이 존재하는 것을 확인할 수 있다. ( 그 위치에 헤더가 들어감)
헤더와 같이 특정 영역을 제외하고 전체 스크롤을 구현하고 싶은 경우, 각 페이지에서도 높이를 100% 또는 100vh로 설정해 주면 된다.
2. 결과 및 소스코드
1. 결과 화면
2. 전체 코드
import React, { useState, useEffect, useCallback } from 'react';
import MainIntroduce from './components/MainIntroduce';
import MainTitle from './components/MainTitle';
import styled from 'styled-components';
import JobInfo from './components/JobInfo';
import LegalChatInfo from './components/LegalChatInfo';
import MapInfo from './components/MapInfo';
import Contact from './components/Contact';
const Home = () => {
const [page, setPage] = useState(0);
const lastPage = 5; // 컨테이너 개수
const [isThrottled, setIsThrottled] = useState(false);
useEffect(() => {
setPage(0);
}, []);
const handleScroll = useCallback(
(e) => {
e.preventDefault();
if (!isThrottled) {
if (e.deltaY > 0 && page < lastPage) {
setPage((prevPage) => Math.min(prevPage + 1, lastPage));
} else if (e.deltaY < 0 && page > 0) {
setPage((prevPage) => Math.max(prevPage - 1, 0));
}
setIsThrottled(true);
setTimeout(() => {
setIsThrottled(false);
}, 1000);
}
},
[page, lastPage, isThrottled]
);
useEffect(() => {
window.addEventListener('wheel', handleScroll, { passive: false });
return () => {
window.removeEventListener('wheel', handleScroll);
};
}, [handleScroll]);
const handleClickNext = () => {
if (page < lastPage) {
setPage((prevPage) => Math.min(prevPage + 1, lastPage));
}
};
return (
<Basefiled>
<Wrap $page={page} $lastPage={lastPage}>
<Container>
<MainTitle onClick={handleClickNext} />
</Container>
<Container>
<MainIntroduce onClick={handleClickNext} />
</Container>
<Container>
<JobInfo onClick={handleClickNext} />
</Container>
<Container>
<LegalChatInfo onClick={handleClickNext} />
</Container>
<Container>
<MapInfo onClick={handleClickNext} />
</Container>
<Container>
<Contact />
</Container>
</Wrap>
</Basefiled>
);
};
export default Home;
const Basefiled = styled.div`
width: 100vw;
padding: 0;
`;
const Container = styled.div`
width: 100%;
height: 100vh;
`;
const Wrap = styled.div`
position: relative;
top: ${({ $page }) => `-${$page * 100}vh`};
transition: top 1s ease-in-out;
display: flex;
flex-direction: column;
height: ${($lastPage) => `${$lastPage * 100}vh`};
`;
- 참고
'React.js > React 프로젝트 및 구현' 카테고리의 다른 글
[React.js] 리액트 최적화를 위한 Intersection Observer 사용 (더보기 버튼/스크롤) (2) | 2024.11.02 |
---|---|
[React.js] 리액트 글자 타이핑 효과 구현 (직접 구현/전체 코드) (0) | 2024.10.27 |
[React.js] 구글 페이지 번역 (전체 번역 및 언어 선택 버튼) (4) | 2024.10.18 |
[React.js] vite.config.js에서 env사용하기 (0) | 2024.10.13 |
[React.js] 리액트 모달 만들기/ 배경 흐리게/ 간단하게 (0) | 2024.10.06 |