touch event를 활용한 Drag and Drop 시에 두 번 터치해야 하는 문제

FeRo 페로·2024년 7월 31일
1
post-thumbnail

문제

위 gif는 여기에서 가져온 짤이다. touch event로 Drag and Drop(DnD)을 할 때 첫 번째 터치에서는 기대하는 움직임이 나오질 않고 반드시 두 번 터치해야지 원하는 움직임이 나온다.
나 역시 이런 문제를 만났다. 일단 PC 브라우저에서는 잘 작동을 했는데 모바일 사파리 브라우저에서 이런 문제가 발생했다.

왜 이런 문제가 발생할까?

일단 공식문서에서 살펴볼 수 있듯이 리액트의 문제라기 보다는 DOM이 문제의 원인이다.

DnD를 할 때 touchstart 이벤트가 발생하고 나서 touchmove, 마지막으로 touchend 이벤트가 발생하는데 touchmovetouchendevent.target은 최초 touchstartevent.target으로 고정된다. 해당 event.target이 상호작용 영역 밖으로 움직이는 경우에도, 심지어 documentwindow에서 제거되는 경우에도 동일하게 적용된다.
다른 mouse event나 pointer event는 event.target가 항상 가장 최근에 상호작용한 요소로 최신화 되기 때문에 이런 문제가 발생하지 않았던 것이었다.

모달 안에서 DnD를 진행할 때 createPortal을 하는 것이 대표적인 예시이다. 리액트에서 dragging되는 요소를 portal을 통해 새로운 위치에 렌더링 하는 경우가 있다. 특정 요소를 dragging하게 되면 리렌더링 되면서 기존 요소가 DOM tree에서 제거되고 portal을 통해 새로운 위치에 다시 렌더링 된다.
이때 기존 요소에서 touchstart 이벤트가 발생하고 DOM tree에서 제거되기 때문에 이후에 발생하는 touchmovetouchend 이벤트는 DOM tree를 따라 버블링이 일어나지 않는다.

해결책

내가 읽었던 여러 글에서는 대표적으로 두 가지 방법을 제시했다. 하나는 아래 예시 코드처럼 touchstart 콜백 안에서 해당 event.targettouchmovetouchend에 대한 이벤트 리스터를 등록하는 것이다. 아래는 스택 오버플로우에 제시된 예시코드이다.

element.addEventListener("touchstart", (event) => {
    const onTouchMove = () => {
        // handle touchmove here
    }
    const onTouchEnd = () => {
        event.target.removeEventListener("touchmove", onTouchMove);
        event.target.removeEventListener("touchend", onTouchEnd);
        // handle touchend here
    }
    event.target.addEventListener("touchmove", onTouchMove);
    event.target.addEventListener("touchend", onTouchEnd);
    // handle touchstart here
});

여러 문서에서 이러한 방법을 event.target에 직접적으로(directly) 등록하는 방법으로 지칭하고 있다.

다른 하나는 touch event 말고 이런 증상이 없는 pointer event를 사용하라는 것이다.

React Beautiful DnD에서 문제 해결하기

나는 DnD를 React Beautiful DnD(RBD)을 사용해서 구현하였는데, RBD에서도 이 문제가 해결되지 않고 존재했다. RBD 깃허브 이슈를 찾아다니다 보니 많은 사람들이 이 문제를 공유하였고 대부분 첫 번째 방법으로 문제를 해결했다.

RBD는 DnD를 감지하는 sensor api를 커스텀 할 수 있다. 그래서 RBD 문서를 보면 어떤 사용자는 손의 제스쳐를 인식하도록 해서 DnD를 하는 사람도 있고 심지어는 뇌파를 이용해서 DnD를 구현한 사람도 있었다.(진짜임)

그래서 기존의 sensor api 대신에 touchstart 이벤트 발생 시에 event.target에 이벤트를 직접 바인딩 해주도록 기존 코드를 커스텀 해서 해결하였다.



import {
  DragDropContext,
  useKeyboardSensor,
  useMouseSensor,
} from 'react-beautiful-dnd';
import useTouchSensor from './custom-sensors/use-touch-sensor';

...
return (
    <DragDropContext
      enableDefaultSensors={false}
      sensors={[useMouseSensor, useKeyboardSensor, useTouchSensor]}
      ....
     </DragDropContext>
);

방법은 먼저 DragDropContext의 enableDefaultSensors를 false로 해서 기존 센서를 off로 하고 사용할 센서들을 sensors에 추가하면 된다. RBD에는 작성되어 있는 keyboard, mouse, touch 센서가 있긴 하지만 이걸 사용한다고 해서 문제가 해결되지 않았다. 12.0.0의 베타 10에서 11버전으로 수정될 때 타겟에 직접 이벤트를 추가하는 코드가 사라졌기 때문이다.


// v12.0.0-beta.10
function bindCapturingEvents(target: HTMLElement) {
  ...
  
  const unbindTarget = bindEvents(target, getTargetBindings(args), options);
  
  ...
}
  
// v12.0.0-beta.11
function bindCapturingEvents() {
  ...
  
  const unbindTarget = bindEvents(window, getHandleBindings(args), options);
  
  ...
}


그래서 베타 10버전의 코드를 되살리는 방향으로 훅을 작성하고 sensor에 추가하면 된다. 아래는 기존 RBD의 use touch sensor api를 바탕으로 베타 10버전의 코드를 추가해 준 코드다.

import * as React from 'react';
import { useCallback, useMemo } from 'use-memo-one';
import type { Position } from 'css-box-model';
import {
  DraggableId,
  FluidDragActions,
  PreDragActions,
  SensorAPI,
} from 'react-beautiful-dnd';

type TouchWithForce = Touch & {
  force: number;
};

type Idle = {
  type: 'IDLE';
};

type Pending = {
  type: 'PENDING';
  point: Position;
  actions: PreDragActions;
  longPressTimerId: NodeJS.Timeout;
};

type Dragging = {
  type: 'DRAGGING';
  actions: FluidDragActions;
  hasMoved: boolean;
};

type Phase = Idle | Pending | Dragging;

type PredicateFn<T> = (value: T) => boolean;

function findIndex<T>(list: T[], predicate: PredicateFn<T>): number {
  if (list.findIndex) {
    return list.findIndex(predicate);
  }

  // Using a for loop so that we can exit early
  for (let i = 0; i < list.length; i++) {
    if (predicate(list[i])) {
      return i;
    }
  }
  return -1;
}

function find<T>(list: T[], predicate: PredicateFn<T>): T | undefined {
  if (list.find) {
    return list.find(predicate);
  }
  const index: number = findIndex(list, predicate);
  if (index !== -1) {
    return list[index];
  }
  return undefined;
}

const supportedPageVisibilityEventName: string = ((): string => {
  const base = 'visibilitychange';

  if (typeof document === 'undefined') {
    return base;
  }

  const candidates: string[] = [
    base,
    `ms${base}`,
    `webkit${base}`,
    `moz${base}`,
    `o${base}`,
  ];

  const supported: string | undefined = find(
    candidates,
    (eventName: string): boolean => `on${eventName}` in document,
  );

  return supported || base;
})();

const idle: Idle = { type: 'IDLE' };

export const timeForLongPress = 120;
export const forcePressThreshold = 0.15;

type GetBindingArgs = {
  cancel: () => void;
  completed: () => void;
  getPhase: () => Phase;
};

function getWindowBindings({
  cancel,
  getPhase,
}: GetBindingArgs): EventBinding[] {
  return [
    {
      eventName: 'orientationchange' as keyof HTMLElementEventMap,
      fn: cancel,
    },
    {
      eventName: 'resize',
      fn: cancel,
    },
    {
      eventName: 'contextmenu',
      fn: (event: Event) => {
        event.preventDefault();
      },
    },
    {
      eventName: 'keydown',
      fn: (event: KeyboardEvent) => {
        if (getPhase().type !== 'DRAGGING') {
          cancel();
          return;
        }
        if (event.key === 'Escape') {
          event.preventDefault();
        }
        cancel();
      },
    },
    {
      eventName: supportedPageVisibilityEventName as keyof HTMLElementEventMap,
      fn: cancel,
    },
  ];
}

function getHandleBindings({
  cancel,
  completed,
  getPhase,
}: GetBindingArgs): EventBinding[] {
  return [
    {
      eventName: 'touchmove',
      options: { capture: false },
      fn: (event: TouchEvent) => {
        const phase: Phase = getPhase();
        if (phase.type !== 'DRAGGING') {
          cancel();
          return;
        }
        
        phase.hasMoved = true;

        const { clientX, clientY } = event.touches[0];

        const point: Position = {
          x: clientX,
          y: clientY,
        };

        event.preventDefault();
        phase.actions.move(point);
      },
    },
    {
      eventName: 'touchend',
      fn: (event: TouchEvent) => {
        const phase: Phase = getPhase();
        if (phase.type !== 'DRAGGING') {
          cancel();
          return;
        }
        event.preventDefault();
        phase.actions.drop({ shouldBlockNextClick: true });
        completed();
      },
    },
    {
      eventName: 'touchcancel',
      fn: (event: TouchEvent) => {
        if (getPhase().type !== 'DRAGGING') {
          cancel();
          return;
        }
        event.preventDefault();
        cancel();
      },
    },
    {
      eventName: 'touchforcechange' as keyof HTMLElementEventMap,
      fn: (event: TouchEvent) => {
        const phase: Phase = getPhase();

        if (phase.type === 'IDLE') {
          throw Error('invariant');
        }

        const touch: TouchWithForce = event.touches[0] as TouchWithForce;

        if (!touch) {
          return;
        }

        const isForcePress: boolean = touch.force >= forcePressThreshold;

        if (!isForcePress) {
          return;
        }

        const shouldRespect: boolean = phase.actions.shouldRespectForcePress();

        if (phase.type === 'PENDING') {
          if (shouldRespect) {
            cancel();
          }
          return;
        }
        if (shouldRespect) {
          if (phase.hasMoved) {
            event.preventDefault();
            return;
          }
          cancel();
          return;
        }
        event.preventDefault();
      },
    },
    {
      eventName: supportedPageVisibilityEventName as keyof HTMLElementEventMap,
      fn: cancel,
    },
  ];
}
type EventOptions = {
  passive?: boolean;
  capture?: boolean;
  once?: boolean;
};

type NewType = (
  event: KeyboardEvent & TouchEvent & EventListenerOrEventListenerObject,
) => void;

type EventBinding = {
  eventName: keyof HTMLElementEventMap;
  fn: NewType;
  options?: EventOptions;
};

function getOptions(
  shared?: EventOptions,
  fromBinding?: EventOptions,
): EventOptions {
  return {
    ...shared,
    ...fromBinding,
  };
}

type UnbindFn = () => void;

function bindEvents(
  el: HTMLElement | Window,
  bindings: EventBinding[],
  sharedOptions?: EventOptions,
) {
    
  const unbindings: UnbindFn[] = bindings.map(
    (binding: EventBinding): UnbindFn => {
      const options = getOptions(sharedOptions, binding.options);

      el.addEventListener(
        binding.eventName,
        binding.fn as EventListenerOrEventListenerObject,
        options,
      );

      return function unbind() {
        el.removeEventListener(
          binding.eventName,
          binding.fn as EventListenerOrEventListenerObject,
          options,
        );
      };
    },
  );

  return function unbindAll() {
    unbindings.forEach((unbind: UnbindFn) => {
      unbind();
    });
  };
}

export default function useTouchSensor(api: SensorAPI) {
  const phaseRef = React.useRef<Phase>(idle);
  const unbindEventsRef = React.useRef<() => void>(() => null);

  const getPhase = useCallback(function getPhase(): Phase {
    return phaseRef.current;
  }, []);

  const setPhase = useCallback(function setPhase(phase: Phase) {
    phaseRef.current = phase;
  }, []);
  
  const startCaptureBinding = useMemo(
    () => ({
      eventName: 'touchstart' as keyof HTMLElementEventMap,
      fn: function onTouchStart(event: TouchEvent) {
        if (event.defaultPrevented) {
          return;
        }
        
        const draggableId: DraggableId | null =
          api.findClosestDraggableId(event);

        if (!draggableId) {
          return;
        }

        const actions: PreDragActions | null = api.tryGetLock(
          draggableId,
          // eslint-disable-next-line no-use-before-define
          stop,
          { sourceEvent: event },
        );

        if (!actions) {
          return;
        }

        const touch: Touch = event.touches[0];
        const { clientX, clientY } = touch;
        const point: Position = {
          x: clientX,
          y: clientY,
        };
        const dragHandleId = api.findClosestDraggableId(event);
        if (!dragHandleId) {
          throw Error('Touch sensor unable to find drag dragHandleId');
        }
        const handle: HTMLElement | null = document.querySelector(
          `[data-rbd-drag-handle-draggable-id='${dragHandleId}']`,
        );
        if (!handle) {
          throw Error('Touch sensor unable to find drag handle');
        }

        unbindEventsRef.current();
        // eslint-disable-next-line no-use-before-define
        startPendingDrag(actions, point, handle);
      },
    }),
    [api],
  );

  const listenForCapture = useCallback(
    function listenForCapture() {
      const options = {
        capture: true,
        passive: false,
      };

      unbindEventsRef.current = bindEvents(
        window,
        [startCaptureBinding],
        options,
      );
    },
    [startCaptureBinding],
  );

  const stop = useCallback(() => {
    const { current } = phaseRef;
    if (current.type === 'IDLE') {
      return;
    }

    if (current.type === 'PENDING') {
      clearTimeout(current.longPressTimerId);
    }

    setPhase(idle);
    unbindEventsRef.current();

    listenForCapture();
  }, [listenForCapture, setPhase]);

  const cancel = useCallback(() => {
    const phase: Phase = phaseRef.current;
    stop();
    if (phase.type === 'DRAGGING') {
      phase.actions.cancel({ shouldBlockNextClick: true });
    }
    if (phase.type === 'PENDING') {
      phase.actions.abort();
    }
  }, [stop]);

  const bindCapturingEvents = useCallback(
    function bindCapturingEvents(target: HTMLElement) {
      const options = { capture: true, passive: false };
      const args: GetBindingArgs = {
        cancel,
        completed: stop,
        getPhase,
      };
      
      const unbindTarget = bindEvents(target, getHandleBindings(args), options);
      const unbindTargetWindow = bindEvents(
        window,
        getHandleBindings(args),
        options,
      );
      const unbindWindow = bindEvents(window, getWindowBindings(args), options);

      unbindEventsRef.current = function unbindAll() {
        unbindTarget();
        unbindTargetWindow();
        unbindWindow();
      };
    },
    [cancel, getPhase, stop],
  );

  const startDragging = useCallback(
    function startDragging() {
      const phase: Phase = getPhase();
      if (phase.type !== 'PENDING') {
        throw Error(`Cannot start dragging from phase ${phase.type}`);
      }

      const actions: FluidDragActions = phase.actions.fluidLift(phase.point);

      setPhase({
        type: 'DRAGGING',
        actions,
        hasMoved: false,
      });
    },
    [getPhase, setPhase],
  );

  const startPendingDrag = useCallback(
    function startPendingDrag(
      actions: PreDragActions,
      point: Position,
      target: HTMLElement,
    ) {
      if (getPhase().type !== 'IDLE') {
        throw Error('Expected to move from IDLE to PENDING drag');
      }

      const longPressTimerId = setTimeout(startDragging, timeForLongPress);

      setPhase({
        type: 'PENDING',
        point,
        actions,
        longPressTimerId,
      });

      bindCapturingEvents(target);
    },
    [bindCapturingEvents, getPhase, setPhase, startDragging],
  );

  React.useLayoutEffect(
    function mount() {
      listenForCapture();

      return function unmount() {
        unbindEventsRef.current();
        const phase: Phase = getPhase();
        if (phase.type === 'PENDING') {
          clearTimeout(phase.longPressTimerId);
          setPhase(idle);
        }
      };
    },
    [getPhase, listenForCapture, setPhase],
  );

  React.useLayoutEffect(function webkitHack() {
    const unbind = bindEvents(window, [
      {
        eventName: 'touchmove',
        fn: () => {
          return;
        },
        options: { capture: false, passive: false },
      },
    ]);

    return unbind;
  }, []);
}

마치며

몰랐지만 DnD의 대표적인 이슈였다. RBD 저자도 질문했던 이슈였고 Dan Abramov도 해당 이슈에 답변을 달았었다. 처음에는 이것 때문에 아예 '모바일 버전을 포기해야 하나' 생각도 했지만 대표적인 이슈이다 보니 이슈란에 선배 개발자들의 원인에 대한 분석과 여러 좋은 해결책들이 많았다. 그래서 처음에는 '왜 touchevent만 이렇게 되고 mouseevent는 멀쩡하게 작동하는 걸까?', '왜 portal을 탈 때 요소가 제거되는거지?'처럼 질문이 꼬리의 꼬리를 물었었지만, 덕분에 지금은 대부분이 말끔히 해소되었다. 물론, 아이폰 safari 브라우저에서도 아주 잘 작동된다.



참고문서
On mobile, re-render the dragged element while it is being dragged. Need to drag twice to drag.

Not able to drag and drop on mobile using React Portal

Touch Move event don't fire after Touch Start target is removed

Touch: target property

Not able to drag and drop on mobile using React Portal

Drag-Drop+Reparenting-Solution-React

Moving to React Portal after touchstart swallows future touch events

the touchmove event

profile
주먹펴고 일어서서 코딩해

0개의 댓글