My Boundary As Much As I Experienced

야놀자 부트캠프 파이널 프로젝트 Percent Hotel 회고 1) 캐로셀 애니메이션 제작기 본문

Projects/Yanolja Bootcamp's

야놀자 부트캠프 파이널 프로젝트 Percent Hotel 회고 1) 캐로셀 애니메이션 제작기

Bumang 2024. 3. 5. 03:43
⚠️참고 사항: 이 기능의 최대 contributor는 @im-na0님(https://github.com/im-na0)입니다.

캐로셀 구현은 우리팀 에이스 @im-na0님이 주도하셨다.(가명을 위해 깃허브 닉네임을 쓰겠다.)
@im-na0님이 먼저 상세페이지의 캐로셀을 구현하기 위해 전체 형태와 드래그 기능을 구현하셨고,
나는 이 컴포넌트와 훅을 복사해와서 나는 메인페이지의 '지역별 최대할인숙소'과 '주말특가'에 맞게 컨테이너를 변형하여 사용하였다. 그러면서 추가로 인디케이터를 만들고 애니메이션 작업을 진행하였다.

 

특이한 Carousel을 구현해달라는 부탁을 받다. 

프로젝트 초반에 내가 인터랙션 구현에 관심이 많다고 어필했더니 기획자 분들과 UX 디자이너 분이 인터랙션 일감을 가져와주셨다.

3초마다 자동 재생되는 캐로셀에 맞춰 할인률 텍스트가 상하 슬라이드 애니메이션이 되게 구현해주세요.

 

이때만 해도 프론트 2명이 중도 이탈하기 전이고..  프론트 팀은 인원(5명)이 많아서 후반에 할 일이 너무 없지 않을까, 콩 한 쪽 나눠 먹기라고 해야되지 않을까, 걱정하던 차라 선뜻 가능하다고 말씀드렸다!

 

 

Carousel을 자체 구현하다. 

사실 캐로셀 라이브러리가 잘 구현되어 있는 것도 많으니 원래 기성 라이브러리를 쓸까 고민했다. 그렇지만 '서울', '최대 N% 할인'같은 텍스트 모션도 슬라이드 변화에 같이 연동되어야하는데 기성 캐로셀로도 어느정도 커스텀이 필요해보였다. 그래서 기성 라이브러리도 개조가 필요할거면 차라리 캐로셀 라이브러리를 직접 구현해보기로 하였다.  ClickEvent와 DragEvent, TouchEvent를 모두 다뤄보는 좋은 기회가 될거라 생각했다.

 


Im-na0님이 주도하신 부분

일단 나영님께서 먼저 진행해주신 부분을 설명하겠다.

나는 이를 메인페이지에서 사용하기 위해 조금 수선을 했지만 기본적으로 나영님께서 먼저 로직을 짜주셨다.

더보기

useCarouselSize로 화면 사이즈에 따른 캐로셀 컨테이너 width값 변화 탐지

일단 간단한 커스텀 훅부터 확인해보자. 퍼센트 호텔은 모바일부터 타블렛 사이즈 360~900px까지 width폭을 지원한다.

1440px이든 1920px이든 그 이후부터는 중앙 정렬된다. 그러므로 화면 폭이 좁아진다면 carousel의 width는 줄어들게 되고

한 번 슬라이딩할 때 변해야되는 x값도 계속 바뀐다. 이를 측정하기 위해 useCarouselSize가 필요했다.

 

// useCarouselSize
import { useEffect, useRef, useState } from "react";

export const useCarouselSize = () => {
  const sliderRef = useRef<HTMLDivElement>(null); // 슬라이더를 지정할 ref
  const [slideWidth, setSlideWidth] = useState(0); // 한번 스와이프 할때마다 이동할 x값

  useEffect(() => {
    const updateSize = () => {
      if (!sliderRef.current) return; // sliverRef가 초기화 되지 않았으면 early return
      
      // 부모 컨테이너의 width를 setSlideWidth에 지정
      const parentWidth =
        sliderRef.current.parentElement?.getBoundingClientRect().width ?? 0; //
      setSlideWidth(parentWidth);
    };
    
    // 리사이즈 이벤트에 따라 updateSize 콜백을 실행
    window.addEventListener("resize", updateSize);
    updateSize(); // 최초 1회 실행
    return () => window.removeEventListener("resize", updateSize); // 페이지 이탈 시 클린업
  }, [sliderRef.current]);

  return {
    slideWidth, // 측정한 슬라이더 width와 sliderRef를 반환
    sliderRef,
  };
};

 

 

주어진 값이 최소값과 최대값 사이에 있는지 확인하는 inRange함수 제작

주어진 값이 최소값과 최대값 사이에 있는 지 확인하는 함수이다.

// 주어진 값이 최소값과 최대값 사이에 있는지 확인하는 유틸리티 함수
const inrange = (v: number, min: number, max: number) => {
  // 값이 최소값 미만일 경우 최소값 반환
  if (v < min) return min;
  // 값이 최대값 초과일 경우 최대값 반환
  if (v > max) return max;
  // 최소값과 최대값 사이에 있을 경우 해당 값 반환
  return v;
};

 

주로 이런식으로 많이 쓰였다.

deltaX(X변화값)이 50 이상 증가했을 때, currentIndex에 +1 한게 maxIndex를 넘으면 maxIndex를 그대로 유지

deltaX(X변화값)이 50 이하 감소했을 때, currentIndex에 -1 한게 첫번째 인덱스[0]보다 작으면 첫번째 인덱스 그대로 유지

    if (deltaX < -50) setCurrentIndex(inrange(currentIndex + 1, 0, maxIndex));
    if (deltaX > 50) setCurrentIndex(inrange(currentIndex - 1, 0, maxIndex));

 

반대로 currentIndex + 1한게 최소값 바닥, 최대값 천장에도 닿지 않는다면 그냥 그대로 currentIndex를 업데이트했다.

이 뿐만이 아니라 캐로셀이 infinite일 때는 maxIndex 초과 시 0으로 돌아가는 식으로 inrange함수를 구현하기도 했다.

 

 

getSliderStyle, 인덱스 변화와 드래그 수치에 따라 바뀌는 스타일 객체 함수 생성

캐로셀 이동하는 방법은 크게 2가지가 있다.

1. 화살표를 클릭하여 다음 슬라이드로 이동, 2. 스와이프로 이동

 

앞서 본 바와 같이 나는 1번의 경우 currentIndex라는 상태로, 2번의 경우에는 transX라는 상태로 관리하였다.

그리고 이를 반영하여 transform: translateX()에 제공하고 style로 쓰일 객체를 반환받는 함수 getSliderStyle을 생성하였다.

 

  • 마우스로 화살표를 클릭해서 이동할 시 currentIndex를 변화시킨다. 그러면 currentIndex * slideWidth 값이 변하여 애니메이션 이동이 발생한다.
  • drag로 이동했을 시는 transX값이 변화하지만 transX의 값이 +50px이상이면 앞으로 이동, -50px이하이면 뒤로 이동한 뒤 transX의 값은 0으로 초기화시킨다.

그렇다면 어차피 transX값은 계속 0일텐데 transform에 넣어둔 이유가 무엇인지 궁금할 것이다.

사용자가 슬라이더를 스와이핑할 때 슬라이더를 드래그한 만큼 넘어가는 것을 보여줘야 하기 때문이다.

transX값을 더해주지 않았을 때는 사용자가 자신이 얼마나 스와이핑 하고 있는지는 모른 채 정적인 화면을 보고 있다가 마우스업을 했을 때 한 번에 넘어가는 것을 보게 된다.

  // 슬라이더의 스타일을 반환하는 함수. 이를 jsx에 인라인 방식으로 제공한다.
  const getSliderStyle = () => {
    return {
      // 현재 슬라이드 위치에 따라 transform 속성을 설정하여 슬라이드 이동
      transform: `translateX(${-currentIndex * slideWidth + transX}px)`,
      // 애니메이션 효과를 적용하기 위한 transition 속성 설정
      transition: `transform ${animate ? 300 : 0}ms ease-in-out 0s`,
    };
  };

 

 

 

드래그 시작한 값과 끝난 값의 x 좌표값 차를 이용하여 드래그 스와이프 구현

클릭을 시작한 좌표값과 끝낸 좌표값의 차이를 계산하여 delta(변화량)값을 도출하였다.

클릭을 시작하고 이를 유지한 채로 50px이상을 움직였으면 드래그로 판단하였다.

(첫 마우스 다운 이벤트의 pageX - 마지막 마우스 이벤트의 pageX 이벤트)

그리고 방향에 따라 인덱스를 증가시키거나 감소시키고 transX값을 초기화 시켰다.

 

이는 드래그 이벤트 뿐만이 아니라, 터치 이벤트도 똑같은 방식으로 구현되어 있다.

모바일 터치 이벤트도 첫 터치 시작의 pageX 값과 마지막 터치 이벤트의 pageX 값의 차이를 transX값으로 계산하였다.

  
  // 4. 드래그한 값이 50px 차이가 있으면 CurrentIndex를 변화시킨다.
  const handleDragEnd = (deltaX: number) => {
    const maxIndex = slideLength - 1;

    if (deltaX < -50) setCurrentIndex(inrange(currentIndex + 1, 0, maxIndex));
    if (deltaX > 50) setCurrentIndex(inrange(currentIndex - 1, 0, maxIndex));

    setAnimate(true);
    setTransX(0);
  };
  
  // 주의. 드래그(deltaX)를 슬라이드 width보다 더욱 앞으로 혹은 더욱 뒤로 했을 땐 바로 이동
  const handleDragChange = (deltaX: number) => {
    setTransX(inrange(deltaX, -slideWidth, slideWidth));
  };
  
  // 1. 마우스 다운 이벤트가 발생할 시 발생
  const handlerSliderMouseDown = (
    clickEvent: React.MouseEvent<HTMLDivElement, MouseEvent>,
  ) => {
    clickEvent.preventDefault();
	
    // 3. (마우스 다운 이벤트에서의 pageX - 마지막 마우스 이벤트의 pageX)한 값을 변화량으로 산정
    const handleMouseMove = (moveEvent: MouseEvent) => {
      const deltaX = moveEvent.pageX - clickEvent.pageX;
      handleDragChange(deltaX); // 드래그 값을 handleDragChange로 보내기
    };

    const handleMouseUp = (moveEvent: MouseEvent) => {
      const deltaX = moveEvent.pageX - clickEvent.pageX;
      handleDragEnd(deltaX);

      document.removeEventListener("mousemove", handleMouseMove);
    };

    // 2. 위 두 개의 핸들러들을 이벤트 리스너에 등록.
    document.addEventListener("mousemove", handleMouseMove);
    document.addEventListener("mouseup", handleMouseUp, {
      once: true,
    });
  };

 


내가 주도한 부분

인디케이터 생성

지역별 최대할인 부분 인디케이터를 만들어 애니메이션 재생과 동시에 인디케이터가 넘어가게 구현하였다. 또한 비활성화된 캐로셀을 클릭하면 그 섹션으로 캐로셀이 이동하도록 만들었다.

 

 

 

일단 백엔드에서 준 데이터를 이 주말특가 부분에 맞게 수선하였다.

{ Gyeonggi: [ {...}, {...}], Busan: [ {...}, {...}], ... } 이런 식으로 주었는데

이를 Object.entries를 이용하여 iteration이 가능하게 만들었다.

 

Object.entries로 바꾸면서 인자들을 [ 인덱스넘버, 지역이름, 상품이담긴배열] 의 형태들로 바꾸었는데

변수명은 localeAndHotel이다... (좋은 변수명은 아닌거 같다..)

 

또한 중간에 해당 지역에 아무런 데이터가 없을 시 백엔드에서 빈 배열로 주던데,

이를 없애주기 위해 filter메소드로 길이가 0 이상인 것만 담게 하였다.

  // 지역 별 할인 관련 데이터
  // 원래 백엔드에서 준 api에선 어떤 지역에 상품이 없으면 그 지역의 데이터를 null로 준다고 했는데 
  // 막상 받아보니 없을 시 빈 배열을 주길래 filter((v) => v[1].length !== 0)을 해주었다.
  // 결과적으로 [인덱스넘버, 지역이름, 상품배열] 식으로 담긴 튜플을 반환한다.
  const localeEntries: [number, string, LocaleItem[]][] = Object.entries(
    localeProds,
  )
    .filter((v) => v[1].length !== 0)
    .map((v, i) => [i, v[0], v[1]]);

  // 이후 localeEntries를 state화하여 사용하였다.
  const [localeAndHotel] = useState(localeEntries);

 

그리고 현재 노출되어있는 섹션을 보여주기 위해 currentLocale이란 변수를 생성하였다.

초기값은 localeAndHotel의 첫번째 객체이다.

  const [currentLocale, setCurrentLocale] = useState<
    [number, string, LocaleItem[]]
  >(localeAndHotel[0]);

 

그리고 인디케이터 컴포넌트는 map함수로 저 인디케이터들을 뿌려줬는데,

이 지역시퀀스의 인덱스넘버(sequnece)와 현재활성화페이지의 인덱스넘버(currentLocale[0])가 같나? 를 비교해서

활성화 여부를 결정하였다.

const SequenceSection = ({
  indicatorRange,
  currentLocale,
  localeAndHotel,
  onSetSequence,
}: indicatorProps) => {
  const clickHandler = (sequence: number) => {
    onSetSequence(localeAndHotel[sequence]);
  };

  return (
    <S.SequneceSection>
      <S.SequneceIndicator>
        {indicatorRange.map((sequence) =>
          // 이 지역시퀀스의 인덱스넘버와 현재활성화페이지의 인덱스넘버가 같나? 
          sequence === currentLocale[0] ? (
            <section key={sequence}>
              <span className="current"></span>
            </section>
          ) : (
            <section key={sequence} onClick={() => clickHandler(sequence)}>
              <span></span>
            </section>
          ),
        )}
      </S.SequneceIndicator>
    </S.SequneceSection>
  );
};

 

이를 통해, currentLocale(현재활성화 페이지)이 바뀔 때마다 인디케이터는 재렌더 되면서 currentLocale에 맞는 인디케이터 시퀀스가 활성화 되게 된다.

 

또한 각자 clickHandler를 달아 클릭 시 해당 인디케이터의 시퀀스로 바꾸게 해놓아 '인디케이터 클릭 시 해당 페이지로 이동'을 구현하였다. (setCurrentLocale로 바꿔준다)

 

 

 

자동재생 애니메이션 구현

자동재생 애니메이션은 useAnimateCarousel에서 빼낸 currentIndex를 사용하여 useEffect에서 조작하는 방식으로 구현하였다.

setInterval로 currentIndex를 3초마다 +1 씩 올려주었다.

 

또한 isPlay라는 상태를 만들어서 자동재생 여부를 관리하였다.

캐로셀 컨테이너 전체에 mouseOver 이벤트가 발생하면 isPlay를 false로 바꿔 재생을 멈추고

mouseLeave이벤트가 발생하면 isPlay를 true로 바꿔 재생을 진행하였다.

또한 페이지를 이동할 시 클린업 함수가 실행되어서 기존 interval을 청소하고 다시 시작하도록 설정하였다.

 

이를 통해 사용자가 캐로셀에 마우스를 올려 상품을 살피고 있을 때는 자동 재생이 멈추고,

마우스를 떼면 3초 뒤 다시 애니메이션이 재생 되었다.

  // 자동 재생을 위한 useEffect
  useEffect(() => {
    // 자동 재생 상태(isPlay)가 false면 함수 종료
    if (!isPlay) return;

    // 일정 시간 간격으로 슬라이드를 변경하는 타이머 생성
    const interval = setInterval(() => {
      // 현재 슬라이드 인덱스를 업데이트하여 다음 슬라이드로 이동
      setCurrentIndex((prev) => (prev + 1) % slideLength);
      // 애니메이션 효과를 적용하기 위해 animate 상태 변수를 true로 설정
      setAnimate(true);
      // 트랜지션 위치를 초기화
      setTransX(0);
    }, 3000);

    // 컴포넌트가 언마운트되거나 isPlay가 false로 변경되면 타이머 제거
    return () => clearInterval(interval);
  }, [isPlay]);

 

또한 인디케이터를 클릭하여 순서를 바꿀 시에도 클린업 함수가 작동하여 기존 setTimeout을 없애고 바뀐 시퀀스로 인디케이터를 재설정 한뒤 새로운 setTimeout (3초 간격)을 생성하게 된다.

 

 

 

전체 코드 (itemCarousel.tsx)

// itemCarousel.tsx 코드
  return (
    <S.CarouselContainer // 캐로셀 전체 컨테이너
      $height={height}
      onTouchStart={(e) => e.preventDefault()}
      onMouseEnter={() => setIsPlay(false)} // 마우스가 올라가면 isPlay(자동재생)가 false
      onMouseLeave={() => setIsPlay(true)} // 포인터가 이탈하면 isplay(자동재생)이 true
    >
      <S.SliderWrapper> // 슬라이더의 wrapper(슬라이더와 arrow를 감싸고 있음)
        <S.SliderContainer // 슬라이더의 실질 컨테이너면서 mouseDown, TouchStart, TransitionEnd를 탐지함
          ref={sliderRef}
          style={getSliderStyle()}
          onMouseDown={draggable ? handlerSliderMoueDown : undefined}
          onTouchStart={draggable ? handleSliderTouchStart : undefined}
          onTransitionEnd={draggable ? handleSliderTransitionEnd : undefined}
        >
          {innerShadow && <S.ImageShadowWrapper />}
          {localeAndHotel.map( // map함수로 실제 컨텐츠를 뿌려주는 부분
            (item) =>
              item[2].length && (
                <ItemCarouselUnit key={item[2][0].id} item={item} />
              ),
          )}
        </S.SliderContainer>
      </S.SliderWrapper>
      {arrows && ( // arrows가 true일 때만 렌더.
        <>
          <S.LeftButton
            aria-label="뒤로가기" // 테스트 코드용 라벨
            onClick={handleSliderNavigationClick(currentIndex - 1)} // 뒤로가기를 누르면 index - 1
            $visible={infinite || currentIndex > 0} // infinite가 false라면 첫번째 슬라이드에서 뒤로가기가 없어진다.
          >
            <S.LeftIcon />
          </S.LeftButton>
          <S.RightButton
            aria-label="앞으로가기"
            onClick={handleSliderNavigationClick(currentIndex + 1)} // 앞으로 가기를 누르면 index + 1
            $visible={infinite || currentIndex < localeAndHotel.length - 1} // infinite가 false라면 마지막 슬라이드에서 앞으로 가기가 없어진다.
          >
            <S.RightIcon />
          </S.RightButton>
        </>
      )}
    </S.CarouselContainer>
  )
};

 

전체 코드 (useAnimateCarousel.ts)

import { useEffect, useState } from "react";

interface CarouselProps {
  slideLength: number;
  slideWidth: number;
  infinite?: boolean;
}

const inrange = (v: number, min: number, max: number) => {
  // v는 델타x
  if (v < min) return min;
  if (v > max) return max;
  return v;
};

export const useAnimateCarousel = ({
  slideLength,
  infinite,
  slideWidth,
}: CarouselProps) => {
  const [currentIndex, setCurrentIndex] = useState(0);
  const [transX, setTransX] = useState(0);
  const [animate, setAnimate] = useState(false);
  const [isPlay, setIsPlay] = useState(true);

  useEffect(() => {
    if (!isPlay) return;

    const interval = setInterval(() => {
      setCurrentIndex((prev) => (prev + 1) % slideLength);
      setAnimate(true);
      setTransX(0);
    }, 3000);

    return () => clearInterval(interval);
    // eslint-disable-next-line
  }, [isPlay]);

  const handleDragChange = (deltaX: number) => {
    setTransX(inrange(deltaX, -slideWidth, slideWidth));
    console.log(deltaX, "deltaX")
  };

  const handleDragEnd = (deltaX: number) => {
    const maxIndex = slideLength - 1;

    if (deltaX < -50) setCurrentIndex(inrange(currentIndex + 1, 0, maxIndex));
    if (deltaX > 50) setCurrentIndex(inrange(currentIndex - 1, 0, maxIndex));

    setAnimate(true);
    setTransX(0);
  };

  const handleSliderNavigationClick =
    (index: number) => (event: React.MouseEvent<HTMLButtonElement>) => {
      event.stopPropagation();

      if (index < 0 || index >= slideLength) return;

      setCurrentIndex(index);
      setAnimate(true);
      setTransX(0);
    };

  // FIXME: touch 이벤트 후 발생하는 mouse 이벤트 무시하기
  const handleSliderTouchStart = (
    touchEvent: React.TouchEvent<HTMLDivElement>,
  ) => {
    const handleTouchMove = (moveEvent: globalThis.TouchEvent) => {
      if (moveEvent.cancelable) moveEvent.preventDefault();

      const delta = moveEvent.touches[0].pageX - touchEvent.touches[0].pageX;
      handleDragChange(delta);
    };

    const handleTouchEnd = (moveEvent: globalThis.TouchEvent) => {
      const delta =
        moveEvent.changedTouches[0].pageX - touchEvent.changedTouches[0].pageX;
      handleDragEnd(delta);

      document.removeEventListener("touchmove", handleTouchMove);
    };

    document.addEventListener("touchmove", handleTouchMove, {
      passive: false,
    });
    document.addEventListener("touchend", handleTouchEnd, {
      once: true,
    });
  };

  const handlerSliderMoueDown = (
    clickEvent: React.MouseEvent<HTMLDivElement, MouseEvent>,
  ) => {
    clickEvent.preventDefault();

    const handleMouseMove = (moveEvent: MouseEvent) => {
      const deltaX = moveEvent.pageX - clickEvent.pageX;
      handleDragChange(deltaX);
    };

    const handleMouseUp = (moveEvent: MouseEvent) => {
      const deltaX = moveEvent.pageX - clickEvent.pageX;
      handleDragEnd(deltaX);

      document.removeEventListener("mousemove", handleMouseMove);
    };

    document.addEventListener("mousemove", handleMouseMove);
    document.addEventListener("mouseup", handleMouseUp, {
      once: true,
    });
  };

  const getSliderStyle = () => {
    return {
      transform: `translateX(${-currentIndex * slideWidth + transX}px)`,
      transition: `transform ${animate ? 300 : 0}ms ease-in-out 0s`,
    };
  };

  const handleSliderTransitionEnd = () => {
    setAnimate(false);

    if (!infinite) return;
    if (currentIndex === 0) {
      setCurrentIndex(slideLength - 2);
    } else if (currentIndex === slideLength - 1) {
      setCurrentIndex(1);
    }
  };

  return {
    currentIndex,
    handleSliderNavigationClick,
    handleSliderTouchStart,
    handlerSliderMoueDown,
    handleSliderTransitionEnd,
    getSliderStyle,
    setCurrentIndex,
    setIsPlay,
  };
};

 

 

 

마무리 회고

'캐로셀 컴포넌트를 한 번 만들어보지 뭐!'

 

...라는 순진한 생각의 결과로 MouseEvent와 TouchEvent의 거의 모든 상태를 겪어보며 에러핸들링을 해야되는 형벌에 처하게 되었고 잠을 못 잤다고 한다.

 

하지만 이처럼 직접 구현한 캐로셀의 '현재 인덱스', '이전/다음 슬라이드 전환 핸들러' 등을

훅 바깥으로 빼낼 수 있기 때문에, '할인률 컴포넌트'에서 '텍스트 슬라이드 애니메이션'은 쉽게 구현할 수 있었다.

엄청나게 압축적인 학습을 할 수 있는 값진 경험이었다.