현재 진행하고 있는 프로젝트에서 dnd기능(drag and drop) 으로 리스트의 순서를 바꾸는 인터렉션이 필요했다. 이전 토이프로젝트에서 적은 기능을 가진 레이어를 구현해봤던 경험을 가지고 있어, 그것을 토대로 구현하면 되겠다라고 생각했지만 생각하지 못한 차이점이 존재했다.
이전 토이프로젝트에서 구현했던 dnd 기능은 html의 기본속성인 draggable 과 drag event 를 이용해 만들었었다. dragstart, dragend 를 이용해 drag 중인 item 을 제어하고 dragover 를 이용해서 drag 중 마우스가 가르키는 위치를 측정하여 뒤바꿀 리스트의 순서를 정하고 바꾸는 로직이었다.
문제는 현 프로젝트는 React를 이용해 제작하는 Web 기반 모바일 app이란 것이다.
매우 슬프게도 모바일에서 draggable 은 지원되지 않는다.
mouse event 를 이용해 dom의 위치를 마우스 좌표를 따라 움직이는, 조금 더 바닥부터 코딩을 해야하는 dnd 를 구현해야 했다. 모바일이니 mouse event 가 아닌 touch event를 사용하기로 하고 보통 mouse event 로 dnd 를 만들듯 사용자가 요소를 터치를 했을때 와 터치 상태에서 움직일때, 터치를 그만 두었을때로 나누어 event 를 사용했다.
Touch Start - grap
사용자가 터치를 시작하면 그 터치한 요소를 감지하여 실질적으로 움직일 요소를 target 으로 ref 에 저장했다. 구현하던 기능은 반드시 햄버거 버튼을 잡고 움직여야 하는 기능이기 때문에 단순히 event 의 target 으로 설정하기에는 image 태그만 가져오게 돼, 그 부모노드를 탐색해 실질적으로 움직일 target 을 선별해내 그 정보를 ref 에 저장했다.
target 을 정하고 나면 그 요소를 position absolute 와 z-index 로 다른 요소들 위로 겹칠수 있도록 만들고 top 에 마우스의 좌표를 계산한 값을 넣어주었다. (x, y 로 움직이는 기능이 아닌 y 로만 움직이는 기능이었기 때문)
또한 target 의 사이즈와 같고 내용물은 보이지 않는 clone node 를 만들어 기존 요소의 위치에 삽입하여 다른 list 의 요소들이 target 이 list 에서 빠져나온 것 때문에 위치가 변하지 않도록 하면서 요소를 움직일때 현재 target 이 삽입될 위치를 표시해주는 용도로 사용되도록 구현했다.
Touch Move - drag
사용자가 target 을 grap 한 상태에서 drag 를 하면 absoulte 된 target 요소의 top 으로 touch 좌표값을 계산한 y 값을 넣어주어 drag 를 위 아래로 움직일때 마다 따라오도록 만들고 그 상태로 다른 list 의 요소들과 겹쳐지게 되면 그 요소의 사이즈와 drag 하는 중인 좌표값을 계산하여 그 요소 안에서 50% 보다 위에 있느냐 아래에 있느냐를 계산해 clone node 와 겹쳐진 요소의 자리를 뒤바꿔 주면서 target 이 들어갈 위치를 표시하도록 구현했다.
Touch End - drop
drag 로 target 이 들어갈 위치를 설정하고 touch 를 끝마치게 되면 clone node 의 위치에 target 을 다시 삽입해주고 absolute 나 z-index 등 grap 단계에서 추가된 style 등을 삭제하여 본래의 상태로 돌려주며 clone node를 삭제 한다.
또한 grap 단계에서 설정된 ref 나 state 등도 초기 상태로 리셋해준다면 한번의 dnd 작업이 끝나도록 구현했다.
mouse envet 를 사용한 dnd 는 라이브러리 사용없이 몇번 구현해보았던 기능이었기에 이 설계 대로만 한다면 쉽게 구현이 될 것이라고 생각했으나 touch event 는 mouse event 와 아주 큰 차이점이 하나 있었다.
모바일에서는 손가락으로 잡아 드래그로 화면을 움직여야 하기 때문에 touch move 이벤트에 scroll 기능이 default로 들어가 있다는 점이었다. 나는 사용자가 요소를 drag 하는 동안에 화면이 touch 를 따라 움직이는걸 원하지 않았다.
몇번의 검색후 drag 하는 동안은 event.preventDefault 를 이용해 dgag scroll을 막아주는 기능을 넣었다.
그리고 마주한 오류.
Ignored attempt to cancel a touchstart event with cancelable=false, for example because scrolling is in progress and cannot be interrupted.
스크롤이 되고 있는 도중에는 event속성중 cancleable 속성이 false가 되어 있고 그 상태에서 prevent 로 default 속성을 정지하는건 불가능하다는 이야기 같았다. 기능또한 멈춰져 있는 상태에서는 drag를 하게 되면 touch scroll 이 작동을 멈추는걸 보여주지만 touch scroll 을 작동시켜 화면이 움직이는 중간에 drag를 하기 시작하면 콘솔에 저 문장과 함께 drag 와 touch scroll 이 동시에 작동되는걸 볼 수 있었다.
해답은 console 에 나온 저 cancleable 을 이용하는 것 이었다.
tag에 onEvent props 로 event 를 주는것이 아닌 useEffect 로 drag event 를 부여하면서 passive 속성을 false 로 줘 default 이벤트를 멈출수 있도록 하고 drag 등의 기능들이 작동할 수 있는 상태를 가르키는 state 를 만들어 cancelable 이 ture 일때만 작동하도록 해주었다.
useEffect(() => {
dontScroll.current = (e) => {
if (e.cancelable) {
e.preventDefault();
setScrollOff(false);
} else {
setScrollOff(true);
}
};
if (dragMode) {
document.addEventListener("touchmove", dontScroll.current, {
passive: false
});
}
return () => {
document.removeEventListener("touchmove", dontScroll.current);
};
}, [dragMode, scrollOff]);
또한 백엔드와 통신을 하거나 할 경우 cancelable 이 false 로 고정되는 현상이 나와 실질적인 drag 로직이 담긴 함수를 useCallback 으로 담아 event를 선언했다.
const dragHandler = useCallback(
(e) => {
drag(e);
},
[scrollOff, dragMode, List]
);
useEffect(() => {
document.addEventListener("touchmove", dragHandler, { passive: false });
return () => {
document.removeEventListener("touchmove", dragHandler);
};
}, [dragHandler, scrollOff, dragMode]);
현재 잘 돌아가기는 하지만 내가 맞는 코드를 적절하게 짠건지는 알수가 없어 추후 재검토를 해볼 예정이다.
모든 코드와 Demo는 이곳에서 볼 수 있다.