위 gif는 여기에서 가져온 짤이다. touch event로 Drag and Drop(DnD)을 할 때 첫 번째 터치에서는 기대하는 움직임이 나오질 않고 반드시 두 번 터치해야지 원하는 움직임이 나온다.
나 역시 이런 문제를 만났다. 일단 PC 브라우저에서는 잘 작동을 했는데 모바일 사파리 브라우저에서 이런 문제가 발생했다.
일단 공식문서에서 살펴볼 수 있듯이 리액트의 문제라기 보다는 DOM이 문제의 원인이다.
DnD를 할 때 touchstart
이벤트가 발생하고 나서 touchmove
, 마지막으로 touchend
이벤트가 발생하는데 touchmove
와 touchend
의 event.target
은 최초 touchstart
의 event.target
으로 고정된다. 해당 event.target
이 상호작용 영역 밖으로 움직이는 경우에도, 심지어 document
나 window
에서 제거되는 경우에도 동일하게 적용된다.
다른 mouse event나 pointer event는 event.target
가 항상 가장 최근에 상호작용한 요소로 최신화 되기 때문에 이런 문제가 발생하지 않았던 것이었다.
모달 안에서 DnD를 진행할 때 createPortal
을 하는 것이 대표적인 예시이다. 리액트에서 dragging되는 요소를 portal을 통해 새로운 위치에 렌더링 하는 경우가 있다. 특정 요소를 dragging하게 되면 리렌더링 되면서 기존 요소가 DOM tree에서 제거되고 portal을 통해 새로운 위치에 다시 렌더링 된다.
이때 기존 요소에서 touchstart
이벤트가 발생하고 DOM tree에서 제거되기 때문에 이후에 발생하는 touchmove
와 touchend
이벤트는 DOM tree를 따라 버블링이 일어나지 않는다.
내가 읽었던 여러 글에서는 대표적으로 두 가지 방법을 제시했다. 하나는 아래 예시 코드처럼 touchstart
콜백 안에서 해당 event.target
에 touchmove
와 touchend
에 대한 이벤트 리스터를 등록하는 것이다. 아래는 스택 오버플로우에 제시된 예시코드이다.
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를 사용하라는 것이다.
나는 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
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