IT일상

React에서 터치 스와이프 구현하기 2 (유한 상태 머신으로 리팩토링)

  • 프론트엔드
Profile picture

Written by solo5star

2023. 9. 30. 17:26

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

위 글에서 이어지는 내용입니다

배경

1

지난 글에서 요즘카페 서비스의 터치 스와이프를 고도화하는 구현을 진행했었습니다.

2
3 4

요구사항 1, 2까지 구현했었는데요, 터치하였을 때 터치에 반응하여 요소가 따라오게 하는 구현이랑, 판정을 통해 이전 혹은 다음 요소로 snap이 일어나게 하게끔 구현해보았습니다.

// 모든 경우에서 사용
const [scrollPosition, setScrollPosition] = useState<number>(0);

// 터치 중일 때 사용 (그 외엔 null)
const [prevPosition, setPrevPosition] = useState<number | null>(null);
const [recentPosHistory, setRecentPosHistory] = useState<Array | null>(null);

// snap 애니메이션 중에 사용 (그 외엔 null)
const [snapStartedAt, setSnapStartedAt] = useState<number | null>(null);
const [snapStartedPos, setSnapStartedPos] = useState<number | null>(null);
const [snapTargetPos, setSnapTargetPos] = useState<number | null>(null);

문제는 ... 구현을 하드하게 하다 보니 상태가 너무 많아졌다는 점인데요, 상태가 많은 것은 그렇다 쳐도 논리적으로 상태 모순이 발생할 수 있다는 문제가 있었습니다.

"터치 중"과 "snap 애니메이션"이 동시에 일어날 수 있을까요? 실제론 그렇지 않습니다. 터치 중일 땐 snap 애니메이션이 일어날 수 없고, snap 애니메이션이 일어나는 중에는 터치 중일 수 없습니다.

따라서 prevPosition은 터치 중일때만 number이며, 그 외의 상황에선 null이어야 합니다. 마찬가지로 snapStartedAt도 snap 애니메이션이 일어나는 중에만 number여야 합니다. 그렇지만 코드 상으론 둘 다 null 이거나, 둘 다 number이 될 수도 있습니다. 이렇게 되면 안되겠지만 가능할 수도 있다는 것이죠.

이것을 상태 모순이라고 합니다.

5

분기에 따라 사용하는 상태들을 그룹핑해보았습니다. 그림에서 볼 수 있듯이, "아무것도 안하는 중"일 땐 "터치 중", "snap 애니메이션 중"의 상태는 사용하지 않습니다. (엄격히 말하자면, 사용할 수 없어야 합니다)

6

그런데 이 상황은 마치 신호등과도 같다고 할 수 있습니다. "아무것도 안하는 중", "터치 중", "snap 애니메이션 중" 3가지는 동시에 존재할 수 없으며, 무조건 하나만 활성이 되어 있어야 합니다.

이러한 특성은 마치 유한 상태 머신(Finite-State Machine) 이라고도 할 수 있습니다.

7

유한 상태 머신(Finite-State Machine) 이란 무엇일까요? "유한" 이라는 것은 가질 수 있는 상태(state) 가 유한임을 의미합니다. "상태 머신"은 주어진 복수개의 상태 중 하나만을 가지며, 외부 자극에 의해 다른 상태로 전이(transition) 되는 모델을 뜻합니다.

유한 상태 머신은 주로 게임에서 많이 언급되는데요, 주로 몬스터와 같은 AI의 행동에 유한 상태 머신 모델을 적용합니다. 몬스터가 "평소"와 같이 행동하다가 플레이어를 발견하면 "쫒아가는" 상태로 전이되고 공격합니다. 불리한 것 같으면 "도망가는" 상태로 전이할 수도 있구요.

유한 상태 머신(Finite-State Machine) 적용

본격적으로 유한 상태 머신 모델을 적용해보도록 하겠습니다.

8

터치 스와이프의 동작에 유한 상태 머신 모델을 적용하면 3가지의 상태와 함께 전이(transition) 조건을 정의할 수 있습니다. 외부의 어떤 자극에 어떻게 전이시킬지 정하는 것이죠.

위 그림과 같이 전이 방향을 그려보았습니다. 사용자의 터치 입력에 반응하여 전이가 결정됩니다.

9

유한 상태 모델의 상태를 적절히 표현할 수 있는 문법이 때마침 TypeScript에 있습니다. 바로 tagged union인데요, union은 취할 수 있는 타입들의 집합입니다. 런타임에서는 union의 타입들 중 하나만 존재할 수 있기 때문에 유한 상태 머신을 표현하기에 가장 찰떡같은 문법이라고 할 수 있습니다.

tagged는 union의 타입을 구분하는 방법입니다. MachineState 타입을 보시면 공통적으로 label 프로퍼티를 가진 것을 볼 수 있는데요, 이 프로퍼티를 기준으로 Type Narrowing이 동작하게 됩니다.

10

전이는 React의 useState에서 제공해주는 dispatch 함수를 사용하면 됩니다.

상태와 전이 추가하기

11

위의 요구사항 3번을 추가한다면 어떻게 추가할 수 있을까요? 구현 방법에 대한 자세한 언급은 하지 않지만 유한 상태 머신 모델에서 상태와 전이를 어떻게 추가할 수 있는지 보여드리도록 하겠습니다.

13

"터치 중" 으로 넘어가기 전에, "스와이프 방향 체크" 를 거치도록 해주었습니다. 세로 스와이프인데 가로로 스와이프를 하려고 하면 "아무것도 안하는 중" 으로 전이되구요.

새로운 상태와 전이를 어떻게 추가하는지 잘 보셨나요? 외부의 자극(터치)에 따라 어떻게 동작해야 할지를 잘 생각해본다면 쉬울 겁니다.

14

TypeScript에서도 마찬가지로 tagged union에 상태를 추가해주기만 하면 됩니다. 간단하죠? 그리고 앞서 정의한 대로 상태가 전이될 수 있도록 구현을 수정해주면 될 것 같습니다.

소스코드와 시연

아래를 펼치면 소스코드를 보실 수 있습니다! 실제 요즘카페에서 사용되는 소스코드를 그대로 들고 왔습니다.

ScrollSnapContainer.tsx
import type React from 'react';
import type { HTMLAttributes, MouseEventHandler, PropsWithChildren, TouchEventHandler, WheelEventHandler } from 'react';
import { useEffect, useRef, useState } from 'react';
import styled from 'styled-components';

type TimingFn = (x: number) => number;

type BoxModel = {
  pageY: number;
  height: number;
};

type TouchPoint = {
  x: number;
  y: number;
};

type MachineState =
  // 아무런 동작도 하지 않는 상태
  | {
      label: 'idle';
    }
  // 터치 방향(좌우 혹은 상하)에 따라 스와이프를 해야할 지 결정하는 상태
  // 이 상태에선 실질적으로 스와이프가 동작하지 않는다
  | {
      label: 'touchdecision';
      originTouchPoint: TouchPoint;
    }
  // 터치의 움직임에 따라 스크롤이 동작하는 상태
  | {
      label: 'touchmove';
      prevPosition: number;
      recentPositionHistory: { timestamp: number; position: number }[];
    }
  // 터치가 끝났고 스크롤이 가장 가까운 스냅 포인트로 이동하는(애니메이션) 상태
  | {
      label: 'snap';
      startedAt: number;
      startedPosition: number;
    };

// 음수 mod 시 양수 값을 얻기 위한 함수
const mod = (n: number, m: number) => ((n % m) + m) % m;

// 터치 스와이프가 수직 혹은 수평 방향인지 판단할 때 샘플링하는 거리
const SWIPE_TOUCH_DECISION_RADIUS = 4;

// 스와이프 시 다음 아이템으로 이동하는 시간(ms)
const SWIPE_SNAP_TRANSITION_DURATION = 300;

// 빠르게 휙 스와이프 시:
//   스와이프 판정을 위해 직전 {value}ms 터치 포인트와 비교
const SWIPE_FAST_SCROLL_TIME_DURATION = 100;

// 빠르게 휙 스와이프 시:
//   직전 터치 포인트와 비교하여 {value}% 만큼 이동하였다면 스와이프 처리
//   작을 수록 짧게 스와이프해도 판정됨
//   클 수록 넓게 스와이프해야 판정됨
const SWIPE_FAST_SCROLL_DISTANCE_RATIO = 0.03;

// 스크롤로 스와이프 시:
//   너무 빠른 스와이프를 방지하기 위해 현재 아이템의 {value}%만큼 보일 때
//   스크롤 스와이프 유효화 처리
//   값이 작을수록 빠른 스크롤 스와이프가 더 많이 차단된다
//   1 = 스크롤 시 무조건 스와이프 판정
//   0 = 다음 아이템으로 완전히 넘어가야 스와이프 판정 가능
const SWIPE_WHEEL_SCROLL_VALID_RATIO = 0.1;

type ScrollSnapVirtualItemProps = PropsWithChildren<{
  // 이전 아이템인지, 현재 아이템인지, 이후 아이템인지 여부를 나타내는 숫자
  offset: -1 | 0 | 1;
  // 0.0 ~ 1.0
  position: number;
}>;

const ScrollSnapVirtualItem = (props: ScrollSnapVirtualItemProps) => {
  const { offset, position, children } = props;

  return (
    <div
      style={{
        height: '100%',
        gridArea: '1 / 1 / 1 / 1',
        transform: `translateY(${(offset + -position) * 100}%)`,
      }}
    >
      {children}
    </div>
  );
};

type ScrollSnapVirtualItemsProps<Item> = {
  scrollPosition: number;
  items: Item[];
  itemRenderer: (item: Item, index: number) => React.ReactNode;
  enableRolling?: boolean;
};

const ScrollSnapVirtualItems = <Item,>(props: ScrollSnapVirtualItemsProps<Item>) => {
  const { scrollPosition, items, itemRenderer, enableRolling } = props;

  // position of item, which user sees
  // always positive integer
  // 현재 화면에 표시되고 있는 아이템의 index
  const focusedIndex = mod(Math.floor(scrollPosition), items.length);

  const indexedItems = items.map((item, index) => ({ index, item }));

  // 현재 화면의 아이템 및 위, 아래의 아이템을 표시한다
  const visibleItems = enableRolling
    ? [
        indexedItems.at(focusedIndex - 1), // 현재에서 상단 (혹은 끝) 아이템
        indexedItems.at(focusedIndex), // 현재 아이템
        indexedItems.at((focusedIndex + 1) % indexedItems.length), // 현재에서 하단 (혹은 첫) 아이템
      ]
    : [
        indexedItems[focusedIndex - 1], // 현재에서 상단 아이템
        indexedItems[focusedIndex], // 현재 아이템
        indexedItems[focusedIndex + 1], // 현재에서 하단 아이템
      ];
  const visiblePosition = mod(scrollPosition, 1);

  return (
    <div
      style={{
        width: '100%',
        height: '100%',
        display: 'grid',
      }}
    >
      {([0, 1, -1] as const)
        .map((visibleIndex) => ({ ...visibleItems[1 + visibleIndex], visibleIndex }))
        .map(({ item, index, visibleIndex }) =>
          item && typeof index === 'number' ? (
            <ScrollSnapVirtualItem key={index} offset={visibleIndex} position={visiblePosition}>
              {itemRenderer(item, index)}
            </ScrollSnapVirtualItem>
          ) : (
            <ScrollSnapVirtualItem key={index} offset={visibleIndex} position={visiblePosition} />
          ),
        )}
    </div>
  );
};

type ScrollSnapContainerProps<Item> = HTMLAttributes<HTMLDivElement> & {
  // position of currently active item. (equiv with index)
  // always 0..items.length (positive integer)
  activeIndex: number;
  onActiveIndexChange: (activeIndex: number) => void;
  // scroll position of container
  scrollPosition: number;
  onScrollPositionChange: (scrollPosition: number) => void;
  timingFn: TimingFn;
  items: Item[];
  itemRenderer: (item: Item, index: number) => React.ReactNode;
  // enable continuity scrolling at the end of item
  enableRolling?: boolean;
};

const ScrollSnapContainer = <Item,>(props: ScrollSnapContainerProps<Item>) => {
  const {
    activeIndex,
    onActiveIndexChange,
    scrollPosition,
    onScrollPositionChange,
    items,
    itemRenderer,
    enableRolling,
    timingFn,
    ...divProps
  } = props;

  const [, setNothing] = useState({});
  const rerender = () => setNothing({});

  const [boxModel, setBoxModel] = useState<BoxModel | null>(null);

  // 마지막 아이템에서 첫 번째 아이템으로 넘어가려고 할 때 허용할 지, 허용하지 않을 지
  // 여부를 결정하는 함수
  const clampPosition = enableRolling
    ? (position: number) => mod(position, items.length)
    : (position: number) => Math.max(0, Math.min(position, items.length - 1));

  const setScrollPosition = (scrollPosition: number) => {
    onScrollPositionChange(clampPosition(scrollPosition));
  };

  const setActiveIndex = (activePosition: number) => {
    onActiveIndexChange(clampPosition(activePosition));
  };

  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const $container = containerRef.current;
    if ($container === null) return;

    const observer = new ResizeObserver((entries) => {
      const entry = entries.pop();
      if (!entry) return;

      const { y: pageY, height } = entry.contentRect;
      setBoxModel({ pageY, height });
    });
    observer.observe($container);

    return () => observer.disconnect();
  }, []);

  const [machineState, setMachineState] = useState<MachineState>({
    label: 'idle',
  });
  const transitionMachineState = (state: MachineState) => {
    setMachineState(state);
  };

  // returns normalized by container box height.
  // if touch is inside of screen, then 0.0 ~ 1.0
  // or outside, it can be negative or greater than 1.0
  const normalizePageY = (pageY: number): number => {
    if (!boxModel) return 0;

    return (pageY - boxModel.pageY) / boxModel.height;
  };

  const onTouchStart = (touchPoint: TouchPoint) => {
    transitionMachineState({
      label: 'touchdecision',
      originTouchPoint: touchPoint,
    });
  };

  const onTouchDecision = (touchPoint: TouchPoint) => {
    if (machineState.label !== 'touchdecision') return;

    // swipe decision
    const { originTouchPoint } = machineState;
    const [rx, ry] = [originTouchPoint.x - touchPoint.x, originTouchPoint.y - touchPoint.y].map(Math.abs);
    if (Math.abs(rx) < SWIPE_TOUCH_DECISION_RADIUS || Math.abs(ry) < SWIPE_TOUCH_DECISION_RADIUS) {
      return;
    }

    // not my direction
    if (ry < rx) {
      transitionMachineState({ label: 'idle' });
      return;
    }

    const position = normalizePageY(touchPoint.y);
    // transition state to 'touchmove'
    transitionMachineState({
      label: 'touchmove',
      prevPosition: position,
      recentPositionHistory: [{ timestamp: Date.now(), position }],
    });
  };

  const onTouchMove = (touchPoint: TouchPoint) => {
    if (machineState.label === 'touchdecision') {
      onTouchDecision(touchPoint);
      return;
    }

    if (machineState.label !== 'touchmove') return;

    const { prevPosition, recentPositionHistory } = machineState;
    const position = normalizePageY(touchPoint.y);

    // process swipe movement
    const delta = prevPosition - position;
    setScrollPosition(scrollPosition + delta);

    // transition itself
    transitionMachineState({
      label: 'touchmove',
      prevPosition: position,
      recentPositionHistory: [...recentPositionHistory, { timestamp: Date.now(), position }].filter(
        ({ timestamp }) => Date.now() - timestamp < SWIPE_FAST_SCROLL_TIME_DURATION,
      ),
    });
  };

  const onTouchEnd = () => {
    onSnapStart();

    if (machineState.label !== 'touchmove') return;

    const { prevPosition, recentPositionHistory } = machineState;

    const recentPosition = recentPositionHistory[0]?.position;
    if (!recentPosition) return;

    const accelDelta = recentPosition - prevPosition;

    if (Math.abs(accelDelta) > SWIPE_FAST_SCROLL_DISTANCE_RATIO) {
      const positionOffset = accelDelta > 0 ? 1 : -1;
      setActiveIndex(activeIndex + positionOffset);
      return;
    }
    setActiveIndex(Math.round(scrollPosition));
  };

  const onSnapStart = () => {
    if (machineState.label === 'snap') return;

    // transition state to 'snap'
    transitionMachineState({
      label: 'snap',
      startedAt: Date.now(),
      startedPosition: scrollPosition,
    });
    onSnap();
  };

  const onSnap = () => {
    if (machineState.label !== 'snap') return;

    // transition 'snap' itself
    const { startedAt, startedPosition } = machineState;
    const x = (Date.now() - startedAt) / SWIPE_SNAP_TRANSITION_DURATION;
    if (x > 1) {
      // transition to 'idle'
      setScrollPosition(activeIndex);
      transitionMachineState({ label: 'idle' });
      return;
    }

    // 정방향 거리와 역방향 거리를 비교하여 가까운 거리로 transition
    const candidateDistance1 = activeIndex - startedPosition;
    const candidateDistance2 = (items.length - Math.abs(candidateDistance1)) * (candidateDistance1 < 0 ? 1 : -1);

    const transitionDistance =
      Math.abs(candidateDistance1) < Math.abs(candidateDistance2) ? candidateDistance1 : candidateDistance2;

    const processTransition = () => {
      // scrollTo(startedPosition + timingFn(x) * transitionDistance);
      setScrollPosition(startedPosition + timingFn(x) * transitionDistance);
      rerender(); // force trigger re-render
    };
    requestAnimationFrame(processTransition);
  };

  if (machineState.label === 'snap') onSnap();

  const handleTouchStart: TouchEventHandler = (event) => {
    const touch = event.touches[0];
    if (!touch) return;
    event.stopPropagation();

    onTouchStart({ x: touch.pageX, y: touch.pageY });
  };

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

    onTouchMove({ x: touch.pageX, y: touch.pageY });
  };

  const handleTouchEnd: TouchEventHandler = (event) => {
    event.stopPropagation();
    onTouchEnd();
  };

  const handleMouseDown: MouseEventHandler = (event) => {
    event.stopPropagation();
    onTouchStart({ x: event.pageX, y: event.pageY });
  };

  const handleMouseMove: MouseEventHandler = (event) => {
    event.stopPropagation();
    onTouchMove({ x: event.pageX, y: event.pageY });
  };

  const handleMouseUp: MouseEventHandler = (event) => {
    event.stopPropagation();
    onTouchEnd();
  };

  const handleMouseLeave: MouseEventHandler = (event) => {
    event.stopPropagation();
    onTouchEnd();
  };

  const handleWheel: WheelEventHandler = (event) => {
    if (event.shiftKey) return;

    event.stopPropagation();

    // wheel moved while snap-ing
    if (machineState.label === 'snap') {
      const { startedPosition } = machineState;
      const transitionDistance = activeIndex - scrollPosition;

      if (
        // check wheel direction is same with transitioning direction
        transitionDistance * event.deltaY > 0 &&
        // determines how fast swipe rolls when wheel move
        // larger value rolls more faster
        Math.abs(transitionDistance) > SWIPE_WHEEL_SCROLL_VALID_RATIO
      )
        return;
    }
    setActiveIndex(activeIndex + (event.deltaY > 0 ? 1 : -1));
    onSnapStart();
  };

  return (
    <Container
      {...divProps}
      ref={containerRef}
      onTouchStart={handleTouchStart}
      onTouchMove={handleTouchMove}
      onTouchEnd={handleTouchEnd}
      onMouseDown={handleMouseDown}
      onMouseMove={handleMouseMove}
      onMouseUp={handleMouseUp}
      onMouseLeave={handleMouseLeave}
      onWheel={handleWheel}
    >
      <ScrollSnapVirtualItems scrollPosition={scrollPosition} items={items} itemRenderer={itemRenderer} enableRolling />
    </Container>
  );
};

const Container = styled.div`
  cursor: grab;
  user-select: none;
  overflow-y: hidden;
`;

export default ScrollSnapContainer;

상태 전이가 발생하는 모습을 시각화해보았습니다. 각 상태에서 외부 자극이 어떻게 들어오냐에 따라 판단하고 전이가 되는 모습을 볼 수 있습니다.

두 번째 그림은 상-하 스와이프인지 인식하여 터치 처리를 하지 않는 그림입니다.

300ms1000ms

CSS scroll-snap에서는 불가능했던 snap animation의 속도 조절도 위와 같이 가능합니다.

easing 함수를 변경하면 위와 같이 통통 튀는 애니메이션도 적용할 수 있구요,

위, 아래 요소만 로드하여 DOM 생성을 최소화할 수도 있습니다.

커스텀 애니메이션도 가능합니다! 🥳

요즘카페의 길고 긴 터치 스와이프 고도화 구현기가 이렇게 마무리되었습니다 🎉 JavaScript로 하나하나 구현해서 그런지 아직까진 잔버그들이 있지만 하나씩 해결해나갈 예정입니다.

맺으며

프론트엔드 개발을 하며 디자인 패턴을 자주 사용하진 않지만, 본문의 터치 스와이프처럼 복잡한 로직에서 특효약이 될 수 있는 경우가 종종 있습니다. 부적절한 모델을 선택한다면 오히려 더 어려운 문제로 발전시킬 수도 있지만... 적합한 모델을 잘 선택한다면 문제 해결에 적지 않은 도움이 될 것입니다.

터치 스와이프의 상태 흐름이 많이 복잡했는데, 다이어그램을 그려보는 것이 많은 도움이 되었습니다. 때로는 시각적으로 표현해보는 것도 문제 해결에 많은 도움이 되지 않을까 싶습니다.


Profile picture

Written by solo5star

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

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