저번에 과제를 진행하면서 캐러셀을 직접 구현해보았다. 생각보다 고려할 부분도 많았지만 재밌었다 :)
일단 좌, 우 버튼을 클릭해 양쪽으로 이동할 수 있고, 옆으로 넘기는 방식의 터치 이벤트로도 이동할 수 있도록 구현했다.
React 무한 캐러셀 구현
Home/index.tsx
import Carousel from 'components/Carousel'
const CAROUSEL_IMAGES = [
'https://img.freepik.com/free-photo/vivid-blurred-colorful-background_58702-2545.jpg',
'https://img.freepik.com/premium-vector/abstract-pastel-color-background-with-pink-purple-gradient-effect-graphic-design-decoration_120819-463.jpg',
'https://media.architecturaldigest.com/photos/6080a73d795a7b010f3dd2e0/2:1/w_2700,h_1350,c_limit/GettyImages-1213929929.jpg',
]
const Home = () => {
return (
<div>
<Carousel carouselList={CAROUSEL_IMAGES} />
</div>
)
}
export default Home
일단 캐러셀을 컴포넌트로 만들어주고, 안에 들어갈 이미지의 링크는 따로 배열에 담아 prop으로 넘겨주도록 했다.
슬라이드 이동
Carousel/index.tsx
return (
<div className={styles.container}>
<div className={styles.carouselWrapper}>
<button type='button' className={styles.swipeLeft}>
<ChevronLeft />
</button>
<button type='button' className={styles.swipeRight}>
<ChevronRight />
</button>
<ul className={styles.carousel}>
{currList?.map((image, idx) => {
const key = `${image}-${idx}`
return (
<li key={key} className={styles.carouselItem}>
<img src={image} alt='carousel-img' />
</li>
)
})}
</ul>
</div>
</div>
)
기본적인 구조는 위와 같다.
const Carousel = ({ carouselList }: Props) => {
const [currIndex, setCurrIndex] = useState(0)
const carouselRef = useRef<HTMLUListElement>(null)
// ...
}
현재 슬라이드의 인덱스를 저장할 currIndex state를 생성한다.
useEffect(() => {
if (carouselRef.current !== null) {
carouselRef.current.style.transform = `translateX(-${currIndex}00%)`
}
}, [currIndex])
그리고 currIndex가 변경될 때마다 translateX를 이용해 슬라이드를 이동해주면 된다.
그런데 이렇게 하면 마지막 슬라이드에서 첫 슬라이드로 이동할 때 부자연스럽다.
이런 현상을 없애기 위해서는 다음과 같은 작업이 필요하다.
- 기존 배열의 맨 앞, 맨 뒤에 맨 마지막 요소와 첫 번째 요소를 추가해준다. (마지막 요소 - 기존 배열 - 첫 번째 요소)
- 마지막 슬라이드에서 다음 슬라이드로 넘어갈 때 복제한 슬라이드로 일단 이동한다.
- translate가 종료된 즉시 실제로 이동해야 하는 슬라이드로 효과 없이 바로 이동해서 현재 슬라이드를 대체한다.
이렇게 해주면 마지막 슬라이드에서 첫 번째 슬라이드로 자연스럽게 넘어가는 것 같은 효과를 준다!
const [currList, setCurrList] = useState<string[]>()
useEffect(() => {
if (carouselList.length !== 0) {
const startData = carouselList[0]
const endData = carouselList[carouselList.length - 1]
const newList = [endData, ...carouselList, startData]
setCurrList(newList)
}
}, [carouselList])
새로운 배열을 만들어 currList에 저장해준다.
const moveToNthSlide = (index: number) => {
setTimeout(() => {
setCurrIndex(index)
if (carouselRef.current !== null) {
carouselRef.current.style.transition = ''
}
}, 500)
}
const handleSwipe = (direction: number) => {
const newIndex = currIndex + direction
if (newIndex === carouselList.length + 1) {
moveToNthSlide(1)
} else if (newIndex === 0) {
moveToNthSlide(carouselList.length)
}
setCurrIndex((prev) => prev + direction)
if (carouselRef.current !== null) {
carouselRef.current.style.transition = 'all 0.5s ease-in-out'
}
}
moveToNthSlide는 transition이 끝난 후 바로 transition을 없애주면서 실제 슬라이드로 바로 이동해주는 함수이다.
handleSwipe는 이동할 방향(1이면 오른쪽으로, -1이면 왼쪽으로 이동)을 받아 해당 슬라이드로 이동시켜준다.
터치 이벤트 추가하기
return (
<div className={styles.container}>
<div
className={styles.carouselWrapper}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<button type='button' className={styles.swipeLeft} onClick={() => handleSwipe(-1)}>
<ChevronLeft />
</button>
<button type='button' className={styles.swipeRight} onClick={() => handleSwipe(1)}>
<ChevronRight />
</button>
<ul className={styles.carousel} ref={carouselRef}>
{currList?.map((image, idx) => {
const key = `${image}-${idx}`
return (
<li key={key} className={styles.carouselItem}>
<img src={image} alt='carousel-img' />
</li>
)
})}
</ul>
</div>
</div>
)
터치 이벤트를 위해서는 onTouchStart, onTouchMove, onTouchEnd 속성을 사용해주어야 한다.
const handleTouchStart: TouchEventHandler<HTMLDivElement> = (e) => {
console.log(e.nativeEvent.touches[0].clientX, 'start!')
}
const handleTouchEnd: TouchEventHandler<HTMLDivElement> = (e) => {
console.log(e.nativeEvent.changedTouches[0].clientX, 'end!')
}
먼저 터치 시작 지점과 끝 지점에서의 x좌표값을 찍어보면 아래와 같이 나온다.
왼쪽에서 오른쪽으로 밀 때는 x값이 증가하고, 반대의 경우 x값이 감소한다.
let touchStartX: number
let touchEndX: number
const Carousel = ({ carouselList }: Props) => {
const handleTouchStart: TouchEventHandler<HTMLDivElement> = (e) => {
touchStartX = e.nativeEvent.touches[0].clientX
}
const handleTouchMove: TouchEventHandler<HTMLDivElement> = (e) => {
const currTouchX = e.nativeEvent.changedTouches[0].clientX
if (carouselRef.current !== null) {
carouselRef.current.style.transform = `translateX(calc(-${currIndex}00% - ${
(touchStartX - currTouchX) * 2 || 0
}px))`
}
}
const handleTouchEnd: TouchEventHandler<HTMLDivElement> = (e) => {
touchEndX = e.nativeEvent.changedTouches[0].clientX
if (touchStartX >= touchEndX) {
handleSwipe(1)
} else {
handleSwipe(-1)
}
}
}
그렇기 때문에 시작 지점의 x좌표가 끝 지점의 x좌표보다 클 경우, 오른쪽 슬라이드로 넘어가야 하기 때문에 handleSwipe에 1을 전달해주면 된다.
그리고 handleTouchMove 함수를 통해 터치를 하는 동안 translate 효과도 부여해준다.
전체 코드
Carousel/index.tsx
import { TouchEventHandler, useEffect, useRef, useState } from 'react'
import { ChevronLeft, ChevronRight } from 'assets/svgs'
import styles from './carousel.module.scss'
interface Props {
carouselList: string[]
}
let touchStartX: number
let touchEndX: number
const Carousel = ({ carouselList }: Props) => {
const [currIndex, setCurrIndex] = useState(1)
const [currList, setCurrList] = useState<string[]>()
const carouselRef = useRef<HTMLUListElement>(null)
useEffect(() => {
if (carouselList.length !== 0) {
const startData = carouselList[0]
const endData = carouselList[carouselList.length - 1]
const newList = [endData, ...carouselList, startData]
setCurrList(newList)
}
}, [carouselList])
useEffect(() => {
if (carouselRef.current !== null) {
carouselRef.current.style.transform = `translateX(-${currIndex}00%)`
}
}, [currIndex])
const moveToNthSlide = (index: number) => {
setTimeout(() => {
setCurrIndex(index)
if (carouselRef.current !== null) {
carouselRef.current.style.transition = ''
}
}, 500)
}
const handleSwipe = (direction: number) => {
const newIndex = currIndex + direction
if (newIndex === carouselList.length + 1) {
moveToNthSlide(1)
} else if (newIndex === 0) {
moveToNthSlide(carouselList.length)
}
setCurrIndex((prev) => prev + direction)
if (carouselRef.current !== null) {
carouselRef.current.style.transition = 'all 0.5s ease-in-out'
}
}
const handleTouchStart: TouchEventHandler<HTMLDivElement> = (e) => {
touchStartX = e.nativeEvent.touches[0].clientX
}
const handleTouchMove: TouchEventHandler<HTMLDivElement> = (e) => {
const currTouchX = e.nativeEvent.changedTouches[0].clientX
if (carouselRef.current !== null) {
carouselRef.current.style.transform = `translateX(calc(-${currIndex}00% - ${
(touchStartX - currTouchX) * 2 || 0
}px))`
}
}
const handleTouchEnd: TouchEventHandler<HTMLDivElement> = (e) => {
touchEndX = e.nativeEvent.changedTouches[0].clientX
if (touchStartX >= touchEndX) {
handleSwipe(1)
} else {
handleSwipe(-1)
}
}
return (
<div className={styles.container}>
<div
className={styles.carouselWrapper}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<button type='button' className={styles.swipeLeft} onClick={() => handleSwipe(-1)}>
<ChevronLeft />
</button>
<button type='button' className={styles.swipeRight} onClick={() => handleSwipe(1)}>
<ChevronRight />
</button>
<ul className={styles.carousel} ref={carouselRef}>
{currList?.map((image, idx) => {
const key = `${image}-${idx}`
return (
<li key={key} className={styles.carouselItem}>
<img src={image} alt='carousel-img' />
</li>
)
})}
</ul>
</div>
</div>
)
}
export default Carousel
Carousel/carousel.module.scss
@use '/src/styles/constants/colors';
.container {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
.carouselWrapper {
position: relative;
width: 100%;
padding: 0 10%;
overflow: hidden;
&:hover {
.swipeLeft,
.swipeRight {
position: absolute;
top: 45%;
z-index: 1;
display: block;
padding: 8px 6px;
background-color: colors.$GRAYE;
border-radius: 10px;
}
}
.swipeLeft,
.swipeRight {
display: none;
}
.swipeLeft {
left: 0;
}
.swipeRight {
right: 0;
}
.carousel {
display: flex;
width: 100%;
li {
flex: none;
object-fit: contain;
}
}
}
.carouselItem {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 350px;
padding: 10px 0 15px;
overflow: hidden;
border-right: 2px solid colors.$WHITE;
border-left: 2px solid colors.$WHITE;
transition: border 300ms;
img {
flex-shrink: 0;
min-width: 100%;
min-height: 100%;
}
}
:root[color-theme='dark'] & {
.carouselItem {
border-right: 2px solid colors.$GRAY2;
border-left: 2px solid colors.$GRAY2;
}
}
}
References
https://ye-yo.github.io/react/2022/01/21/infinite-carousel.html
https://velog.io/@jujusnake/JULABO-React.js로-Infinite-Carousel-구현-라이브러리-미사용
'FE > React' 카테고리의 다른 글
[React] 재귀 컴포넌트로 트리 메뉴 만들기(1) (0) | 2022.12.20 |
---|---|
[React] Storybook 사용해보기 (0) | 2022.12.02 |
React 스톱워치 구현하기(TS) (0) | 2022.11.17 |
React 다크 모드 구현하기(with Recoil, SCSS) (0) | 2022.11.06 |
React 프로젝트에 i18n으로 다국어 지원하기 (0) | 2022.10.31 |