[React] 라이브러리 쓰지 않고 캐러셀 구현하기

힛짱·2022년 12월 31일
1

React

목록 보기
4/6
post-thumbnail

🎠 캐러셀 기능


프로젝트 메인 페이지의 캐러셀을 구현해보았다. 라이브러리를 사용하지 않고 구현해보고 싶어서 구글링을 하며 관련 정보를 얻었고, 내 코드에 적용시켜 보았다. 내가 하고 싶은 기능은 크게 3가지이다.

  1. 좌우 버튼 클릭 시 이동
  2. 하단 페이지네이션
  3. 자동 슬라이드

⚡️ 코드 흐름


먼저 전체 코드를 살펴보고 하나씩 쪼개서 파헤쳐보자!

캐러셀 컴포넌트

const HomeCarousel = () => {
  const TOTAL_SLIDES = 4;
  const [slideIndex, setSlideIndex] = useState(0);
  const [thumbnail, setThumbnail] = useState([]);
  const navigate = useNavigate();

  // 썸네일 리스트 API
  useEffect(() => {
    const getThumbnail = async () => {
      // API 요청
      // ... 생략
    };
    getThumbnail();
  }, []);

  // 캐러셀 이미지 상세 페이지 이동
  const handleDetailPost = ({ item }) => {
    navigate('/photodetail', {
      state: {
        ...
      },
    });
  };

  // 캐러셀 자동 슬라이드
  useInterval(() => {
    if (slideIndex === 4) {
      setSlideIndex(0);
    } else {
      setSlideIndex(slideIndex + 1);
    }
  }, 3500);

  // 오른쪽 버튼 클릭 시 오른쪽으로 슬라이드 이동
  const nextSlide = () => {
    if (slideIndex !== TOTAL_SLIDES) {
      setSlideIndex(slideIndex + 1);
    }
  };
  
  // 왼쪽 버튼 클릭 시 왼쪽으로 슬라이드 이동
  const prevSlide = () => {
    if (slideIndex !== 0) {
      setSlideIndex(slideIndex - 1);
    }
  };

  // 하단 버튼
  const movePage = (index) => {
    setSlideIndex(index);
  };

  return (
    <Carousel>
      {thumbnail.map((item, index) => (
        <ThumbnailWrap
          key={index}
          className={slideIndex === index ? 'active' : null}
          style={
            slideIndex === 5
              ? { transform: 'translateX(0px)' }
              : { transform: `translateX(-${slideIndex}00%)` }
          }
        >
          <Title>{item.itemName}</Title>
          <Thumbnail
            src={item.itemImage}
            alt=""
            onClick={() => handleDetailPost({ item })}
          />
        </ThumbnailWrap>
      ))}

      {slideIndex !== 0 && (
        <HomeCarouselPagination moveSlide={prevSlide} direction="prev" />
      )}
      {slideIndex !== TOTAL_SLIDES && (
        <HomeCarouselPagination moveSlide={nextSlide} direction="next" />
      )}

      <IconWrap>
        {Array.from({ length: 5 }).map((item, index) => (
          <PageIcon
            key={index}
            onClick={() => movePage(index)}
            className={slideIndex === index ? 'icon active' : 'icon'}
          />
        ))}
      </IconWrap>
    </Carousel>
  );
};

• slideIndex 로 캐러셀 관리

  const TOTAL_SLIDES = 4;
  const [slideIndex, setSlideIndex] = useState(0);

먼저, useState를 통해 slideIndex 초기값을 0으로 설정하여 캐러셀을 관리한다.
TOTAL_SLIDES는 마지막 페이지에서의 다음 페이지 이동을 방지하기 위해 설정한다.

• 슬라이드 이동 값 설정

  {thumbnail.map((item, index) => (
    <ThumbnailWrap
      key={index}
      className={slideIndex === index ? 'active' : null}
      style={
        slideIndex === 5
          ? { transform: 'translateX(0px)' }
        : { transform: `translateX(-${slideIndex}00%)` }
      }
      >
      ...
    </ThumbnailWrap>
  ))}

API로 캐러셀 이미지를 가져와 thumbnail에 데이터가 저장된다. 이미지를 렌더링할 때, css의 transform: translateX() 속성을 slideIndex에 따라 수정한다.

• 하단 페이지네이션

  // 하단 버튼
  const movePage = (index) => {
    setSlideIndex(index);
  };

  return (
    <Carousel>
      ...

      <IconWrap>
        {Array.from({ length: 5 }).map((item, index) => (
          <PageIcon
            key={index}
            onClick={() => movePage(index)}
            className={slideIndex === index ? 'icon active' : 'icon'}
          />
        ))}
      </IconWrap>
    </Carousel>
  );

총 5페이지로 캐러셀을 구성할 계획이라 배열을 만들어 하단 페이지네이션 버튼을 만들어준다. Array.from() 메서드로 배열을 만들건데,

잠깐! 간단하게 Array.from() 메서드에 대해 알아보자!

Array.from()

Array.from() 메서드는 유사 배열 객체(array-like object)나 반복 가능한 객체(iterable object)를 얕게 복사해 새로운 Array객체를 만든다.

Array.from() - JavaScript | MDN

코드에서 { length: 5 }는 5 길이의 유사 배열 객체를 생성한다.

만약 유사 배열 객체에 length 값만 입력한다면 Array.from 메서드가 값이 undefined로 채워진 배열을 반환한다. 나는 인덱스 값으로 이루어진 배열이 필요하기 때문에 인덱스를 참조하여 새로운 배열의 값을 반환하도록 해야한다.

Array.from()은 선택 매개변수인 mapFn를 가지는데, 배열(혹은 배열 서브클래스)의 각 요소를맵핑할 때 사용할 수 있습니다. 즉, Array.from(obj, mapFn, thisArg)는 중간에 다른 배열을 생성하지 않는다는 점을 제외하면 Array.from(obj).map(mapFn, thisArg)와 같습니다. 이 특징은 typed arrays와 같은 특정 배열 서브클래스에서 중간 배열 값이 적절한 유형에 맞게 생략되기 때문에 특히 중요합니다.

라고 mdn 문서에서 설명하고 있다. map메서드는 첫 번째 매개변수(currentValue)로 값을, 두 번째 매개변수(index)로 인덱스를 참조할 수 있다. 위의 설명과 같이 Array.from 메서드에서도 인덱스를 참조하려면 두 개의 매개변수가 필요하다.

즉, 내가 원하는 결과를 만들기 위해선 인덱스를 참조하여 새로운 배열의 값을 반환하기 위해 길이가 5인 배열을 만들고 맵핑해야 한다.

export const PageIcon = styled.div`
  display: inline-block;
  margin: 4px;
  width: 8px;
  height: 8px;
  background-color: white;
  box-shadow: 1px 1px 2px var(--black);
  border-radius: 4px;
  cursor: pointer;
  transition: ease-in 0.4s;
  &.icon {
    opacity: 0.4;
  }
  &.icon.active {
    opacity: 0.8;
  }
`;

해당 페이지 index값과 아이콘의 index값이 같다면 icon active를, 다를 경우 icon 클래스명을 적용하여 opacity속성으로 변화를 주고 transition속성을 적용하여 자연스러운 효과를 준다.

• 좌우 버튼 클릭시 페이지 이동

  // 오른쪽 버튼 클릭 시 오른쪽으로 슬라이드 이동
  const nextSlide = () => {
    if (slideIndex !== TOTAL_SLIDES) {
      setSlideIndex(slideIndex + 1);
    }
  };
  
  // 왼쪽 버튼 클릭 시 왼쪽으로 슬라이드 이동
  const prevSlide = () => {
    if (slideIndex !== 0) {
      setSlideIndex(slideIndex - 1);
    }
  };

  return (
    <Carousel>
      ...
      
      {slideIndex !== 0 && (
        <HomeCarouselPagination moveSlide={prevSlide} direction="prev" />
      )}
      {slideIndex !== TOTAL_SLIDES && (
        <HomeCarouselPagination moveSlide={nextSlide} direction="next" />
      )}

      ...
    </Carousel>
  );

'>' 버튼 (nextSlide) 클릭 시 slideIndex + 1 이동
'<' 버튼 (prevSlide) 클릭 시 slideIndex - 1 이동

HomeCarouselPagination 컴포넌트를 호출하여 moveSlide(슬라이드 이동 함수)와 direction을 전달한다.


페이지네이션 컴포넌트

const HomeCarouselPagination = ({ direction, moveSlide }) => {
  return (
    <button type="button" onClick={moveSlide}>
      {direction === 'next' && <MoveBtn className="right" type="button" />}
      {direction === 'prev' && <MoveBtn className="left" type="button" />}
    </button>
  );
};

moveSlidedirection을 전달받아 조건에 맞는 button 요소를 반환한다.


useInterval 커스텀 훅

  // 캐러셀 자동 슬라이드
  useInterval(() => {
    if (slideIndex === 4) {
      setSlideIndex(0);
    } else {
      setSlideIndex(slideIndex + 1);
    }
  }, 3500);

커스텀 훅을 적용하기 전에 먼저, 자동 슬라이드 기능을 구현하기 위해 setInterval() 메서드를 사용해야 한다고 생각했다.

간단하게 setInterval() 메서드를 알아보자!

setInterval()

지정된 시간 간격마다 지정된 기능을 반복하고자 할 때 사용한다. 이는 특정 코드나 주어진 함수를 지정된 간격으로 호출한다. 이 메소드는 윈도우가 닫히거나 clearInterval() 메소드가 호출될 때 까지 계속 실행되고 리턴값으로 0이 아닌 숫자인 타이머 id를 반환한다.

JavaScript setInterval() method

일단 적용해보자!

• 시도 1.

  setInterval(() => {
    if (slideIndex === 4) {
      setSlideIndex(0);
    } else {
      setSlideIndex(slideIndex + 1);
    }
  }, 3500);

많이 이상하다. 3500ms 이후 그 타이밍의 상태 값으로 돌아갔다가 렌더링되기를 반복한다.
구글링 해보니 React에서는 state가 변하면 리렌더링되기 때문에 setInterval()메서드는 무한히 실행되버린다. 그렇다면 렌더링 시에만 실행되도록 useEffect()안에서 실행하면 어떨까?

• 시도 2.

  useEffect(() => {
    const timer = setInterval(() => {
      if (slideIndex === 4) {
        setSlideIndex(0);
      } else {
        setSlideIndex(slideIndex + 1);
      }
    }, 3500);
    return () => clearInterval(timer);
  }, [slideIndex]);

오잉? 이 코드를 적용했을 때 자동 슬라이드 기능이 잘된다! 그러나 구글링을 통해 안 사실은 setInterval메서드는 함수를 실행하는 시간조차 delay에 포함시키기 때문에 우리가 원하는 delay 시간을 100% 보장하지 못하며 만약 함수를 실행하는 시간이 delay 시간보다 길다면 타이머가 제대로 작동하지 않는다.

예를 들어 1초 마다 한 번씩 함수가 호출되도록 했다. 그런데 함수 실행이 1초보다 길어져버리면 어떻게 될까? 함수가 실행이 끝난 후에 1초를 기다리지 않고 다음 함수를 바로 실행해버린다.

만약 위의 코드에 3500ms 마다 서버에 요청해 데이터를 불러오는 메서드를 실행하고 그 과정이 3500ms 이상 걸린다면 이러한 문제가 나타날 수 있을 것이다.

useInterval 커스텀 훅을 적용하자!

const useInterval = (callback, delay) => {
  const savedCallback = useRef();

  useEffect(() => {
    savedCallback.current = callback;
  }, [callback]);

  useEffect(() => {
    function tick() {
      savedCallback.current();
    }

    if (delay !== null) {
      const id = setInterval(tick, delay);
      return () => clearInterval(id);
    }
  }, [delay]);
};

위의 useInterval 커스텀 훅은 Dan Abramov 개발자에 의해 구현된 훅이다. 그는 React hooks 컴포넌트에서 setInterval 사용 시의 문제점을 설명하며 커스텀 훅을 제시했다.

• 리렌더링 방지를 위한 useRef 의 사용

  const savedCallback = useRef(); // 최근에 들어온 callback을 저장할 ref 생성

useRef

useRef.current프로퍼티로 전달된 인자(initialValue)로 초기화된 변경 가능한 ref객체를 반환합니다. 반환된 객체는 컴포넌트의 전 생애주기를 통해 유지될 것입니다. 본질적으로 useRef.current프로퍼티에 변경 가능한 값을 담고 있는 상자와 같습니다.

Hook API reference | React

React 공식 문서의 정의이다. 정의가 참 와닿지 않는다... 그래서 useRef의 중요한 특징만 말하자면, 저장공간(변수 관리)과 DOM 요소 접근, 리렌더링 방지를 위해 사용한다.

useInterval에서 useRef를 사용한 이유는 바로 리렌더링을 방지하기 위해서다.

useRef는 함수형 프로그래밍에서 사용하는 ref로 초기화된 ref 객체인 {current: null}을 반환하며 반환된 객체는 컴포넌트의 전 생애주기 동안 유지되어 useRef로 관리하는 값은 값이 변경되어도 컴포넌트가 리렌더링 되지 않는다.

만약, 값이 변경될 때마다 리렌더링되는 useState로 데이터를 관리하게 된다면, useEffect() 내부에서 savedCallback 값이 변경될 때마다 리렌더링이 일어나게 된다. 그래서 tick()함수 안의 savedCallback 값을 확인하면 계속해서 초기값만 가져오게 될 것이다.


• callback이 바뀔 때마다 실행하는 useEffect

  useEffect(() => {
    savedCallback.current = callback; // callback이 바뀔 때마다 ref를 업데이트
  }, [callback]);

callback 데이터가 바뀔 때마다 savedCallback의 current 값이 새로운 callback 데이터로 업데이트 된다.

• delay 값이 바뀔 때마다 실행하는 useEffect

  useEffect(() => {
    function tick() {
      savedCallback.current(); // tick 함수가 실행되면 callback 함수를 실행
    }

    if (delay !== null) {
      const id = setInterval(tick, delay); // delay에 맞추어 interval을 새로 실행
      return () => clearInterval(id); // unmount될 때 clearInterval
    }
  }, [delay]);

두 번째 useEffectsetInterval 함수에 첫 번째 useEffect를 통해 저장한 callback 함수를 전달해 실행되도록 한다. delay가 변경될 때마다 실행되며 delay가 null이 아닐 경우 setInterval 함수를 호출하여 callback 함수를 실행한다. 이 후 언마운트 될 때 clearInterval을 실행한다.



참고 :

profile
프론트엔드 개발자 장희수입니다😉

0개의 댓글