시작하며...
지난 기초, 중급 프로젝트에서 캐러셀을 라이브러리를 사용하여 구현하였다.
그때 회고를 보면 나중에는 라이브러리가 아닌 직접 구현해보고 싶다고 글을 적었었다.
그런데 마침 이번 프로젝트에서도 캐러셀을 구현해야 될 기회가 생겨 직접 구현하게 되었다.
캐러셀을 구현한 결과는 아래와 같고, 이를 구현하기까지의 과정을 작성하려고 한다. 💪💪💪
무한 캐러셀 원리?
카드가 4개가 있는 무한 캐러셀을 만들었다.
마지막 카드에서 첫 번째 카드로 이동할 때 자연스럽게 오른쪽으로 넘어가면서 1번 인덱스로 가야 한다.
이를 어떻게 구현해야 될까?
이를 그림으로 표현해 보았다.
마지막 카드(4번 이미지)에서 오른쪽으로 넘어가면 첫 번째 카드(1번 이미지)를 복사한 카드를 바로 오른쪽에 두어 자연스럽게 넘어가는 것처럼 만든다.
그러면 맨 마지막 인덱스(1번 이미지)에서 현재 인덱스를 바로 1번 인덱스(1번 이미지)로 바꿔 눈속임을 준다.
그러면 4번 카드에서 오른쪽으로 넘어가면, 4번 인덱스 -> 5번 인덱스 -> 1번 인덱스
1번 카드에서 왼쪽으로 넘어가면, 1번 인덱스 -> 0번 인덱스 -> 4번 인덱스 가 되는 것이다.
즉, 인덱스 0번과 5번은 눈속임용 카드인 것이다.
이런 원리를 가지고 무한 캐러셀을 만들었다.
캐러셀 구현하기
1. 캐러셀 기본 구조 만들기
먼저 tailwind css 을 사용해서 캐러셀의 기본 구조를 만들었다.
- 컨테이너로, 버튼의 위치를 지정하기 위해 "relative"
- 내부 슬라이드를 감싸며, 밖으로 넘치는 것을 막기 위해 "overflow-hidden"
- 슬라이드로, 캐러셀 내용들을 가로로 배치하기 위해 "flex"
- flexBox 안에서 요소가 줄어들지 않고 원래 크기를 유지하기 위해 "shrink-0"
그 내부에는 각 슬라이드의 구조와 스타일을 원하는 대로 설정하면 된다.
return (
<div className="relative">
<div className="overflow-hidden rounded-2xl">
<div
className="flex"
>
{items.map((item) => (
<div
key={item.title}
className={`${item.background} h-340 w-full shrink-0 justify-between rounded-2xl`}
>
// 생략
</div>
))}
</div>
</div>
<button
type="button"
className="버튼 스타일 생략"
>
‹
</button>
<button
type="button"
className="버튼 스타일 생략"
>
›
</button>
</div>
);
그러면 결과는 아래와 같고 캐러셀의 기본적인 스타일 구조가 완성되었다. 🎉
2. 다음 슬라이드로 이동하기
이동 함수 만들기
이제 버튼을 눌러 다음 슬라이드로 이동해야 한다.
prev 이전, next 다음으로 가는 함수를 만들어 준다.
const Carousel = ({ items }: CarouselProps) => {
const [currentIndex, setCurrentIndex] = useState(0);
// 이전으로 가기
const goToPrevious = () => {
setCurrentIndex((prevIndex) =>
prevIndex === 0 ? items.length - 1 : prevIndex - 1,
);
};
// 다음으로 가기
const goToNext = () => {
setCurrentIndex((prevIndex) =>
prevIndex === items.length - 1 ? 0 : prevIndex + 1,
);
};
그러나 이 함수를 버튼에 onClick 이벤트로 등록해도 다음 슬라이드로 이동하지 않는다.
왜냐하면 currentIndex 값만 바뀌고 그에 따른 슬라이드에 대한 변화가 없기 때문이다.
translateX 로 이동하기
그래서 currentIndex 값에 따라 수평 방향으로 요소를 이동해줘야 한다.
현재 모든 슬라이드의 width 가 full(100%) 이며,
currentIndex 가 0에서 1이 될 때 수평 방향으로 100% 만큼 이동해야 한다.
왼쪽으로 이동시켜 다음 슬라이드를 봐야 하기 때문에 currentIndex 가 1이면 translateX(-100%), 2이면 translateX(-200%) 가 돼야 한다.
이를 코드로 구현하면 다음과 같다.
추가적으로 transform 속성 변화가 500ms 동안 자연스럽게 진행되도록 애니메이션을 추가하였다.
return (
<div className="relative">
<div className="overflow-hidden rounded-2xl">
<div
className="flex transition-transform duration-500"
style={{ transform: `translateX(-${currentIndex * 100}%)` }}
>
{생략}
</div>
</div>
)
결과를 보면 잘 넘어가는 것을 확인할 수 있다.
그러나 마지막에서 첫 번째로 갈 때, translateX(-300%) 에서 translatxX(-0%) 로 가는 과정이 어색하다.
그래서 맨 처음에 말한 무한 캐러셀의 원리대로 눈속임용을 배열에 추가하여 만들어 볼 것이다.
3. 자연스러운 무한 캐러셀
각 슬라이드의 데이터를 담은 배열인 items 의 앞과 뒤를 확장한다.
const extendedItems = [items[items.length - 1], ...items, items[0]];
prev 이전, next 다음으로 이동하는 함수도 인덱스의 위치를 고려하지 않도록 다시 수정하고, currentIndex 를 1번부터 시작한다.
const [currentIndex, setCurrentIndex] = useState(1);
const goToPrevious = () => {
setCurrentIndex((prevIndex) => prevIndex - 1);
};
const goToNext = () => {
setCurrentIndex((prevIndex) => prevIndex + 1);
};
이제 "4번 -> 눈속임용 1번" 으로 이동하면 바로 "1번" 으로 이동하고,
"1번" -> "눈속임용 4번" 으로 이동하면 바로 "4번" 으로 이동한다.
useEffect(() => {
let timeoutId: NodeJS.Timeout;
if (currentIndex === 0) {
timeoutId = setTimeout(() => {
setCurrentIndex(extendedItems.length - 2);
}, 500);
} else if (currentIndex === extendedItems.length - 1) {
timeoutId = setTimeout(() => {
setCurrentIndex(1);
}, 500);
}
return () => {
clearTimeout(timeoutId);
};
}, [currentIndex, extendedItems.length]);
그러나 잘 이동은 되지만 "transition-transform duration-500" 때문에 눈속임용에서 일반으로 이동할 때 이동 과정이 눈에 보이게 된다.
그래서 이때 transition 이라는 새로운 값을 만들어 눈속임용에서는 애니메이션을 제거한다.
const Carousel = ({ items }: CarouselProps) => {
const extendedItems = [items[items.length - 1], ...items, items[0]];
const [currentIndex, setCurrentIndex] = useState(1);
// 새로 추가됨
const [transition, setTransition] = useState(true);
const goToPrevious = () => {
// 일반적인 이동은 true
setTransition(true);
setCurrentIndex((prevIndex) => (prevIndex -= 1));
};
const goToNext = useCallback(() => {
// 일반적인 이동은 true
setTransition(true);
setCurrentIndex((prevIndex) => (prevIndex += 1));
}, []);
useEffect(() => {
const interval = setInterval(() => {
goToNext();
}, 5000);
return () => clearInterval(interval);
}, [currentIndex, goToNext]);
useEffect(() => {
let timeoutId: NodeJS.Timeout;
if (currentIndex === 0) {
timeoutId = setTimeout(() => {
setCurrentIndex(extendedItems.length - 2);
// 눈속임용에서는 false
setTransition(false);
}, 500);
} else if (currentIndex === extendedItems.length - 1) {
timeoutId = setTimeout(() => {
setCurrentIndex(1);
// 눈속임용에서는 false
setTransition(false);
}, 500);
}
return () => {
clearTimeout(timeoutId);
};
}, [currentIndex, extendedItems.length]);
return (
<div className="relative h-200 w-full rounded-2xl md:h-240 lg:h-280">
<div className="h-full overflow-hidden rounded-2xl">
<div
// 눈속임용이 아닐땐 애니메이션 적용
className={`flex ${transition && "transition-transform duration-500"}`}
style={{
transform: `translateX(-${currentIndex * 100}%)`,
}}
>
{생략}
</div>
</div>
)
실행을 하면 자연스럽게 잘 이동하는 것을 확인할 수 있다.
4. 자동으로 이동하기
interval 을 사용하여 5초 기준으로 슬라이드가 자동으로 이동할 수 있게 하였다.
useEffect(() => {
const interval = setInterval(() => {
goToNext();
}, 5000);
return () => clearInterval(interval);
}, [currentIndex]);
5. 모바일 터치로 이동하기
추가적으로 모바일에서 터치로 prev 이전, next 다음 슬라이드로 이동할 수 있게 구현했다. 📲
const [touchStartX, setTouchStartX] = useState<number | null>(null);
const handleTouchStart = (e: React.TouchEvent) => {
setTouchStartX(e.touches[0].clientX);
};
const handleTouchEnd = (e: React.TouchEvent) => {
if (touchStartX !== null) {
const touchEndX = e.changedTouches[0].clientX;
const distance = touchStartX - touchEndX;
if (Math.abs(distance) > 50) {
if (distance > 0) {
goToNext();
} else {
goToPrevious();
}
}
setTouchStartX(null);
}
};
return (
<div
className="relative"
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{/* 생략 */}
</div>
)
발생한 오류
자동 이동과 모바일 터치까지 캐러셀 컴포넌트를 잘 구현했다고 생각했다. 😊
슬라이드가 숑 날라가는 현상
그러나... 베타 테스트에서 캐러셀의 버튼을 계속 클릭하면(500ms 보다 빠르게) 슬라이드가 숑💨 날라가서 빈 화면이 보인다고 피드백을 받았다. 🥲
그래서 슬라이드가 이동 중일 땐 다음으로 이동하지 못하도록 버튼을 disabled 하여 막는 방법으로 문제를 해결했다.
const [isTransitioning, setIsTransitioning] = useState(false);
const goToPrevious = () => {
setTransition(true);
// 이동 중 true
setIsTransitioning(true);
setCurrentIndex((prevIndex) => prevIndex - 1);
};
const goToNext = () => {
setTransition(true);
// 이동 중 true
setIsTransitioning(true);
setCurrentIndex((prevIndex) => prevIndex + 1);
};
useEffect(() => {
// ...생략
// 이동 완료 후(500ms 후) false 로
setTimeout(() => {
setIsTransitioning(false);
}, 500);
return () => {
clearTimeout(timeoutId);
};
}, [currentIndex, extendedItems.length]);
return (
<div
className="relative"
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{생략}
<button
type="button"
disabled={isTransitioning}
onClick={goToPrevious}
className="생략"
>
‹
</button>
{다음 버튼 생략}
</div>
)
전체 코드
블로그 글을 작성하면서 불필요한 스타일 코드를 몇 개 발견하였지만, 전체 코드는 아래 링크에서 확인할 수 있다.
마치며...
최종적으로 무한 캐러셀을 구현하게 되었다.
전부터 캐러셀을 직접 구현하고 싶었는데 드디어 직접 구현할 수 있게 되어서 너무 좋았다.
추가적으로 내가 미처 발견하지 못한 오류를 베타 테스트를 통해 찾아내어 개선할 수 있어서 또한 너무 좋았다.
프론트엔드 개발자로서 성장할 수 있는 발걸음이 된 거 같아 뿌듯하다.
'💜 프로젝트 구현' 카테고리의 다른 글
[Next.js / TanStack query] useInfiniteQuery 사용하여 무한 스크롤 구현 (feat. cursor 방식) (0) | 2024.09.07 |
---|---|
[Next.js] 세션 스토리지, 이전 페이지 저장해서 뒤로 가기 구현 (feat. app router) (0) | 2024.09.02 |
[GitHub Action / Chromatic] 스토리북 PR 미리보기 배포 (feat. yarn) (0) | 2024.08.08 |
DropDown 컴파운드 패턴으로 공통 컴포넌트 구현하기 (0) | 2024.07.29 |
[Next.js] React Quill 로 이미지 업로드 구현하기 (1) | 2024.07.17 |
FE 개발자가 되고 싶은 짱잼이
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!