[React] Drag & Drop (DnD) 직접 구현하기 (2). Mouse Event를 사용하여 구현하기

[React] Drag & Drop (DnD) 직접 구현하기 (2). Mouse Event를 사용하여 구현하기

기존에 Drag Event를 통해 드래그 앤 드롭 기능을 구현했지만, 페이지 레이아웃이 변경되거나 요소가 다시 렌더링되는 Reflow 상황에서 이벤트가 중단되었습니다. 이로 인해 드래그 중이던 요소가 예상치 못한 위치에 놓이거나, 드래그 자체가 취소되는 문제가 발생했습니다.

이 문제를 해결하기 위해 Mouse Event를 사용하여 드래그 기능을 다시 구현하기로 결정했습니다. Mouse Event는 Reflow의 영향을 받지 않아 안정적인 이벤트 처리가 예상했으나, event.dataTransfer 객체를 사용할 수 없다는 점이 문제가 있었습니다.

event.dataTransfer는 Drag & Drop 과정에서 데이터를 전달하는 데 사용됩니다. Mouse Event로 전환하면서 이 객체를 사용할 수 없게 되었고, 따라서 드래그 상태와 데이터를 관리할 방법이 필요했습니다. 이에 따라 전역 상태 관리가 필요하다고 판단하게 되었습니다.

상태 관리 라이브러리를 선택하는 과정에서 여러 옵션을 검토했습니다. 그중에서도 zustand를 선택한 이유는 다음과 같습니다:

  • 가벼움: zustand는 다른 상태 관리 라이브러리에 비해 매우 가볍습니다.
  • 사용의 용이성: 설정이 간단하고 사용 방법이 직관적이어서 빠르게 적용할 수 있습니다.

1️⃣ Mouse Event 구현하기

Drag & Drop 상태관리 (useDragDropStore)

 

lurgi-dnd/src/hooks/useDragDropStore.ts at main · lurgi/lurgi-dnd

Contribute to lurgi/lurgi-dnd development by creating an account on GitHub.

github.com

 

type DragDropState = {
  dragEvent: CustomDragEvent;

  startDrag: () => void;
  endDrag: () => void;

  setCurrentDraggable: (draggable: { id: string | number; index: number }) => void;
  clearCurrentDraggable: () => void;
  setTempDraggable: (draggable: { id: string | number; index: number }) => void;
  clearTempDraggable: () => void;
  setTargetDraggable: (draggable: { id: string | number; index: number }) => void;
  clearTargetDraggable: () => void;

  setCurrentDroppable: (props: { droppableId: string | number; droppableIndex: number }) => void;
  clearCurrentDroppable: () => void;
  setTempDroppable: (props: { droppableId: string | number; droppableIndex: number }) => void;
  clearTempDroppable: () => void;
  setTargetDroppable: (props: { droppableId: string | number; droppableIndex: number }) => void;
  clearTargetDroppable: () => void;

  droppables: Droppable[];
  initialDroppables: Droppable[];

  pushDroppable: (droppable: Droppable) => void;
  addDraggable: (props: { droppableId: number | string; draggable: Draggable }) => void;
  moveDraggable: () => void;
  resetFailDroppable: () => void;
  resetSuccessDroppable: () => void;
};
  • dragEventevent.dataTransfer를 대체하는 객체로, 드래그 상태와 관련된 정보를 담고 있습니다.
  • Draggable과 Droppable의 세 가지 상태는 다음과 같습니다.
    • Current: 드래그 시작 시 고정되는 상태.
    • Temp: 드래그 중에 UI에 표시되는 임시 상태.
    • Target: 드래그 대상이 변경될 때 갱신되는 상태.
  • droppablesinitialDroppables화면에 렌더링되는 요소들을 관리하기 위한 배열입니다.
    • droppables은 현재 화면에 표시될 요소들의 상태.
    • initialDroppables는 드래그 실패 시 되돌리기 위한 초기 상태.

Cursor 상태 관리 (useCursor)

 

lurgi-dnd/src/hooks/useCursor.ts at main · lurgi/lurgi-dnd

Contribute to lurgi/lurgi-dnd development by creating an account on GitHub.

github.com

 

const useCursor = create<CursorState>((set) => ({
  cursorElement: null,

  renderCursor: (element, onMouseUp) => {
    // 커서 렌더링 로직
  },

  deleteCursor: () => {
    // 커서 삭제 로직
  },
}));
  • renderCursor: 드래그 시작 시 커서에 따라다니는 요소와 드래그 종료 이벤트를 설정합니다.
  • deleteCursor: 드래그 종료 시 커서를 초기화합니다.

전역 상태를 바탕으로 컴포넌트 구현

Droppable, Draggable의 구현

 

lurgi-dnd/src/components/Droppable.tsx at main · lurgi/lurgi-dnd

Contribute to lurgi/lurgi-dnd development by creating an account on GitHub.

github.com

Droppable 컴포넌트

const Droppable: React.FC<DroppableProps> = ({ droppableId, children, onDrop }) => {
  // 드래그 이벤트 핸들러
  const handleMouseDown = () => { /* 드래그 시작 로직 */ };
  const handleMouseUp = () => { /* 드래그 종료 로직 */ };
  const handleMouseEnter = () => { /* 드롭 영역 진입 로직 */ };

  return (
    <div
      onMouseDown={handleMouseDown}
      onMouseUp={handleMouseUp}
      onMouseEnter={handleMouseEnter}
    >
      {droppables[droppableIndex]?.draggables.map((draggable, index) => (
        React.isValidElement(draggable) ? React.cloneElement(draggable, { index }) : null
      ))}
    </div>
  );
};
  • handleMouseDown는 드래그 시작 시 호출되며, 상태를 초기화합니다.
  • handleMouseUp는 드래그 종료 시 호출되며, 상태를 정리하고 onDrop 이벤트를 트리거합니다.
  • handleMouseEnter는 드래그 중 다른 Droppable 영역에 진입할 때 호출됩니다.

Draggable 컴포넌트

 

lurgi-dnd/src/components/Draggable.tsx at main · lurgi/lurgi-dnd

Contribute to lurgi/lurgi-dnd development by creating an account on GitHub.

github.com

 

const Draggable: React.FC<DraggableProps> = ({ id, index, children }) => {
  // 레퍼런스와 상태 훅
  const ref = useRef<HTMLDivElement>(null);

  // 드래그 이벤트 핸들러
  const handleMouseDown = (e: React.MouseEvent) => { /* 드래그 시작 로직 */ };
  const handleMouseEnter = () => { /* 드래그 대상 진입 로직 */ };
  const handleMouseLeave = () => { /* 드래그 대상 이탈 로직 */ };

  return (
    <div
      ref={ref}
      onMouseDown={handleMouseDown}
      onMouseEnter={handleMouseEnter}
      onMouseLeave={handleMouseLeave}
    >
      {children}
    </div>
  );
};
  • handleMouseDown는 드래그 시작 시 상태를 설정하고, 커서를 따라다닐 요소를 생성합니다.
  • handleMouseEnter는 다른 Draggable 요소 위로 드래그될 때 호출되어 위치 변경을 트리거합니다.
  • handleMouseLeave는 드래그 대상에서 이탈할 때 상태를 초기화합니다.

2️⃣ 애니메이션 구현

드래그 중 요소의 위치 변경을 자연스럽게 보여주기 위해 애니메이션을 적용했습니다.

위치값 캐싱

이전 위치와 현재 위치를 비교하여 애니메이션을 적용하기 위해 위치값을 캐싱해야 했습니다.

  • useRef를 이용하여 위치값을 저장하려 하였지만, 정상적으로 작동하지 않았습니다.
// Draggable.tsx
useEffect(() => {
    rectRef.current.curRect = ref.current?.getBoundingClientRect();
    console.log(rectRef.current); // curRect와 prevRect가 동일한 값을 출력한다.
    rectRef.current.prevRect = ref.current?.getBoundingClientRect();
}, [index]);
  • index가 변경된다는 것은 Draggable의 위치가 변경된다는 것.
  • ref값을 이용해 리렌더링 이전 값을 참조하고 싶었으나, console.log의 결과는 예상과 다르게 작동하였다. (prevRect는 이전 위치값을, curRect는 현재 위치값을 가질 것이라 생각)
  • 따라서 다음과 같은 객체를 만들어 관리하였습니다.
interface RectObj {
  left?: number;
  top?: number;
}

const DraggableRectClosure = (() => {
  const rectMap = new Map<string, RectObj>();

  const setRect = (id: string, { left, top }: RectObj) => {
    rectMap.set(id, { left, top });
  };

  const getRect = (id: string) => {
    return rectMap.get(id);
  };

  return {
    setRect,
    getRect,
  };
})();
  • 클로저를 이용하여 각 Draggable 요소의 위치값을 전역적으로 관리했습니다.
  • setRectgetRect를 통해 요소의 위치값을 저장합니다.
useEffect(() => {
    const curRect = ref.current?.getBoundingClientRect();
    const curRect = DraggableRectClosure.getRect(draggableId);

    console.log(curRect, curRect); // 예상대로 작동
    DraggableRectClosure.setRect(draggableId, { left: curRect?.left, top: curRect?.top });
  }, [index]);
  • 위치 값 캐싱이 아주 잘 되는 것을 확인하였습니다.

애니메이션 적용

useLayoutEffect(() => {
  const curRect = ref.current?.getBoundingClientRect();
  const prevRect = DraggableRectClosure.getRect(draggableId);

  if (ref.current && prevRect?.left && prevRect.top && curRect) {
    const deltaX = prevRect.left - curRect.left;
    const deltaY = prevRect.top - curRect.top;

    if (deltaX || deltaY) {
      const element = ref.current;
      element.style.transform = `translate(${deltaX}px, ${deltaY}px)`;
      element.style.transition = '';

      requestAnimationFrame(() => {
        element.style.transform = 'translate(0px, 0px)';
        element.style.transition = 'transform 0.2s ease';
      });
    }
  }

  DraggableRectClosure.setRect(draggableId, { left: curRect?.left, top: curRect?.top });

  return () => {
    if (ref.current) {
      const element = ref.current;
      element.style.transform = '';
      element.style.transition = '';
    }
  };
}, [index, draggableId]);
  • useLayoutEffect를 이용해 위치값이 변경될 때마다 이전 위치와 현재 위치를 비교하여 애니메이션을 적용합니다.
  • requestAnimationFrame를 이용해 브라우저의 다음 페인트 전에 애니메이션을 시작하도록 합니다.
  • transformtransition 속성을 이용하여 layout shift를 방지한 부드러운 애니메이션을 구현합니다.

결과

드래그 앤 드롭 기능이 부드럽게 작동하는 것을 확인할 수 있습니다.

 

 

GitHub - lurgi/lurgi-dnd

Contribute to lurgi/lurgi-dnd development by creating an account on GitHub.

github.com

마치며…!

D&D를 만들면서 상태관리, 이벤트, 애니메이션 등 여러 부분에서 많이 학습이 되었습니다. useLayoutEffect, requestAnimationFrame과 같은 함수를 어떻게 사용할 것인지 부터, 상태관리를 통해 이벤트를 어떻게 구현할 것인지 등등이요.

아직까지 리팩터링 할 부분이 많이 남아있지만 뿌듯하네요