[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)
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;
};
dragEvent
는event.dataTransfer
를 대체하는 객체로, 드래그 상태와 관련된 정보를 담고 있습니다.- Draggable과 Droppable의 세 가지 상태는 다음과 같습니다.
- Current: 드래그 시작 시 고정되는 상태.
- Temp: 드래그 중에 UI에 표시되는 임시 상태.
- Target: 드래그 대상이 변경될 때 갱신되는 상태.
droppables
와initialDroppables
화면에 렌더링되는 요소들을 관리하기 위한 배열입니다.droppables
은 현재 화면에 표시될 요소들의 상태.initialDroppables
는 드래그 실패 시 되돌리기 위한 초기 상태.
Cursor 상태 관리 (useCursor)
const useCursor = create<CursorState>((set) => ({
cursorElement: null,
renderCursor: (element, onMouseUp) => {
// 커서 렌더링 로직
},
deleteCursor: () => {
// 커서 삭제 로직
},
}));
renderCursor
: 드래그 시작 시 커서에 따라다니는 요소와 드래그 종료 이벤트를 설정합니다.deleteCursor
: 드래그 종료 시 커서를 초기화합니다.
전역 상태를 바탕으로 컴포넌트 구현
Droppable, Draggable의 구현
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 컴포넌트
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 요소의 위치값을 전역적으로 관리했습니다.
setRect
와getRect
를 통해 요소의 위치값을 저장합니다.
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
를 이용해 브라우저의 다음 페인트 전에 애니메이션을 시작하도록 합니다.transform
과transition
속성을 이용하여 layout shift를 방지한 부드러운 애니메이션을 구현합니다.
결과
드래그 앤 드롭 기능이 부드럽게 작동하는 것을 확인할 수 있습니다.
마치며…!
D&D를 만들면서 상태관리, 이벤트, 애니메이션 등 여러 부분에서 많이 학습이 되었습니다. useLayoutEffect, requestAnimationFrame과 같은 함수를 어떻게 사용할 것인지 부터, 상태관리를 통해 이벤트를 어떻게 구현할 것인지 등등이요.
아직까지 리팩터링 할 부분이 많이 남아있지만 뿌듯하네요
'Front End > React' 카테고리의 다른 글
React 서비스 SEO 성능 개선하기 (feat. SSR) (1) | 2024.09.22 |
---|---|
[React] 이미지 로딩, 렌더 사이 깜박임 현상 해결하기 (0) | 2024.09.05 |
[React-query] refetch와 invalidate의 차이 (0) | 2024.06.07 |
[React] Context를 알아보자. (0) | 2024.04.30 |
React에서의 MSW 기본 사용법 (0) | 2024.04.14 |