프로젝트중 리스트 드래그앤드랍기능이 여러팀에서 필요하여 공통컴포넌트로 빼기로 팀간 협의가 되어 내가 작업을 하게되었다. 초반 라이브러리 선정시 react-dnd와 react-beautiful-dnd 둘중 고민을 하였지만 react-dnd가 훨씬 가볍고 이전에 회사에서 react-beautiful-dnd는 사용을 해보았기에 다른 라이브러리도 경험 해볼 수 있어 좋은 경험이 되었다.
우선 각 페이지의 데이터 타입과 유아이 형태는 다르지만 드래그앤드랍이라는 기능이 같고 드래깅을하다 드롭이 되는순간 그 리스트의 새로운 배열을 리턴해주는 방식이기때문에 공통 컴포넌트로 빼는건 어찌보면 당연하였기에 확장성은 최대한 열고 어떤 방식에 컴포넌트에서도 사용이 가능하게 하기 위해 코드를 짜려 노력했다.
주요로직을 간단히 설명하자면 부모컴포넌트에서 DnDWrapper컴포넌트로 아이템리스트, 콜백받을 함수, 칠드런node, dragSectionName(각 드래깅리스트의 이름을 각각 다르게해줘야 리스트가 두개가있을경우 서로간 이동이안된다.)을 보내주는것이고 그걸 받은 DnDWrapper컴포넌트가 react-dnd 내장함수를 사용한 useDnD 커스텀훅을 이용하여 ref와 그아이템을 칠드런으로 들어온 노드에 적용을하면 동작하는 로직이라 보면된다.
예시코드와 주석을 작성하여 다른팀원분들도 무리없이 컴포넌트를 사용하였고. 나중에 개인적으로도 사용해볼만한 컴포넌트를 만들어놓은거 같아 나름 뿌듯했다.
// DnDWrapper.tsx
"use client";
import React, { useEffect, useState } from "react";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { useDnD } from "@/hooks/common/useDnD";
interface DnDWrapperPropsType {
dragList: any[];
onDragging?: (newOrder: any[]) => void; // 항목이 드래그 중일 때 호출되는 함수
onDragEnd: (newOrder: any[]) => void; // 드래그가 종료되었을 때 호출되는 함수
children: (
item: any,
ref: React.RefObject<HTMLLIElement>,
isDragging: boolean,
) => React.ReactNode; // 각 항목을 랜더링하는 함수
dragSectionName: string; // 드래그 섹션 이름(각 드래그리스트는 섹션 이름을 다르게 해야 각 섹션 아이템간 이동이 불가)
}
interface DraggableItemProps {
dragItem: any;
itemIndex: number; // 항목의 인덱스
onMove: (dragIndex: number, hoverIndex: number, isFinished: boolean) => void; // 항목이 이동했을 때 호출되는 함수
itemRenderer: (
dragItem: any,
ref: React.RefObject<HTMLLIElement>,
isDragging: boolean,
) => React.ReactNode; // 항목을 랜더링하는 함수
dragSectionName: string; // 드래그 섹션 이름
}
// 드래그 앤 드랍을 처리하는 래퍼 컴포넌트를 정의한다.
export const DnDWrapper = ({
dragList,
onDragging,
onDragEnd,
children,
dragSectionName,
}: DnDWrapperPropsType) => {
const [currentItems, setCurrentItems] = useState(dragList); // 현재 항목의 상태 관리
// 항목이 이동했을 때 호출되는 함수.
const handleItemMove = (
dragIndex: number,
hoverIndex: number,
isFinished: boolean,
) => {
const newItems = [...currentItems];
const [draggedItem] = newItems.splice(dragIndex, 1);
newItems.splice(hoverIndex, 0, draggedItem);
setCurrentItems(newItems);
newItems.forEach((item, index) => {
item.order = index;
});
if (isFinished) {
onDragEnd(newItems); // 드래그가 종료되었을 때 콜백 함수를 호출.
} else {
onDragging && onDragging(newItems); // 항목이 드래그 중일 때 콜백 함수를 호출.
}
};
useEffect(() => {
setCurrentItems(dragList);
}, [dragList]);
// 각 항목을 랜더링.
return (
<DndProvider backend={HTML5Backend}>
{currentItems.map((item, idx) => (
<DraggableItem
key={item.id}
dragItem={item}
itemIndex={idx}
onMove={handleItemMove}
itemRenderer={children}
dragSectionName={dragSectionName}
/>
))}
</DndProvider>
);
};
// 드래그 가능한 항목 컴포넌트를 정의
const DraggableItem = ({
dragItem,
itemIndex,
onMove,
itemRenderer,
dragSectionName,
}: DraggableItemProps) => {
const { ref, isDragging } = useDnD({ itemIndex, onMove, dragSectionName }); // useDnD 훅을 사용하여 드래그 앤 드랍을 처리.
return itemRenderer(dragItem, ref, isDragging); // 항목 랜더링.
};
// useDnD.tsx
"use client";
import { useRef } from "react";
import { useDrag, useDrop } from "react-dnd";
interface UseDnD {
itemIndex: number;
onMove: (dragIndex: number, hoverIndex: number, isFinished: boolean) => void;
dragSectionName: string;
}
interface DraggedItem {
type: string;
draggingItemCurrentIndex: number;
}
export const useDnD = ({ itemIndex, onMove, dragSectionName }: UseDnD) => {
// useRef를 사용해 HTMLLIElement에 대한 참조를 생성
const ref = useRef<HTMLLIElement>(null);
const [{ isDragging }, drag] = useDrag({
type: dragSectionName,
item: { type: dragSectionName, draggingItemCurrentIndex: itemIndex },
collect: monitor => ({ isDragging: monitor.isDragging() }),
end: (draggedItem: DraggedItem, monitor) => {
// 드래그 동작이 종료되었을 때의 동작 설정 (가장마지막동작,useDrop보다늦게동작)
const didDrop = monitor.didDrop();
if (!didDrop && ref.current) {
onMove(draggedItem.draggingItemCurrentIndex, itemIndex, true);
}
},
});
// 드롭 동작을 관리하기 위한 useDrop 훅 설정
const [, drop] = useDrop({
accept: dragSectionName,
hover: (draggingItem: DraggedItem, monitor) => {
// 현재 드래그 중인 항목이나 대상 항목이 유효하지 않을 경우 반환
if (!ref.current || draggingItem.draggingItemCurrentIndex === itemIndex)
return;
// 현재 항목의 위치와 크기 정보를 가져옴
const hoverBoundingRect = ref.current.getBoundingClientRect();
// 드래그 중인 항목의 현재 위치를 가져옴
const clientOffset = monitor.getClientOffset();
if (!clientOffset) return;
// 드래그 중인 항목의 위치와 크기 계산
const draggedItemRect = {
left: clientOffset.x,
right: clientOffset.x + hoverBoundingRect.width,
top: clientOffset.y,
bottom: clientOffset.y + hoverBoundingRect.height,
};
// 두 항목이 겹치는 영역을 계산
const overlapX = Math.max(
0,
Math.min(hoverBoundingRect.right, draggedItemRect.right) -
Math.max(hoverBoundingRect.left, draggedItemRect.left),
);
const overlapY = Math.max(
0,
Math.min(hoverBoundingRect.bottom, draggedItemRect.bottom) -
Math.max(hoverBoundingRect.top, draggedItemRect.top),
);
// 겹치는 영역의 면적 계산 후, 해당 면적이 전체 면적의 10%를 초과하면 위치 변경
const overlapArea = overlapX * overlapY;
const hoverArea = hoverBoundingRect.width * hoverBoundingRect.height;
const thresholdArea = hoverArea * 0.1;
if (overlapArea > thresholdArea) {
onMove(draggingItem.draggingItemCurrentIndex, itemIndex, false);
draggingItem.draggingItemCurrentIndex = itemIndex;
}
},
drop: (draggedItem: DraggedItem) => {
// 항목이 다른 항목 위에 드롭되었을 때 동작 설정
return onMove(draggedItem.draggingItemCurrentIndex, itemIndex, true);
},
});
// drag와 drop을 합쳐 해당 요소에 연결
drag(drop(ref));
return {
ref,
isDragging,
};
};
/*
** 드래그앤드랍 예제파일 **
* react-dnd-html5-backend 관련 에러뜰경우
패키지 인스톨필요 "yarn add react-dnd-html5-backend"
* 만약 DnDWrapper안에 컴포넌트를 칠드런으로 넘길경우 forwardRef사용 필수
*/
"use client";
import React, { useState, ForwardedRef } from "react";
import { DnDWrapper } from "@/components/DnDWrapper";
export interface TestDnDItem {
id: string;
text: string;
}
const DnDExamplePage = () => {
const [initialItems, _] = useState<TestDnDItem[]>([
{
id: "1~",
text: "DnD 예시아이템1",
},
{
id: "2~",
text: "DnD 예시아이템2",
},
{
id: "3~",
text: "DnD 예시아이템3",
},
{
id: "4~",
text: "DnD 예시아이템4",
},
{
id: "5~",
text: "DnD 예시아이템5",
},
{
id: "6~",
text: "???",
},
{
id: "7~",
text: "@@@",
},
]);
const whenDragging = (newList: TestDnDItem[]) => {
// console.log("!드래그중일떄", newList);
};
const whenDragEnd = (newList: TestDnDItem[]) => {
console.log("@드래그끝났을떄", newList);
};
return (
<>
<div>DnD 예제코드.</div>
<div className="flex justify-around">
<div className="border border-gray-950 mb-10">
// 1. 바로 드래깅할 아이템 태그를 직접 넣을때.
<DnDWrapper
dragList={initialItems}
onDragging={whenDragging}
onDragEnd={whenDragEnd}
dragSectionName={"abc"}
>
{(dragItem, ref, isDragging) => (
<li
ref={ref}
className={`p-2 border border-blue-700 m-2 ${
isDragging ? "opacity-20" : ""
}`}
>
<p>{dragItem.id}</p>
<p>{dragItem.text}</p>
</li>
)}
</DnDWrapper>
</div>
<div className="border border-gray-950">
// 2. 컴포넌트를 칠드런으로 넣을때.(자식컴포넌트는 ref를 내려받아야하므로 forwardRef **필수**)
<DnDWrapper
dragList={initialItems}
onDragging={whenDragging}
onDragEnd={whenDragEnd}
dragSectionName={"def"}
>
{(dragItem, ref, isDragging) => (
<DnDTestComponent
dragData={dragItem}
ref={ref}
isDragging={isDragging}
zxzx="다른프롭스내려봄"
yzyz="또다른프롭스내려봄"
/>
)}
</DnDWrapper>
</div>
</div>
</>
);
};
export default DnDExamplePage;
interface DnDTestComponentProps {
dragData: TestDnDItem;
isDragging: boolean;
zxzx: string;
yzyz: string;
}
const DnDTestComponent = React.forwardRef(
(
{ dragData, isDragging, zxzx, yzyz }: DnDTestComponentProps,
// { dragData, isDragging, ...props }: DnDTestComponentProps,
ref: ForwardedRef<HTMLLIElement>,
) => (
<li
ref={ref}
className={`p-2 border border-red m-2 ${isDragging ? "opacity-20" : ""}`}
>
<p>{dragData.id}</p>
<div>{yzyz}</div>
{/* <div>{props.zxzx}</div> */}
</li>
),
);