[기능구현] Swiper 구현하기 - 세부기능 편

seeen·2023년 12월 12일
2
post-thumbnail

스타일링 관련된 기능은 이전 편에서 볼 수 있습니다. 이번 편에서는 모바일 환경에서 터치 스와이프, 오토 플레이, 반응형 대응에 대해서 다룹니다.

요구사항

이전 편에서 다룬 요구사항을 제외하고 남은 요구사항을 조금 더 세분화 해보았다.

  • 모바일 환경에서 스와이퍼 버튼을 누르는 대신 좌우로 스와이프하여 탭을 넘길 수 있어야한다.
  • 일정 시간 이후 자동으로 다음 탭으로 넘어가는 기능이 있어야하며, 마지막 탭에 갔을 때 첫 번째 탭으로 돌아가야한다. 또한 사용자가 시간을 지정할 수 있어야한다.
  • 모바일 환경 대응에 문제가 없어야한다. 한 탭에 여러 개의 요소가 있으면 모든 요소가 일관되게 축소되야한다.
  • 모바일 환경 대응 시 한 탭에 여러 개의 요소가 있을 때 사용자가 지정한 Media Query 분기점을 기준으로 요소의 개수를 감소할 수 있어야한다.

일정 시간 이후 자동으로 다음 탭을 넘어가는 '오토 플레이' 기능 외에는 모바일, 태블릿 환경에 대응하기 위한 기능들이다. 이제 구현 과정을 알아보자.

구현 과정

설명에 앞서 솔직히 코드 퀄리티는 좋지 못하다. 실험적인 코드이기도 하며 최소한으로 해당 기능을 구사할 뿐 우아하게 동작하지는 않는다. 🫠

터치 스와이프 기능 구현하기

PC 환경에서는 좌우 스와이프 버튼을 눌러 탭을 넘길 수 있도록 해두었다. 하지만 모바일 환경에서는 화면이 작다보니 버튼이 거슬릴뿐만 아니라, 스와이프에 익숙하기에 터치 스와이프 기능을 지원할 필요가 있어보였다.

터치 스와이프 기능을 구현하는 방법은 다양하다. 또한 어느정도 수준으로 구현할 것인지 그 목표에 따라 난이도가 천차만별인 것 같다. 나는 스와이프가 되는 것에 의미를 두었다. 따라서 최소한의 로직으로 간단하게 구현하길 바랬다.

CSS 레벨에서 스와이프를 지원해주는 속성이 있다. scroll-snap-type(MDN) 이란 속성인데 처음에는 이를 사용해서 스와이프를 구현해볼 생각이었다. 하지만 $elementsOneTab 과 같은 기능들을 조작하기 위해 Swiper 컴포넌트 내부에서 pos 라는 단 하나의 상태값으로 관리하고 있어서, 스와이프 기능 역시 pos 상태와 연관되게 두는 것이 관리 포인트를 더 낮출 수 있을 것 같았다.

그렇게 하기 위해서 touch events(MDN) 를 읽어 일정 조건 이상이 되면 pos 값을 증가 또는 감소 시키는 방향으로 진행했다. 전체적인 원리는 다음과 같다.

스와이프를 하면 위 그림과 같이 touch 이벤트가 발생한다. 첫 시작 지점에서 끝 지점으로 갈수록 사용자의 손에 가속도가 붙어 터치 포인트 간 간격이 벌어진다. 두 포인트 간 거리가 일정 수준을 넘어갔을 때 pos를 증가 또는 감소시키는 방법으로 스와이프를 구현했다. 코드로 보자.

const [prevTouch, setPrevTouch] = useState<React.Touch | null>(null);

const handleTouchMove: TouchEventHandler = (event) => {
  const touch = event.touches[0]!;

  setPrevTouch(touch);
  if (!prevTouch) return;

  const diff = touch.pageX - prevTouch.pageX;

  // pos 상태 값을 증가 또는 감소
  acceleratePos(diff);
};

const handleTouchEnd: TouchEventHandler = () => {
  setPrevTouch(null);
};

prevTouch 라는 상태값을 두어 직전의 터치 포인트를 기록한다. handleTouchMove 라는 이벤트 핸들러를 보면, 직전의 터치 포인트와 현재의 터치 포인트를 비교하는 diff 변수값을 볼 수 있다. 이를 acceleratePos 함수에 보내어 스와이프 조건 여부를 판별한다.

이렇게 끝내면 정말 좋겠지만 위 코드는 touch event가 연속적으로 일어남을 간과했다.

스와이프를 한 번 진행했을 때 위와 같이 터치 포인트는 계속해서 생성된다. 따라서 세 번째 diff 부터 스와이프 조건을 만족했다고 하면, 한 번의 스와이프로 세 번의 pos 값 변화가 나타난다. 그래서 다음과 같이 여러 개의 탭을 휙휙 지나가게 된다.

이 문제는 단순하게 acceleratePos 함수에 쓰로틀링을 걸어 해결했다.

// 컴포넌트 외부 영역
// ---
let isAcceleratingPos = false; // Closure
// ---

const timerId = useRef<NodeJS.Timeout | null>(null);

const acceleratePos = (diff: number) => {
  if (isAcceleratingPos) return;

  if (pos < childrenListLength - 1 && diff < -CAN_SWIPE) {
    isAcceleratingPos = true;
    setPos(pos + 1);
  }

  if (pos > 0 && diff > CAN_SWIPE) {
    isAcceleratingPos = true;
    setPos(pos - 1);
  }

  if (timerId.current) return;

  // ✅
  timerId.current = setTimeout(() => {
    isAcceleratingPos = false;

    if (timerId.current) {
      clearTimeout(timerId.current);
      timerId.current = null;
    }
  }, 150);
};

후술하겠지만 이 방법이 그닥 좋다고 생각하지 않는다. 근본적으로 diff 값을 그대로 스와이프 조건과 비교하여 사용한 것이 문제라고 생각한다. 아무튼 ✅ 부분에서 isAcceleratingPos 라는 변수로 쓰로틀링을 걸어주고 있다. 이렇게 하니 한 번의 스와이프로 여러 개의 탭이 넘어가는 문제는 해결할 수 있었다.

$elementsOneTab 속성을 지정해도 정상적으로 동작한다.

이렇게 끝낼 수 있을 줄 알았으나 한 가지 문제점이 더 있었다. (인생이 그리 호락호락하지 않지..) 다음과 같이 약간의 세로 방향에서의 스와이프로도 Swiper 컴포넌트의 스와이프가 되는 문제였다.

이는 메인 페이지에서 탐색을 위해 위 아래로 스와이프 하는 것에도 동작하여, 사용자에게는 오작동 같이 느껴질 것이다. 이 역시 간단한 방법으로 해결하였다. touch 이벤트가 일어날 때 y축 방향, 즉 세로 방향의 스와이프가 일정 수준 이상 일어난다면 스와이프 자체를 동작하지 않도록 하는 것이다.

const handleTouchMove: TouchEventHandler = (event) => {
  const touch = event.touches[0]!;

  setPrevTouch(touch);
  if (!prevTouch) return;

  const diff = touch.pageX - prevTouch.pageX;

  // ✅
  const otherPos = touch.pageY - prevTouch.pageY;
  if (Math.abs(otherPos) > OPPOSITE_DIRECTION_CAN_SWIPE) return;

  acceleratePos(diff);
};

✅ 을 보면 Y축 방향의 스와이프가 일정 수준 이상이 되면 acceleratePos 함수 실행 이전에 return 함으로써 스와이프 동작을 무효화하고 있다. 그리하면 다음과 같이 위 아래 스와이프 때 Swiper의 스와이프는 동작하지 않고 좌우 스와이프 때만 동작하는 것을 볼 수 있다.

이렇게 터치 스와이프 기능을 만들 수 있었다. 정말 기본적인 스와이프만 지원하기에 부족한 부분도 많다. diff 의 값을 스와이프 조건에 직접 비교하여 pos 상태를 변화시키고 있기에 조건에 부합하지 않으면 스와이프가 동작하지 않는다. 이는 스와이프를 완전히 수행하기 전 단계에서 터치를 유지하고 있는 상태에는 아무런 동작을 수행하지 않는다는 뜻이다. 또한 쓰로틀링을 걸어두었지만 사용자가 150ms 시간 이상 터치를 유지한 채로 있다가 스와이프를 진행하면 150ms 간격으로 탭이 전환된다.

아무튼 이런 문제점들은 리팩토링 단계에서 보완할 생각이다. pos 상태 값을 조작하는 대신 scroll-snap-type: x proximity; 속성을 생각 중이기도 하다. 🤔

오토 플레이 기능 구현하기

Swiper 컴포넌트는 메인 페이지 배너에서도 사용할 예정이었기에, 일정 시간이 경과되면 자동으로 다음 탭으로 넘어가는 기능 또한 중요했다. 구현 자체는 간단했지만 여러 엣지 케이스를 고려하지 못한채로 배포를 했어서 당황했던 기억이 있다.

const useAutoplay = ({
  autoplay, // 사용자에게로부터 autoplay 활성화 여부를 받아옴
  $autoplayTime,
  childrenListLength, // 탭의 개수
  pos, // 탭 위치
  setPos,
}: Props) => {
  const [isPlaying, setIsPlaying] = useState<boolean>(autoplay);
  const intervalId = useRef<NodeJS.Timeout | null>(null);

  useEffect(() => {
    // ✅A
    if (childrenListLength < 2) setIsPlaying(false);

    if (isPlaying) {
      intervalId.current = setInterval(
        () => {
          pos <= childrenListLength - 2
            ? setPos((prev) => prev + 1)
            : setPos(0);
        },
        // ✅B
        $autoplayTime < 1000 ? 1000 : $autoplayTime,
      );
    }

    if (!isPlaying && intervalId.current) clearInterval(intervalId.current);

    return () => {
      if (intervalId.current) clearInterval(intervalId.current);
    };
  }, [childrenListLength, pos, setPos, $autoplayTime, isPlaying]);

  // autoplay 기능을 껐다 켰다 할 수 있는 버튼을 위한 함수
  const toggleAutoplay = () => {
    setIsPlaying((prev) => !prev);
  };

  return {
    isPlaying,
    toggleAutoplay,
  };
};

export default useAutoplay;

위와 같이 setInterval 함수를 사용하여 구현하였다. 마지막 탭까지 갔을 경우 자동으로 첫 번째 탭으로 이동할 수 있도록 하였다. 엣지 케이스가 ✅A, ✅B 인데 각각 탭의 개수가 1개일 때와 사용자가 지정한 오토 플레이 타임이 극단적으로 짧을 때이다.

탭이 1개일 때는 외부적으로는 오토 플레이가 동작하지 않는 것 같지만 인터벌이 내부적으로 계속 돌아가는 형태이므로 불필요한 자원 낭비가 발생한다. 따라서 인터벌 실행을 방지할 수 있도록 하였다. 오토 플레이 타임의 기본 값은 5초인데 10ms 와 같이 극단적으로 작은 값을 부여하면 Swiper가 난리가 난다. 물론 의도적으로 그럴 일은 별로 없겠지만 실수를 방지하기 위해 1초 미만의 값은 1초로 강제하도록 하였다.

아래는 오토 플레이 기능 적용 예시이다. $autoplayTime 을 1초로 지정하였다.

반응형 대응하기

반응형 웹을 위해 Swiper 컴포넌트 역시 반응형 대응이 필수적이었다. Swiper 컴포넌트에서 탭의 스타일링을 수행하고 있었기 때문에 CSS Media Query를 사용하여 기본적인 반응형은 어렵지 않게 대응할 수 있었다. 웬만한 상황에서는 반응형 대응이 필수적이라 기본적으로 해당 기능을 탑재할까 했지만, 혹시 몰라서 responsive 라는 prop 으로 반응형 대응 여부를 정할 수 있도록 하였다.

${({ responsive, width, $childrenLength, pos }) =>
  responsive &&
  css`
    @media (max-width: ${width}px) {
      width: calc(100vw * ${$childrenLength});
      height: auto;
      transform: translateX(calc(-100vw * ${pos}));
    }
  `}

위 코드는 반응형 대응 코드 중 일부이다. 위와 같이 Swiper 컴포넌트를 선언할 때 지정한 width 값보다 뷰포트의 가로 넓이가 작다면 width 를 일괄적으로 100vw로 지정하고, Media Query와 calc 연산을 통해 반응형 대응을 수행하였다.

문제는 $elementsOneTab 이란 속성을 부여하면서 반응형 대응에 까다로운 부분이 생겨났다는 것이다. 아래의 지도 카드 Swiper는 width 값이 1140px이고 하나의 지도 카드의 width208px이다. 보통의 모바일 가로 사이즈가 390px 정도이니, 아무리 축소해도 390px 안에 5개의 지도 카드를 보여줄 수는 없다.

근본적으로 지도 카드의 개수를 줄이는 작업이 필요하다. 즉, $elementsOneTab 으로 전달한 값을 화면 사이즈에 맞게 감소시킬 필요가 있는 것이다. 이 부분은 사용자에게로부터 동적으로 받아오는 값이다 보니 CSS Media Query 로는 작업이 다소 복잡했다. 따라서 자바스크립트 단에서 반응형을 대응하기 위해 matchMedia(MDN)를 사용하였다.

목표한 동작은 다음과 같다. $elementsOneTab 속성을 2 이상으로 부여했을 경우, 사용자에게로부터 어느 시점부터 $elementsOneTab 을 감소시킬 것인지를 받아 적용하는 것이었다. 여기서 어느 시점은 Media Query 분기점과 동일하다.

$elementsOneTab 이 5라면 위 지도 카드의 Swiper와 동일한 모습일텐데 5 ➡️ 4 ➡️ 3 ➡️ 2 ➡️ 1 이런식으로 순차적으로 감소시킬 필요가 있으므로 number 타입 배열의 형태로 사용자에게 분기점을 받아오기로 정했다.

따라서 사용처는 다음과 같이 elementsMediaQueries prop으로 숫자열 배열을 넘기면 된다.

<Swiper
  width={1000}
  $elementsOneTab={5}
  $elementsMediaQueries={[800, 600, 400, 200]}
  responsive
>
// ..
</Swiper>

그러면 다음과 같이 $elemnetsMediaQueries 의 값을 받은 배열에서 순회하면서 window.matchMedia 를 통해 change 이벤트 즉, 뷰포트에 변화가 있을 때 설정한 Media Query 조건에 부합하는지 검사하여, 조건 부합 시 $elementsOneTab 의 값을 감소시킨다.

// 중략.. 

const mediaQueryListRef = useRef<string[] | null>(null);

useEffect(() => {
  // $elemnetsMediaQueries prop의 값으로 초기화
  mediaQueryListRef.current = mediaQueriesIncludeInit.map(
    (mediaQuery) => `(max-width: ${mediaQuery}px)`,
  );

  const handleMediaChange = (elementsCount: number) => {
    setMatches(elementsCount);
  };

  // 배열을 순회하면서 window.matchMedia 에 이벤트 등록
  mediaQueryListRef.current.forEach((mediaQuery, index) => {
    const matchMedia = window.matchMedia(mediaQuery);

    if (mediaQueryListRef.current) {
      const mediaQueryConditions = mediaQueryListRef.current;

      matchMedia.addEventListener('change', () =>
        handleMediaChange(mediaQueryConditions.length - index),
      );
    }
  });

  return () => {
    if (mediaQueryListRef.current) {
      const mediaQueryConditions = mediaQueryListRef.current;

      mediaQueryConditions.forEach((mediaQuery, index) => {
        const matchMedia = window.matchMedia(mediaQuery);

        matchMedia.removeEventListener('change', () =>
          handleMediaChange(mediaQueryConditions.length - index),
        );
      });
    }
  };
  
// 중략..

이제 $elemnetsMediaQueries 를 사용했을 때와 사용하지 않았을 때를 비교해보자. 먼저 사용하지 않았을 때이다.

탭이 비정상적으로 찌그러드는 모습을 볼 수 있다. 다음은 $elemnetsMediaQueries사용했을 때이다.

탭이 비정상적으로 찌그러들기 전 $elementsOneTab 을 순차적으로 감소시켜 보다 자연스러운 형태를 유지하는 것을 볼 수 있다. 괜찮을지도에서는 다음과 같이 적용되었다.

이로써 Swiper 컴포넌트 구현하기 편을 모두 마치겠다. 다음 편부터는 현재 Swiper 컴포넌트의 문제점을 확인하고 리팩토링 해보는, 리팩토링 편으로 이어나가겠다. 😇

profile
woowacourse FE 5th, depromeet Web 15th

3개의 댓글

comment-user-thumbnail
2024년 1월 8일

연재 계속 해줏매!

1개의 답글