IT일상

React에서 터치 스와이프 구현하기

  • 프론트엔드
Profile picture

Written by solo5star

2023. 9. 24. 03:02

배경

우테코에서 개발하고 있는 요즘카페 서비스에선 메인 화면의 카페들을 스와이프 형식으로 보여주고 있습니다. 따라서 터치 스와이프 구현이 필요합니다.

이 글에서 JavaScript로 구현한 scroll snap의 소스 코드는 https://gist.github.com/solo5star/ca5788e3c2b75c3c8ab017a4fb97c438 에서 확인할 수 있습니다

정말 간단하게 구현하기 (CSS)

.container {
  height: 500px;
  overflow-y: scroll;
  scroll-snap-type: y mandatory;
}

.container > .item {
  scroll-snap-align: start;
}

터치 스와이프를 구현하는 정말 간단한 방법은 CSS의 scroll-snap을 사용하는 것입니다. CSS 몇 줄이면 구현이 가능합니다. 대부분의 경우 scroll-snap만 사용해도 큰 문제가 없을 것입니다.

주의할 점은, .container가 고정된 높이를 가지고 있어야 하며 스크롤이 .container 내부에서 일어나야 합니다. 즉 .containeroverflow-y: scroll (혹은 auto)이 적용되어 있어야 한다는 뜻입니다.

3
https://caniuse.com/mdn-css_properties_scroll-snap-stop

CSS scroll-snap의 호환성을 확인해보면, 거의 모든 브라우저(90.89%)에서 지원하기 때문에 걱정없이 적용할 수 있을 것 같습니다.

CSS scroll-snap만으로 요구사항을 만족할 수 있다면 사용하시는 것을 권해드립니다. 만약 CSS scroll-snap을 사용하다가 한계를 느끼신다면 JavaScript로 구현하는 것을 고려해보세요. JavaScript로 구현한다면 CSS scroll-snap보다 더 정밀하게 제어할 수 있을 겁니다. 대신, 스크롤 컨테이너에서 당연하게 되던 것(터치를 움직이면 아이템이 움직인다던가)들도 일일이 제어해주어야 하기 때문에 난이도는 훨씬 높아집니다.

JavaScript로 구현하는 것이 부담스럽다면 swiper.js 같은 라이브러리를 사용하는 것도 고려해보세요.

아래에서 다시 언급하겠지만 요즘카페 서비스에서는 CSS scroll-snap에 한계를 느꼈고 터치 스와이프 로직을 JavaScript로 재구현하였습니다.

용어 정리

4

본문으로 넘어가기 전에, 본문에서 사용하는 몇 가지 용어를 간단하게 설명하고 넘어가도록 하겠습니다. 용어의 정의는 w3.org의 내용을 가져왔습니다.

  • snapport/scroll container: scroll snap이 발생하는 스크롤 영역입니다. 사진에서 빨간색 박스를 가리킵니다. snapport와 scroll container는 대부분의 경우 동일하지만 snap위치 간혹 snapport는 scroll container보다 작을 수도 있습니다.
  • snap position: scroll snap이 발생하였을 때 이동할 위치입니다.
  • snap area: scroll container 내에 있는 자식 요소들입니다.

CSS scroll-snap에서 겪은 문제

요즘카페 서비스에는 무한 스크롤(Infinite Scroll) 기능이 있습니다. 스크롤이 거의 끝나는 시점에 추가 페이지를 fetch하여 DOM을 추가하게 됩니다. 이 과정에서 발생하는 문제가 두 가지 있습니다.

첫 번째로 React의 reconciliation이 동작하면서 DOM을 추가하는 타이밍에 사용자가 스크롤을 하고 있다면 스크롤이 캔슬됩니다. 두 번째로 흰 화면 플리커링이 가끔씩 발생합니다. 원인은 알아내지 못하였지만 스크롤 중 DOM을 조작하였기 때문에 발생하는 현상으로 추측하고 있습니다.

CSS scroll-snap의 한계

6 7

위는 CSS scroll-snap을 제어할 수 있는 프로퍼티 목록입니다. 보시는 것처럼... 제어할 수 있는 것이 너무 적습니다. 만약 snap되는 속도나 easing 애니메이션을 변경하고 싶다면 아쉽지만 CSS scroll-snap에서는 불가능합니다.

이러한 문제와 한계들을 극복하고 정밀하게 제어하기 위해 CSS scroll-snap을 JavaScript로 재구현하게 되었습니다.

요구사항 정의

먼저 scroll snap이 어떤 식으로 동작해야 하는지 정의해야 합니다. snap 동작은 swiperjs, fullPage.js 등의 터치 스와이프 구현체들을 많이 참고하였습니다. 본문에서 언급되는 100ms, 0.03 같은 값들은 제가 휴리스틱으로 도출한 값이기 때문에 꼭 이 값을 따르지 않아도 됩니다.

  • 구현을 단순화하기 위해 snap area의 높이를 100%로 고정하였습니다. 즉 snap container와 동일한 사이즈를 가지게 됩니다.
  • snap position은 snap container의 최상단에 고정합니다.

요구사항 1: 터치 입력에 따라 요소들이 움직여야 한다

8

가장 기본적인 동작입니다. 터치 입력 시 터치 입력에 따라 snap container 내의 요소들이 움직여야 합니다.

요구사항 2: 터치 입력이 종료되었을 때 snap이 되어야 한다

9 10

터치 입력 중에는 요소들이 손가락을 따라 움직일 것입니다. 손가락을 터치 스크린에서 떼면, 요소들이 snap position에 맞춰지도록 이동해야 하는데 현재 요소에 머무를지, 다음 요소로 이동할지 결정해주어야 합니다. 이를 판정하기 위한 조건은 두 가지가 있습니다.

Case1) 빠르게 스와이프 시 다음 snap area로 이동해야 합니다. 터치 스와이프의 가속력까지 고려한다면 좋겠지만 ... 짧은 시간(100ms)내에 터치가 일정 거리(0.03%) 이동하였는지 정도만 확인하여도 충분합니다. 실제로 swiperjs도 비슷한 식으로 동작합니다.

Case2) Case1에 해당되지 않을 경우, 스와이프 가속력과 상관없이 스와이프가 종료되었을 때 가까운 snap area로 이동합니다.

이 두 가지 판정 Case 외에도 다른 조건들이 있을 수도 있지만, 이 정도만 고려하여도 높은 완성도의 scroll snap을 구현할 수 있습니다.

(선택) 요구사항 3: 스와이프하는 방향이 snap container의 scroll 방향과 일치하지 않을 경우 반응하지 않는다

11

snap container의 스크롤 방향은 수직인데, 스와이프하려는 방향이 수평인 경우 반응하지 않아야 합니다. 만약 snap container가 중첩되어 있어 수직, 수평 방향이 모두 존재한다면 이를 적절히 처리하는 것이 중요합니다.

만약 구현하려는 것이 캐러셀(carousel)이라면 스와이프 방향과 관계없이 무조건 반응하여도 큰 문제는 없을 것 같습니다. 참고로 이 글에서는 요구사항 3의 구현을 다루진 않습니다.

구현

구현: 요구사항 1

요소가 터치의 움직임에 따라 움직이도록 구현합니다.

12

터치의 움직임에 따라 요소가 움직이도록 하게 하려면 touchmove, touchend 이벤트를 리스닝하여 처리해주면 됩니다. 요소의 움직임은 CSS의 transform: translateY로 처리할 수 있습니다.

13

터치가 움직인 만큼만 요소를 움직이게 하려면 각 touch point간 간격을 계산하고 누적시켜 요소를 이동시키면 됩니다.

function App() {
  // 스크롤 위치를 나타내는 값이며 터치 움직임이 누적된 값이기도 하다
  const [scrollPosition, setScrollPosition] = useState(0);
  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.pageY - prevTouch.pageY;
    setScrollPosition(scrollPosition + diff);
  };

  const handleTouchEnd: TouchEventHandler = () => {
    // 터치 종료. 다음 터치 입력에서는 사용하지 않으므로 삭제 처리
    setPrevTouch(null);
  };

  return (
    // 스크롤 컨테이너
    <div
      onTouchMove={handleTouchMove}
      onTouchEnd={handleTouchEnd}
      style={{
        width: "200px",
        height: "400px",
        overflow: "hidden",
        border: "2px solid black",
      }}
    >
      {/* 자식 요소들을 이동 처리하기 위한 컨테이너 */}
      <div style={{
        height: '100%',
        transform: `translateY(${scrollPosition}px)`
      }}>
        {Array(10)
          .fill(undefined)
          .map((_, index) => (
            <div
              style={{
                height: "100%",
                background: `hsl(${index * 30}, 100%, 70%)`,
              }}
            >
              {index}
            </div>
          ))}
      </div>
    </div>
  );
}

소스 코드가 길지만 touch 이벤트를 어떻게 핸들링하며 scrollPosition, prevTouch 상태를 어떻게 갱신하는지 잘 봐주시면 좋겠습니다.

구현: 요구사항 1 리팩토링

scrollPosition은 snap container의 높이 값에 의존적입니다. 이를 어떻게 해결하는지 이 문단에서 설명합니다.

scrollPosition은 touch가 이동한 길이가 누적된 값이 저장됩니다. setScrollPosition을 사용하면 임의로 스크롤 위치를 옮길 수도 있는데요, 만약 setScrollPosition을 사용하여 3번째 아이템으로 이동하고 싶다면 어떻게 해야 할까요?

const height; // snap container height
const K; // index of item
setScrollPosition(height * K);

snap container DOM의 높이를 구한 뒤 index를 곱해주어야 합니다. 만약 snap container의 높이가 400px이고, 표시하고자 하는 아이템이 3번째라면 400 * 3 = 1200 으로 설정해주어야 합니다.

구현을 진행하며 scrollPosition을 바꾸는 일은 자주 있을 것입니다. 그럴 때 마다 높이(height)를 곱셈해야 합니다.

어차피 높이(height)라는 요소는 화면(UI)에 반영할 때만 필요한 값이기 때문에 scrollPosition에서 분리하는 것이 더 나을 수 있습니다.

15
const useDOMHeight = (ref: RefObject<HTMLElement>) => {
  return useSyncExternalStore(
    (callback) => {
      const observer = new ResizeObserver(callback);
      if (ref.current) observer.observe(ref.current);
      return () => observer.disconnect();
    },
    () => ref.current?.clientHeight ?? 1
  );
};

snap container의 높이를 얻기 위해 ResizeObserver를 사용하였습니다. useSyncExternalStore가 사용되었는데, React의 바깥 세계에 존재하는 DOM의 높이(height)라는 상태를 React의 상태로 사용하기 위해 사용하였습니다. (쉽게 말하자면 useEffectuseState의 조합과 동일합니다)

function App() {
  // ...

  const containerRef = useRef<HTMLDivElement>(null);
  const height = useDOMHeight(containerRef);

  const handleTouchMove: TouchEventHandler = (event) => {
    // ...

    const diff = (touch.pageY - prevTouch.pageY) / height;
    setScrollPosition(scrollPosition + diff);
  };

  // ...
}

touch point 간 간격을 누산할 때, 높이(height)를 분리하기 위해 나눗셈을 취합니다.

  <div
    style={{
      height: "100%",
      transform: `translateY(${scrollPosition * height}px)`,
    }}
  >
    {/* 자식 요소 ... */}
  </div>

주요 로직에서는 index처럼 다루다가 UI에 표시할 때만 height를 곱해주면 됩니다. 이로써 1번째 요소를 표시한다, 3번째에서 절반 더(3.5) 표시한다 등의 케이스에 대해 쉽게 처리할 수 있게 되었습니다.

리팩토링 전리팩토링 후

뿐만 아니라 scrollPosition을 snap container 높이로 부터 독립적으로 만듬으로서 리사이징(resize)을 하더라도 현재 스크롤의 위치를 유지할 수 있습니다.

구현: 요구사항 2

터치 입력이 종료되었을 때 snap이 일어나도록 하는 것을 구현합니다.

지금까지의 구현은, 터치를 중단하였을 때 요소가 그대로 멈춰있습니다. 터치를 중단하였을 때 snap이 발생하도록 구현해볼 것입니다.

9 10

빠르게 스와이프하는 것을 구현하려면 최근 100ms (혹은 더 짧은) touch point를 알고 있어야 합니다. 이것을 기준으로 빠르게 스와이프를 한 것인지 판정하기 때문입니다.

18
function App() {
  // ...
  type RecentPositionHistory = Array<{ position: number; timestamp: number }>
  const [recentPositionHistory, setRecentPositionHistory] = useState<RecentPositionHistory>([]);

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

    const diff = (touch.pageY - prevTouch.pageY) / height;
    const position = scrollPosition + diff;
    setScrollPosition(position);
    // 타임스탬프와 함께 기록
    setRecentPositionHistory((prev) =>
      [...prev, { position, timestamp: Date.now() }]
        // 100ms 이내의 touch point만 기록
        .filter(({ timestamp }) => Date.now() - timestamp < 100)
    );
  };
  // ...
}

터치가 종료되었을 때 recentPositionHistory[0] 과 거리 차이를 계산하면 대략 100ms 간 이동한 거리를 계산할 수 있습니다.

function App() {
  // ...

  const handleTouchEnd: TouchEventHandler = () => {
    // 터치 종료. 다음 터치 입력에서는 사용하지 않으므로 삭제 처리
    setPrevTouch(null);
    setRecentPositionHistory([]);

    // 빠르게 스와이프하였는지?
    const fastSwipeDistance = recentPositionHistory[0].position - scrollPosition;
    if (Math.abs(fastSwipeDistance) > 0.03) {
      setScrollPosition(Math.round(scrollPosition) + (fastSwipeDistance > 0 ? -1 : 1));
      return;
    }

    // 가까운 요소로 이동
    setScrollPosition(Math.round(scrollPosition));
  };
  
  // ...
}

빠르게 스와이프하였는지 먼저 검사합니다. snap container 높이의 0.03%보다 크게 움직였다면 다음 요소로 넘어갑니다. 만약 빠른 스와이프가 아니라면 가까운 근처 요소로 이동합니다.

그런데 setScrollPosition으로 바로 이동하니 부자연스럽습니다. snap이 부드럽게 일어나도록 구현이 필요합니다.

20

300ms동안 조금씩 상단으로 이동하는 모션을 만들려면 시작 위치도착 위치를 기억할 필요가 있습니다. 그리고 터치를 종료한 시간을 기억하고 그 시점으로 부터 300ms 동안 snap 애니메이션이 발생하도록 해야 합니다.

21
function App() {
  // ...

  const [snapStartedAt, setSnapStartedAt] = useState(0); // snap 시작 시간
  const [snapStartedPosition, setSnapStartedPosition] = useState(0); // snap이 시작된 지점
  const [snapTargetPosition, setSnapTargetPosition] = useState(0); // snap 목표 지점
  
  // ...
  
  const handleTouchEnd: TouchEventHandler = () => {
    // 터치 종료. 다음 터치 입력에서는 사용하지 않으므로 삭제 처리
    setPrevTouch(null);
    setRecentPositionHistory([]);

    setSnapStartedAt(Date.now());
    setSnapStartedPosition(scrollPosition);

    // 빠르게 스와이프하였는지?
    const fastSwipeDistance = recentPositionHistory[0].position - scrollPosition;
    if (Math.abs(fastSwipeDistance) > 0.03) {
      setSnapTargetPosition(Math.round(scrollPosition) + (fastSwipeDistance > 0 ? -1 : 1));
      return;
    }

    // 가까운 요소로 이동
    setSnapTargetPosition(Math.round(scrollPosition));
  };
  
  // snap 중인 경우 애니메이션 처리
  if (Date.now() - snapStartedAt < 300) {
    requestAnimationFrame(() => {
      const progress = (Date.now() - snapStartedAt) / 300;
      const position =
        snapStartedPosition +
        (snapTargetPosition - snapStartedPosition) * progress;
      setScrollPosition(position);
    });
  }
  // snap 종료 처리
  else if (snapStartedAt !== 0) {
    setSnapStartedAt(0);
    setScrollPosition(snapTargetPosition);
  }
  
  // ...
}

snapStartedAt을 기준으로 snap 애니메이션 과정 처리와 종료 처리를 합니다. 애니메이션이 발생해야하는 시간(~300ms)중엔 시작 시간과 종료 시간을 가지고 얼만큼 진행되었는지(progress)를 구합니다. 그리고 progressscrollPosition에 반영함으로서 애니메이션이 발생하게 됩니다.

snap 애니메이션이 성공적으로 적용된 모습을 볼 수 있습니다. 다만 밋밋하다고 느끼실 수 있는데, 애니메이션이 선형적으로 일어나고 있기 때문입니다.

23 24

왼쪽 그림이 현재의 선형(linear) 애니메이션입니다. 오른쪽 그림처럼 easing 함수를 적용한다면 애니메이션이 좀 더 부드럽게 보일 것입니다.

25

easing 함수 스니펫은 https://easings.net/ 에서 볼 수 있습니다. 저는 여기서 easeOutExpo를 적용해보겠습니다.

26

TypeScript 스니펫을 제공해주기 때문에 복사해서 가져오기만 하면 됩니다.

 function App() {
   // ...
 
   // snap 중인 경우 애니메이션 처리
   if (Date.now() - snapStartedAt < 300) {
     requestAnimationFrame(() => {
       const progress = (Date.now() - snapStartedAt) / 300;
       const position =
         snapStartedPosition +
-        (snapTargetPosition - snapStartedPosition) * progress;
+        (snapTargetPosition - snapStartedPosition) * easeOutExpo(progress);
       setScrollPosition(position);
     });
   }
   
   // ...
 }

적용 방법은 간단합니다. progress를 easing 함수로 씌우기만 하면 됩니다. 이렇게 하면 선형적인 progress 입력이 easing 함수 출력으로 변환되면서 easing 애니메이션이 적용됩니다.

좀 더 부드러운 snap 애니메이션이 되었네요!

재미삼아 easeOutBounce도 적용해보았습니다.

전체 코드

import {
  RefObject,
  TouchEventHandler,
  useRef,
  useState,
  useSyncExternalStore,
} from "react";

const useDOMHeight = (ref: RefObject<HTMLElement>) => {
  return useSyncExternalStore(
    (callback) => {
      const observer = new ResizeObserver(callback);
      if (ref.current) observer.observe(ref.current);

      return () => observer.disconnect();
    },
    () => ref.current?.clientHeight ?? 1
  );
};

function easeOutExpo(x: number): number {
  return x === 1 ? 1 : 1 - Math.pow(2, -10 * x);
}

function easeOutBounce(x: number): number {
  const n1 = 7.5625;
  const d1 = 2.75;
  
  if (x < 1 / d1) {
      return n1 * x * x;
  } else if (x < 2 / d1) {
      return n1 * (x -= 1.5 / d1) * x + 0.75;
  } else if (x < 2.5 / d1) {
      return n1 * (x -= 2.25 / d1) * x + 0.9375;
  } else {
      return n1 * (x -= 2.625 / d1) * x + 0.984375;
  }
}  

function App() {
  // 스크롤 위치를 나타내는 값이며 터치 움직임이 누적된 값이기도 하다
  const [scrollPosition, setScrollPosition] = useState(0);
  const [prevTouch, setPrevTouch] = useState<React.Touch | null>(null);

  const containerRef = useRef<HTMLDivElement>(null);
  const height = useDOMHeight(containerRef);

  type RecentPositionHistory = Array<{ position: number; timestamp: number }>;
  const [recentPositionHistory, setRecentPositionHistory] =
    useState<RecentPositionHistory>([]);

  // snap 애니메이션을 위한 상태들
  const [snapStartedAt, setSnapStartedAt] = useState(0);
  const [snapStartedPosition, setSnapStartedPosition] = useState(0);
  const [snapTargetPosition, setSnapTargetPosition] = useState(0);

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

    const diff = (touch.pageY - prevTouch.pageY) / height;
    const position = scrollPosition + diff;
    setScrollPosition(position);

    // 최근 100ms 이내의 터치 포인트들을 기억
    setRecentPositionHistory((prev) =>
      [...prev, { position, timestamp: Date.now() }].filter(
        ({ timestamp }) => Date.now() - timestamp < 100
      )
    );
  };

  const handleTouchEnd: TouchEventHandler = () => {
    // 터치 종료. 다음 터치 입력에서는 사용하지 않으므로 삭제 처리
    setPrevTouch(null);
    setRecentPositionHistory([]);

    // snap 애니메이션을 발생시킨다
    setSnapStartedAt(Date.now());
    setSnapStartedPosition(scrollPosition);

    // 빠르게 스와이프하였는지?
    const fastSwipeDistance =
      recentPositionHistory[0].position - scrollPosition;
    if (Math.abs(fastSwipeDistance) > 0.03) {
      setSnapTargetPosition(
        Math.round(scrollPosition) + (fastSwipeDistance > 0 ? -1 : 1)
      );
      return;
    }

    // 가까운 요소로 이동
    setSnapTargetPosition(Math.round(scrollPosition));
  };

  // snap 중인 경우 애니메이션 처리
  if (Date.now() - snapStartedAt < 300) {
    requestAnimationFrame(() => {
      const progress = (Date.now() - snapStartedAt) / 300;
      const position =
        snapStartedPosition +
        (snapTargetPosition - snapStartedPosition) * easeOutExpo(progress);
      setScrollPosition(position);
    });
  }
  // snap 종료 처리
  else if (snapStartedAt !== 0) {
    setSnapStartedAt(0);
    setScrollPosition(snapTargetPosition);
  }

  return (
    // 스크롤 컨테이너
    <div
      ref={containerRef}
      onTouchMove={handleTouchMove}
      onTouchEnd={handleTouchEnd}
      style={{
        width: "200px",
        height: "400px",
        overflow: "hidden",
        border: "2px solid black",
        resize: "vertical",
      }}
    >
      {/* 자식 요소들을 이동 처리하기 위한 컨테이너 */}
      <div
        style={{
          height: "100%",
          transform: `translateY(${scrollPosition * height}px)`,
        }}
      >
        {Array(10)
          .fill(undefined)
          .map((_, index) => (
            <div
              style={{
                height: "100%",
                background: `hsl(${index * 30}, 100%, 70%)`,
              }}
            >
              {index}
            </div>
          ))}
      </div>
    </div>
  );
}

export default App;

맺음말

글을 작성하며 정리해보니 구현의 양이 상당히 많았던 것 같습니다. 또한 상태의 갯수도 많아지게 되면서 복잡도가 크게 증가하게 되네요.

글이 굉장히 길어졌지만 최대한 지루하지 않게 설명하기 위해 그림과 동작하는 모습을 최대한 많이 첨부하였습니다. 이해하는데 도움이 되셨으면 좋겠네요. 본문에서 언급한 것 외에도 Virtual List, Finite-State Machine 모델 적용, 리스트에서 위와 아래의 경계가 없는 rolling 모드, wheel 지원도 구현하였지만 너무 길어질 것 같아 이 글에선 생략하였습니다.

구현에 시간이 정말 많이 들었지만 snap을 정밀하게 제어할 수 있게 되어 다양한 시도를 할 수 있다는 점에서 굉장히 만족스러운 구현이었습니다.

끝까지 읽어주셔서 감사합니다.


Profile picture

Written by solo5star

안녕하세요 👋 개발과 IT에 관심이 많은 solo5star입니다

  • GitHub
  • Baekjoon
  • solved.ac
  • about
© 2023, Built with Gatsby