FE/React

React Infinite Carousel 만들기(TS)

SH_Roh 2022. 11. 24. 01:45
반응형

저번에 과제를 진행하면서 캐러셀을 직접 구현해보았다. 생각보다 고려할 부분도 많았지만 재밌었다 :)

일단 좌, 우 버튼을 클릭해 양쪽으로 이동할 수 있고, 옆으로 넘기는 방식의 터치 이벤트로도 이동할 수 있도록 구현했다.

 

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를 이용해 슬라이드를 이동해주면 된다.

 

 

그런데 이렇게 하면 마지막 슬라이드에서 첫 슬라이드로 이동할 때 부자연스럽다.

이런 현상을 없애기 위해서는 다음과 같은 작업이 필요하다.

  1. 기존 배열의 맨 앞, 맨 뒤에 맨 마지막 요소와 첫 번째 요소를 추가해준다. (마지막 요소 - 기존 배열 - 첫 번째 요소)
  2. 마지막 슬라이드에서 다음 슬라이드로 넘어갈 때 복제한 슬라이드로 일단 이동한다.
  3. 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-구현-라이브러리-미사용

 

반응형