라이브러리 없이 드래그앤드롭 구현해보기

정하승정하승
5 min read

시작하기 전에

과제로 칸반 형태의 투두리스트를 만들면서 드래그앤드롭까지 구현해야 했는데, 투두리스트에서 CRUD 정도만 구현했지 DND까지는 구현을 해보지 않았고, 당시에는 시간이 좀 많이 걸릴 것 같다고 생각해서 라이브러리를 써서 제출했는데, 이후에 좀 아쉬움이 남아서 동작 원리를 알아보기 위해 이번 기회에 라이브러리 없이 구현해 보려고 한다.

드래그 이벤트 알아보기

기능을 구현하기 앞서서 드래그앤드롭 이벤트에 관해 알아보고자 한다.

  • dragStart - 이름 그대로 드래그를 시작하는 순간 발생하는 이벤트

  • drag - 요소를 드래그할때마다 발생하는 이벤트

  • dragenter - 드래그 된 요소가 드롭 영역에 들어갈 때 발생하는 이벤트

  • dragover - 드래그하면서 마우스가 대상 객체 위에 있을 때 발생하는 이벤트

  • drop - 이벤트가 달린 요소에 드래그를 끝내면 발생하는 이벤트

    • 브라우저의 기본동작을 막기 위해 e.stopPropagation() 로 막아야 한다.
  • dragleave - 요소가 객체 위에서 벗어날 때 발생하는 이벤트

  • dragend - 드래그 자체를 끝날때 발생하는 이벤트

여기 있는 이벤트를 다 사용하지는 않고, dragstart, dragover, dragend, drop 이벤트만 사용했다.

보드 간 순서 변경

먼저, 객체가 드래그 앤 드롭이 가능하도록 하기 위해서는 해당 엘리먼트에 draggable 을 반드시 설정해 줘야 한다. (기본값은 true)

dragstart 이벤트

    const handleDragStart = (
        e: React.DragEvent<HTMLDivElement>,
        board: Board
    ) => {
        setDraggedBoard(board)
        e.dataTransfer.effectAllowed = "move";
    };

dragover 이벤트

 const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
        e.preventDefault();
        e.dataTransfer.dropEffect = "move";
 };

DataTransfer 객체

먼저 드래그 앤 드롭을 위한 이벤트에 안에는 DataTransfer 라는 객체를 반환하게 된다.

notion image

dataTransfer 객체 안에는 또 다른 프로퍼티가 있는데, dropEffecteffectAllowed다.

  • dropEffect - 드래그 중인 요소가 이동 가능하다는 것을 시각적으로 알려주는 프로퍼티다.

  • effectAllowed - 사용자에게 이 객체는 이동이 가능하다고 알려주는 프로퍼티다.

이 두 프로퍼티는 기능과 관련된 부분은 아니기에 지워줘도 크게 문제될 부분은 아니다.

drop

일단 브라우저는 기본적으로 drop 이벤트를 허용하지 않는다. 공식 문서에서도 웹 페이지의 대부분 영역은 데이터를 드롭하기에 적절하지 않다고 설명하고 있다.

notion image

이러한 기본 동작 때문에 stopPropagation 이나 preventDefault 를 통해서 드롭이 가능하도록 해야한다.

또한 drop 이벤트는 단독으로 동작하지 않고 dragover 이벤트도 있어야 올바른 드래그 앤 드롭이 구현된다.

const handleDrop = (e: React.DragEvent<HTMLDivElement>, targetBoard: Board) => {
        e.stopPropagation()

        // 드래그 될 보드의 id
        const oldIndex = boards.findIndex(
            (board) => board.id === draggedBoard.id
        );
        // 드래그 할 위치에 있는 보드의 id
        const newIndex = boards.findIndex(
            (board) => board.id === targetBoard.id
        );

        if (oldIndex !== newIndex) {
            const newBoards = [...boards];
            newBoards.splice(oldIndex, 1);
            newBoards.splice(newIndex, 0, draggedBoard);
            boardActions.reorderBoards(newBoards);
        }
    };

보드 내 태스크 순서 변경과 보드 간 태스크 이동

dragstart 이벤트

const handleTaskDragStart = (
        e: React.DragEvent<HTMLDivElement>,
        task: Task
    ) => {
        e.stopPropagation();
        e.dataTransfer.effectAllowed = "move";
        e.dataTransfer.setData(
            "application/json",
            JSON.stringify({
                taskId: task.id,
                sourceBoardId: boardId,
                task
            })
        );
    };

drop 이벤트

const handleTaskDrop = (
        e: React.DragEvent<HTMLDivElement>,
        targetTask: Task | null
    ) => {
        e.preventDefault();
        e.stopPropagation();
        try {
            const data = JSON.parse(e.dataTransfer.getData("application/json"));
        } catch (error) {
            console.error("태스크 드롭 처리 중 오류 발생:", error);
        }
    };

보드 내 태스크 순서 변경과 보드 간 태스크 이동은 약간 까다로웠다. 보드 이동과 같은 방식으로 구현하면 되겠거니 했지만 원하는대로 되지 않았다.

여기서는 dataTransfer 내 getData, setData 를 활용해야 했다.

dragstart 이벤트가 일어날 때 setData 메소드로 드래그 할 데이터를 전달하고, drop 이벤트가 일어날 때 getData로 데이터를 받으면 된다.

notion image

notion image

번들 사이즈를 측정해서 비교해 본 결과, dnd-kit 라이브러리를 써서 구현했던 것에 비하면 번들 사이즈를 16% 가량 줄일 수 있었다.

구현해 보면서 느낀 점

드래그앤드롭을 제대로 구현해 본 적이 한 번도 없어서 라이브러리를 적용했는데, 문서가 딱히 친절하지 않아서 오히려 애를 먹었었다. 더군다나 이 dnd-kit이라는 라이브러리가 다른 라이브러리와는 달리 Drag and Drop API가 기반한 라이브러리가 아니라고 한다.

위 문서에서 가이드가 잘 되어있었고, 덕분에 DataTransfer 객체가 무엇인지도 알게 된 것 같다. 이후에는 지금 하고 있는 팀 프로젝트에도 적용해 볼 생각이다.

모바일에도 적용하기

웹에서는 마우스 이벤트를 이용해서 드래그앤드롭을 진행하지만, 모바일에서는 이와 달리 ontouchstart 같은 터치 이벤트를 이용해야 한다.

모바일웹에서도 PC에서처럼 동일하게 적용했지만 드래그앤드롭을 진행하는 과정에서 불필요한 드래그가 발생하는 문제가 있었다.

드래그가 적용된 부분에 select-none 클래스를 적용해봤지만 소용이 없었다.

notion image

const handleTouchStart = (e: React.TouchEvent, task: Task) => {
        e.preventDefault(); // 기본 동작 방지
        e.stopPropagation();

        // 롱 프레스 감지를 위한 타임아웃 설정
        touchTimeout.current = setTimeout(() => {
            setDraggedTask(task);
            setIsDragging(true);
            if (draggedTaskRef.current) {
                draggedTaskRef.current.style.opacity = "0.5";
            }
        }, 500); // 500ms 롱 프레스
    };

그래서 열심히 구글링 해 본 결과, setTimeout API와 useRef를 활용해서 touchstart 이벤트가 발생했을 때, 롱프레스 이벤트가 일어나도록 했다.

const handleTouchMove = (e: React.TouchEvent) => {
        e.preventDefault();

        const touch = e.touches[0];
        const element = document.elementFromPoint(touch.clientX, touch.clientY);

        // 보드 요소 찾기
        const boardElement = element?.closest("[data-board-id]") as HTMLElement | null;
        const taskElement = element?.closest("[data-task-id]") as HTMLElement | null;

        if (boardElement) {
            const targetBoardId = boardElement.getAttribute("data-board-id");

            // 다른 보드로 이동하는 경우
            if (targetBoardId) {
                if (taskElement) {
                    const taskId = taskElement.getAttribute("data-task-id") || undefined;
                    const rect = taskElement.getBoundingClientRect();
                    const position = touch.clientY < rect.top + rect.height / 2 ? "before" : "after";

                    setDropPosition({ taskId, position });
                } else {
                    // 보드의 마지막으로 이동
                    setDropPosition({ taskId: undefined, position: "after" });
                }
            }
            // 같은 보드 내에서 이동하는 경우
            else if (taskElement) {
                const taskId =
                    taskElement.getAttribute("data-task-id") || undefined;
                const rect = taskElement.getBoundingClientRect();
                const position = touch.clientY < rect.top + rect.height / 2 ? "before" : "after";
            }
        }
    };

onTouchMove 이벤트 발생 시, 터치 위치에서 가장 가까운 보드와 태스크를 찾고, 이후에 태스크 요소 존재 여부에 따라 드롭 위치를 설정하면 된다.

 const handleTouchEnd = () => {
        if (touchTimeout.current) {
            clearTimeout(touchTimeout.current);
        }

        if (isDragging && draggedTask && dropPosition) {
            const element = document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2);
            const boardElement = element?.closest("[data-board-id]") as HTMLElement | null;
            const targetBoardId = boardElement?.getAttribute("data-board-id");

            // 다른 보드와 같은 보드 내에 대한 경우 처리 로직
        }
    };

이벤트 관련 문제

PC 화면에서와 동일하게 e.preventDefault() 를 적용하였으나, 터치 이벤트가 발생할 때마다 다음과 같은 오류가 발생하고 있었다.

Unable to preventDefault inside passive event listener invocation.

브라우저는 스크롤 성능을 최적화 하기 위해 passive라는 속성을 설정한다고 한다. 이 이벤트 리스너는 터치 이벤트가 발생할 때 브라우저 측에서 true로 설정되는데 이는 성능 최적화 때문에 그렇게 설계되었다고 한다.

false로 설정할 수도 있지만, 이는 성능에 안 좋은 영향을 미친다. 그렇기 때문에 preventDefault 호출을 최소화 하는 게 더 낫다.

그리고 터치되는 영역에 touch-none을 적용해야 한다. 이외에 다른 속성을 사용하면 불필요한 스크롤이 발생하여 성능이 나빠질 수 있다.

Reference

https://developer.mozilla.org/ko/docs/Web/API/DataTransfer

https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Drag_operations#specifying_drop_targets

0
Subscribe to my newsletter

Read articles from 정하승 directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

정하승
정하승