[Ez to Play _ Function] 아주 쉽고 누구나 할 수 있는 멋진 캐러셀(Carousel) 만들기

스택: React, TypeScript, Styled-Components

 

화질이 낮아서 슬프다..ㅠ

 

이번 팀 프로젝트에서 맡은 파트 중 캐러셀이 들어간 페이지가 있어 만들게 되었다. 캐러셀 라이브러리가 있다는 것을 알고 있었지만 무식하면 용감하다고 한 땀 한 땀 직접 짰다. 의외로 원리만 알면 간단하다. 

 

요런게 옆으로 계속 넘어가는게 캐러셀이다.

 

 

캐러셀이 동작하는 원리는 이렇다. 3개의 슬라이드가 있을 때, '하나의 슬라이드 가로폭 * 3'짜리 칸을 만들어 그 안에 슬라이드를 가로로 주르륵 줄 세워 놓고 클릭 또는 지정된 시간마다 좌표를 하나의 가로폭 만큼씩 빼주는 것이다. 이 때 css의 transition 속성을 이용하면 슬라이드를 부드럽게 넘길 수 있다.

transition: -100vw 0.5s 
//0.5초동안 100vw만큼 왼쪽으로 이동

아래 그림을 예시로 1에서 2로 넘어갈 땐 -100vh를 주는데, 다만 2에서 3으로 넘어갈 땐 어디까지나 가장 첫 슬라이드는 1번이기에 -200vw를 적용해 주어야 한다. 

 

하지만 여기서 문제는 3에서 어떻게 1로 돌아오느냐 이다. 3에서 다시 1로 돌아오기 위해 아무 생각 없이 transition을 0vw로 주게 되면 왼쪽으로 넘어가던 슬라이드가 갑자기 오른쪽으로 1번까지 우다다 이동하는걸 볼 수 있을 것이다(나는 봤다)

 

마지막 슬라이드에 도착하더라도 똑같이 왼쪽으로 넘어가면서 1로 돌아가는 방법은 아래와 같다. 마지막에 슬라이드를 하나 더 준비하는 것이다. 이 슬라이드는 첫 번째 슬라이드와 같아야 한다.

누구나 이해할 수 있는 무한 캐러셀 한 장 요약

이해하면 간단하다. 3번 뒤에 1번이 있기 때문에 똑같이 슬라이드를 왼쪽으로 넘기면 된다. 다만 3에서 1로 넘어가는 동작이 끝남과 동시에 사람 눈에 보이지 않을 만큼 빠르게 transition에 0을 줘 처음 위치로 되돌려야 한다. 그리고 아무 일 없었다는 듯 다시 2..3.. 으로 넘어가는 것이다. 

 

나는 왼쪽으로만 넘어가는 캐러셀을 만들었기 때문에 위와 같이 만들었지만 만약 버튼 등으로 좌, 우로 이동 가능한 캐러셀을 만든다면 1번 앞에 마지막 슬라이드와 같은 슬라이드를 추가해 넣고 원리를 반대로 적용하면 된다.

 

 

이제 이론 공부는 끝났으니 본격적으로 코드를 파헤쳐 보자.

 

 

일단 하나의 슬라이드를 만든다. 나는 피그마에 미리 디자인된 것과 똑같이 만들어야 하다보니 뒷 배경의 블러처리가 조금 고심했는데, 이와 관련된 내용은 여기 있다. 디자인 코드에 대해선 자세히 언급하지 않겠다.

https://itgoblin.tistory.com/110

 

[CSS] filter: blur() 와 backdrop-filter:blur()

css를 만지다가 배경을 블러처리 해야 하는 일이 생겨서 filter 와 backdrop-filter를 써보게 되었다. 내가 만들 것과 팀원분이 만든 것의 디자인이 비슷하기 때문에 코드를 복사한 후 div박스에 background

itgoblin.tistory.com

 

가장 처음 한 일은 일단 더미데이터에서 axios를 이용해 데이터(사진과 내용)를 받아오고, 그것을 map 함수를 돌려 슬라이드 리스트를 뽑는 것이었다. 그리고 그 map 함수 뒤에 따로 첫 번째 슬라이드를 붙여주었다. 받아온 데이터 리스트(코드에선 carouselData)의 0번째 index를 찾아 붙여주는 식이다. 

 

 

이 리스트들을 다시 carouselData.length !== 0 && 뒤에 붙인 이유는 화면 렌더링이 데이터 받아오는 속도보다 빠를 경우 생기는 오류를 방지하기 위해 데이터가 있을 때만 실행되도록 하기 위함이었다. 

 

 

여기까지 성공하면 슬라이드 4개가 주르륵 생긴 상태인데, 이 슬라이드들을 묶은 div를 만들고, transition과 transform을 지정했다. 

transition: -100vw 0.5s 이거를 transition: transform 0.5s/ transform: translate -100vw 이렇게 따로 지정한 것과 같다. 나는 슬라이드 하나의 가로폭이 390px이라 슬라이드가 옆으로 넘길 때 마다 0px, -390px, -780px ... 이런식으로 값을 바꿔줘야 하기 때문에 변화된 값을 props로 받는 형식을 취했다.  transition의 슬라이딩 시간까지 props로 받은 이유는 마지막 슬라이드에서 다시 1로 되돌아가기 위해 0.5s가 아닌 0s를 줘야 하는 순간이 있기 때문이다.

 

그럼 그 바뀌는 숫자들을 어떻게 props로 내려주는가. 아래를 보자.

일단 useState를 이용하여 숫자의 변화를 업데이트 시켜줄 상태관리 변수를 만들고, 모든 슬라이드들을 감싸는 태그에 연결시켜주었다. 위에 return문을 캡쳐한 이미지를 다시 보면 이해가 쉽다.

let [movementWidth, setMovementWidth] = useState(0);
let [time, setTime] = useState(0.5);
 <Styled_CarouselLogicStyled.Container
  translate={`translate(${movementWidth}px)`}
  transform={`transform ${time}s`}
 >
 
...

 </ Styled_CarouselLogicStyled.Container>

 

그리고 아래와 같이 좌표 지정 함수를 하나 만든다. 

마지막 슬라이드인 경우와 아닌 경우를 나누어 각각의 경우에 일어나야 할 변화를 적었는데, 마지막 슬라이드냐 아니냐는 현재 movementWidth값이 마지막 슬라이드 위치인 -780과 같냐 아니냐로 구분했다. 다만 -780을 바로 작성하지 않고 '-390 * 전체 리스트 길이의 개수 - 1'로 복잡하게 작성한 이유는 나중에 캐러셀 슬라이드가 3개가 아닌 5개, 6개가 되더라도 코드를 건드리지 않고 정상 작동할 수 있도록 하기 위함이다. -1은 가장 끝에 붙은 1번 슬라이드 복제품을 뺀 것이다.

 

 

마지막 슬라이드가 아닌 경우, 해 줘야 할 일은 간단하다. transition시간(time)을 0.5초로 주고, 현재 위치(movementWidth)에서 390을 뺀 값을 transform 값으로 넣어 준다. 현재 위치가 0이면 -390이, 현재 -390이라면 -780이 될 것이다.

 

마지막 슬라이드인 경우, 뒤에 1번 슬라이드 복제품을 하나 더 만들어 놓았기 때문에, 일단 똑같이 movementWidth - 390을 줘 옆으로 0.5초간 이동한다. 그리고 그 이동이 끝나면 바로 movementWidth와 time에 0을 줘 눈 깜박 할 사이에 1번 슬라이드로 이동하면 되는데, 바로 코드를 적으면 슬라이드의 0.5초 이동을 기다리지 않고 바로 코드가 실행되기 때문에, setTimeout이라는 함수를 이용해 0.5초 후 movementWidth와 time에 0이 적용되게끔 하였다. 

 

이제 슬라이드는 0.5초마다 넘어가지만, 이러한 동작들이 3초마다 일어나게 하고 싶다. 유저의 편안한 사용을 고려했을 때, 하나의 슬라이드가 화면에 노출되는 시간이 3초였으면 좋겠다. 

 

 

이럴땐 setInterval 함수를 쓸 수 있다. setInterval은 함수 안의 코드를 지정한 시간마다 반복 실행해준다. setInterval은 return값으로 고유한 interval ID를 갖는데 clearInterval의 인자로 setInterval을 담은 변수를 넣어줌으로서 interval ID를 지워 반복을 중단시킬 수 있다. 캐러셀이 멈춰있는 3초 외에도 케러셀이 움직이는 1초가 필요하기 때문에 그 시간이 겹치지 않도록 setInterval 한 번 실행하고 바로 clearInterval로 멈추기를 movementWidth가 변할 때 마다 반복하도록 해주었다.

 

이렇게 하면 3초마다 옆으로 넘어가는 예쁜 캐러셀을 만들 수 있다.

완성~

 

 

참고용 전체 코드

//전체 코드

import { useEffect, useState } from 'react';
import { CarouselList } from '../../zustand/homepage.stores';
import CarouselSlide from './CarouselSlide';
import { Styled_CarouselLogic } from './CarouselLogic.styled';
import { PerformanceType } from '../../model/Performance';

const Carousel = () => {
  let [movementWidth, setMovementWidth] = useState(0);
  let [time, setTime] = useState(0.5);
  const { carouselData } = CarouselList();

  /** 캐러셀 3초마다 동작 */
  useEffect(() => {
    const interval = setInterval(() => {
      SlideTransitionControl();
    }, 3000);

    return () => {
      clearInterval(interval);
    };
  }, [movementWidth]);

  /** 캐러셀 슬라이드 좌표 지정하는 함수 */
  function SlideTransitionControl() {
    // 마지막 슬라이드가 아닌 경우
    if (movementWidth !== -390 * (carouselData.length - 1)) {
      setTime(0.5);
      setMovementWidth(movementWidth - 390);
    }
    // 마지막 슬라이드인 경우
    else {
      /** 마지막 슬라이드에서 첫 슬라이드로 */
      // 0.5초동안 슬라이드 넘김
      setMovementWidth(movementWidth - 390);
      // 0.5초 후, 0초동안 첫 번째 슬라이드로 이동(사람 눈엔 보이지 않음)
      setTimeout(function () {
        setMovementWidth(0);
        setTime(0);
      }, 500);
    }
  }

  return (
    <>
      <Styled_CarouselLogic.Div>
        <Styled_CarouselLogic.Container
          translate={`translate(${movementWidth}px)`}
          transform={`transform ${time}s`}
        >
          {carouselData.length !== 0 && (
            <>
              {/* 슬라이드 리스트 */}
              {carouselData.map((v: PerformanceType, i) => {
                return (
                  <div key={i}>
                    <CarouselSlide
                      posterImg={v.imageUrl}
                      title={v.title}
                      performanceArtist={v.performanceArtist.performanceId}
                      category={v.category}
                      price={v.price}
                      date={v.date}
                      categoryId={v.categoryId}
                    />
                  </div>
                );
              })}
              {/* 무한 슬라이드를 위해 첫번째 슬라이드 복제 */}
              <div>
                <CarouselSlide
                  posterImg={carouselData[0].imageUrl}
                  title={carouselData[0].title}
                  performanceArtist={
                    carouselData[0].performanceArtist.performanceId
                  }
                  category={carouselData[0].category}
                  price={carouselData[0].price}
                  date={carouselData[0].date}
                  categoryId={carouselData[0].categoryId}
                />
              </div>
            </>
          )}
        </Styled_CarouselLogic.Container>
      </Styled_CarouselLogic.Div>
    </>
  );
};

export default Main;