들어가며
프로젝트를 진행하면서 리스트 순서를 바꾸는 기능이 필요했다.
리스트 순서를 변경하는 라이브러리는 react-beautiful-dnd 와 react-dnd 두 가지가 있었다.
처음에는 react-beautiful-dnd 를 사용해서 테스트를 해보다가 곧 종료된다는 이야기가 있어서, 주말간에 react-dnd 를 새로 설치해 테스트 해보았다!
react-beautiful-dnd 를 적용했을 때 애니메이션이 미리 정의되어 있어서 좀 더 유연한 느낌이지만 용량이 react-dnd 보다 크다.
반면에 react-dnd 는 hover 중일 때 순서가 변경되는 애니메이션이나 위치 변경을 직접 정의해야해 처음 적용시킬 때 살짝 어려운 편이다.
설치하기
# react-dnd를 사용하기 위해 필요한 라이브러리 install
$ npm i react-dnd react-dnd-html5-backend
react-dnd 를 사용하기 위해서는 react-dnd-html5-backend 도 함께 설치해야 한다.
react-dnd-html5-backend 는 HTML5의 드래그 앤 드롭 API를 사용하는 기본 백엔드이다. 이 백엔드는 마우스, 터치 이벤트를 처리하고 브라우저의 기본 드래그 앤 드롭 기능을 활용한다.
추가적으로 모바일에서도 사용하기 위해서는 react-dnd-multi-backend, react-dnd-touch-backend 를 설치해주면 된다.
기본설정
react-dnd 를 사용하기 위해서 기본적으로 애플리케이션의 루트 컴포넌트를 DndProvider 로 감싸줘야 한다.
만약 특정 부분에서만 사용한다면 그 컴포넌트 위에 provider 로 감싸도 무방하다.
"use client";
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
export default function DndPage() {
return (
<DndProvider backend={HTML5Backend}>
<div> 순서 변경이 필요한 리스트들 </div>
</DndProvider>
);
}
나는 개인 프로젝트(Nextjs14)에서 테스트를 진행했기 때문에 특정 컴포넌트 가장 바깥에 Provider 를 감쌌다.
회사 프로젝트는 react 로 진행 중인데 react 에서도 동일하게 적용할 수 있다.
UseDrag
useDrag 는 요소를 드래그 할 수 있게 한다.
const ITEM_TYPE = 'ITEM'; // 아이템 타입 선언
const [{ isDragging }, drag] = useDrag(() => ({
type: ITEM_TYPE,
item: { id, index }, // 드래그할 항목의 id 와 index 를 보냄
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
}));
useDrag 훅을 사용해 드래그 상태와 드래그 참조를 설정한다. 이때 type 과 item 은 필수값이므로 꼭 설정해줘야 한다.
✏️ isDragging : 현재 컴포넌트가 드래깅 중인지 boolean 형태로 리턴받음
✏️ type : 항목의 타입을 지정
✏️ item : 드래그 되는 항목에 전달할 데이터를 설정
✏️ collect : isDragging 이 현재 드래깅 중인지 아닌지 리턴함
UseDrop
useDrop 은 요소가 드래그 된 아이템을 받는다. 해당 훅을 사용해 드롭 대상의 설정을 정의한다.
const [, drop] = useDrop({
accept: ITEM_TYPE,
hover: (item) => {
if(item.index !== index) {
moveItem(item.index, index);
item.index = index; // 드래그 중인 아이템의 인덱스 업데이트
}
}
});
✏️ accept : 우리가 지정한 type 만 허용. 즉 useDrag 와 타입이 다르면 반응하지 않음.
✏️ hover : 요소를 드래그 해 다른 요소 위에 hover 할 때 요소 자신이 아니면 위치를 바꿈.
→ 즉, useDrag 에서 item 으로 지정한 index 를 가지고 위치를 교환
UseDrag 와 UseDrop 통합
const ITEM_TYPE = 'ITEM'; // 아이템 타입 선언
function DraggableItem({ id, text, index, moveItem }) {
const ref = useRef(null);
const [{ isDragging }, drag] = useDrag(() => ({
type: ITEM_TYPE,
item: { id, index }, // 드래그할 항목의 ID와 인덱스를 보냄
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
}));
const [, drop] = useDrop({
accept: ITEM_TYPE,
hover: (item) => {
if(item.index !== index) {
moveItem(item.index, index);
item.index = index; // 드래그 중인 아이템의 인덱스 업데이트
}
}
});
drag(drop(ref)); // drag, drop ref 에 연결
return (
<div ref={ref} style={{ opacity: isDragging ? 0.5 : 1, padding: '8px', border: '1px solid gray', cursor: 'move' }}>
{text}
</div>
);
}
처음에는 useDrag 와 useDrop 을 ref 로 연결시키지 않고, 따로따로 구현을 했었다.
그 과정에서 리스트 배치가 잘 되지 않는 문제점을 발견했고 useRef 를 이용해 DOM 요소에 대해 참조를 생성하고, 그 참조가 드래그와 드롭 기능을 연결하도록 만들었다.
drag(drop(ref));
drag 와 drop 을 연결해 드래그 가능한 요소와 드롭 가능한 영역을 동일한 DOM 요소로 설정한다.
이 연결을 통해서 사용자가 드래그할 때 해당 요소가 드래그 되고, 드롭할 때도 이 요소가 드롭 가능한 영역으로 인식된다.
List 생성
function DroppableArea({ items, setItems }) {
const moveItem = (fromIdx, toIdx) => {
const updateData = [...items];
const [movedItem] = updateData.splice(fromIdx, 1); // 드래그된 항목 제거
updateData.splice(toIdx, 0, movedItem); // 새로운 위치에 항목 삽입
setItems(updateData); // 리스트 상태 업데이트
}
return (
<div style={{ minHeight: '200px', padding: '16px', border: '1px dashed black' }}>
{items.map((item, index) => (
<DraggableItem
key={item.id}
id={item.id}
text={item.text}
index={index}
moveItem={moveItem}
/>
))}
</div>
);
}
드래그 앤 드롭을 적용시키는 핵심 부분인 리스트 영역이 필요하다.
가장 부모 컴포넌트 (DndPage) 에서 useState 를 통해 배열을 정의한 후, 그 배열을 넣을 DroppableArea 컴포넌트이다.
DroppableArea 컴포넌트에서는 부모에서 전달받는 배열(items)와 배열을 업데이트 하는 함수(setItems)를 받는다.
✏️ moveItem : 해당 함수는 드래그 된 항목을 새로운 위치로 이동시킴
✏️ fromIdx : 드래그 된 항목의 원래 인덱스
✏️ toIdx : 드래그 된 항목이 새롭게 놓일 인덱스
const updateData = [...items]; // 현재 항목들을 복사
현재 항목의 리스트를 복사해 updateData 배열을 생성한다. 원본 배열인 items 를 직접 수정하지 않기 위해 필요하다.
const [movedItem] = updateData.splice(fromIdx, 1); // 드래그된 항목 제거
fromIdx 에서 드래그된 항목을 제거하고, 제거된 항목을 movedItem 변수에 저장한다.
splice 메서드는 배열에서 항목을 제거하고 제거된 항목을 반환시킨다.
updateData.splice(toIdx, 0, movedItem); // 새로운 위치에 항목 삽입
toIdx 위치에 movedItem 을 삽입한다. 여기서, 2번째 인자 0은 항목을 삭제하지 않고 삽입만 시키게 한다.
{items.map((item, index) => (
<DraggableItem
key={item.id}
id={item.id}
text={item.text}
index={index}
moveItem={moveItem}
/>
))}
최종적으로 DraggabledItem 컴포넌트에 key, id, text, index, moveItem 을 전달한다.
✏️ key : 각 항목을 고유하게 식별하기 위한 고유값
✏️ id: 드래그 할 항목의 ID
✏️ text: 본인이 넣을 내용
✏️ index: 배열 내에서 항목의 인덱스
DndPage (부모컴포넌트)
export default function DndPage() {
const [items, setItems] = useState([
{ id: 1, text: 'Item 1' },
{ id: 2, text: 'Item 2' },
{ id: 3, text: 'Item 3' },
{ id: 4, text: 'Item 4' }, // 추가 항목 예시
]);
return (
<DndProvider backend={HTML5Backend}>
<DroppableArea items={items} setItems={setItems} />
</DndProvider>
);
}
최종적으로 부모 컴포넌트의 코드이다.
부모 컴포넌트에서는 useState 를 이용해 리스트 배열을 넘기고, 그 배열을 위에 작성한 것처럼 DroppabledArea 에 전달하면 된다.
적용한 리스트의 일부
마치며
코드를 계속 수정하면서 보다보니 리스트에 따라서 유연하게 코드를 수정해야하는 것 같다.
내 프로젝트에서 간단하게 테스트를 하는 코드와 프로젝트 내 테이블 코드에 적용할 때, 동일한 코드이지만 순서가 제대로 배치가 안되는 문제가 있었다. 따라서, 코드를 참고만 하고 본인이 진행하는 프로젝트에 유연하게 코드를 수정해 적용해야 할 것 같다.