최근에 Drag and Drop(DnD) 기능을 직접 만들기를 시도했었다.
하지만 생각보다 고려해야 할 사항들이 많았고, 그렇게 삽질해서 만들어진 결과물은 완성도가 너무나도 부족했다...😂
싸늘하다.. 가슴에 비수가 날아와 꽂힌다..
하지만 걱정하지마라. 라이브러리는 우리보다 빠르니까.
결국 기존에 작업하던 코드를 삭제하고 구글링을 통해 참고할만한 글을 찾던 중 DnD 기능을 제공해주는 라이브러리가 있다는 것을 알게 되었다.
그래서 오늘은 라이브러리로 DnD 기능을 구현하는 예제를 만들어보려고 한다.
0. DnD Library
먼저 React 환경에서 DnD 기능을 제공해주는 라이브러리는 대표적으로 3개가 있다.
라이브러리 각각의 특징을 간단하게 정리하면 다음과 같다.
- react-draggable
react-draggable은 드래그로 아이템 간의 순서를 변경하는 기능보다, 윈도우즈에서 윈도우를 드래그해서 위치를 옮기는 기능에 강점이 있는 라이브러리라고 한다.
오늘 우리가 구현할 기능은 전자이기 때문에 해당 라이브러리는 후보에서 제외했다.
- react-dnd
react-dnd는 Drag and Drop 기능을 사용할 수 있게 해주는 hook을 제공해주는 라이브러리다.
해당 라이브러리 Overview에 따르면 react-dnd 라이브러리 내부에서 자체적으로 redux를 사용한다. 그렇기 때문에 react-dnd 개념 중 일부는 flux와 redux 아키텍쳐와 유사하다고 한다.
- react-beautiful-dnd
react-beautiful-dnd는 react-dnd에서 조금 더 확장된 느낌으로 UI/UX 또는 퍼포먼스가 좋은 동작이 미리 정의되어있다는 것이 react-dnd와의 차이점이자 특징이다.
이처럼 좀 더 편리한 기능을 제공해주기 때문에 react-dnd보다 용량이 약 2배 많다.
필자는 간단한 예제를 구현하여 라이브러리 사용방법의 감을 잡은 후, 개인 프로젝트에 적용할 예정이기 때문에 커스터마이징의 폭이 넓을 것이라 판단되는 react-dnd를 선택했다.
1. Project Setting
본격적으로 예제 만들기에 앞서 프로젝트 세팅을 해주자.
1-1. Setting
# create next app with typescript
$ npx create-next-app@latest react-dnd --typescript
# react-dnd를 사용하기 위해 필요한 라이브러리 install
$ npm i react-dnd react-dnd-html5-backend
필자는 next.js 프레임워크를 기반으로 하는 app을 만들었다.
프로젝트명은 react-dnd, 언어는 typescript로 세팅했다.
1-2. Project Structure
src
┣ common
┃ ┣ contants.ts
┃ ┣ tasks.ts
┃ ┗ types.ts
┣ components
┃ ┣ Column.tsx
┃ ┗ MovableItem.tsx
┣ pages
┃ ┣ index.tsx
┃ ┣ _app.tsx
┃ ┗ _document.tsx
┗ styles
┗ App.css
2. React-DnD Example
이 글에서 사용되는 예제는 아래의 글을 참고하여 작성되었다.
해당 글에서는 React.js를 기반으로 하고, 20년도에 작성된 글이다보니 코드의 업데이트가 필요했다.
따라서 라이브러리의 최신 버전으로 필자의 세팅 환경에 맞게 코드를 수정하며 작성했다.
2-0. App.css
.container {
display: flex;
flex-direction: row;
justify-content: space-around;
}
.column {
height: 600px;
width: 200px;
margin: 20px;
border-radius: 10px;
box-shadow: 1px 1px 6px rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
flex-wrap: wrap;
}
.first-column {
background-color: #f5ffea;
}
.second-column {
background-color: #fffbf5;
}
.movable-item {
border-radius: 10px;
background-color: #fff3f3;
height: 100px;
width: 170px;
margin: 10px auto;
display: flex;
justify-content: center;
align-items: center;
box-shadow: 0px 0px 3px rgba(0, 0, 0, 0.5);
}
예제가 너무 못생기지 않게 간단한 CSS 스타일을 추가한다.
2-1. index.tsx
import { useState } from "react";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { tasks } from "@/common/tasks";
import { COLUMN_NAMES } from "@/common/contants";
import MovableItem from "@/components/MovableItem";
import Column from "@/components/Column";
const App = () => {
const [items, setItems] = useState(tasks);
const moveCardHandler = (dragIndex: number, hoverIndex: number) => {
const dragItem = items[dragIndex];
if (dragItem) {
setItems((prevState) => {
const coppiedStateArray = [...prevState];
const prevItem = coppiedStateArray.splice(hoverIndex, 1, dragItem);
coppiedStateArray.splice(dragIndex, 1, prevItem[0]);
return coppiedStateArray;
});
}
};
const returnItemsForColumn = (columnName: string) => {
return items
.filter((item) => item.column === columnName)
.map((item, index) => (
<MovableItem
key={item.id}
name={item.name}
setItems={setItems}
index={index}
moveCardHandler={moveCardHandler}
/>
));
};
const { DO_IT, IN_PROGRESS, AWAITING_REVIEW, DONE } = COLUMN_NAMES;
return (
<div className="container">
<DndProvider backend={HTML5Backend}>
<Column title={DO_IT} className="column first-column">
{returnItemsForColumn(DO_IT)}
</Column>
<Column title={IN_PROGRESS} className="column second-column">
{returnItemsForColumn(IN_PROGRESS)}
</Column>
<Column title={AWAITING_REVIEW} className="column first-column">
{returnItemsForColumn(AWAITING_REVIEW)}
</Column>
<Column title={DONE} className="column second-column">
{returnItemsForColumn(DONE)}
</Column>
</DndProvider>
</div>
);
};
export default App;
홈 화면에 나타나는 page 전체 코드다.
짚고 넘어가야 하는 코드별로 나눠서 살펴보자.
2-1-1. moveCardHandler
...
const moveCardHandler = (dragIndex: number, hoverIndex: number) => {
const dragItem = items[dragIndex];
// 1. 사용자가 item을 드래그하고 있다면
if (dragItem) {
setItems((prevState) => {
// 2. 기존의 데이터(prevState)를 새로운 변수에 복사한다.
const coppiedStateArray = [...prevState];
// 3. splice로 hoverIndex 위치부터 1개의 데이터를 제거한 후,
// 삭제한 index 위치에 현재 드래그하고 있는 item 데이터를 넣는다.
// -> 삭제된 요소들의 배열은 prevItem 변수에 저장된다.
const prevItem = coppiedStateArray.splice(hoverIndex, 1, dragItem);
// 4. 3번과 마찬가지의 과정을 거친 후
coppiedStateArray.splice(dragIndex, 1, prevItem[0]);
// 5. coppiedStateArray 배열을 return
return coppiedStateArray;
});
}
};
...
사용자가 드래그하고 있는 item을 handling하는 함수다.
dragIndex
와 hoverIndex
를 매개변수로 받는다.
dragIndex
를 통해 현재 사용자가 드래그하고 있는 item을 특정하고,
hoverIndex
를 통해 현재 사용자가 어떤 item 위에 마우스를 올려두고 있는지 판단한다.
위 splice 과정에 대한 설명이 이해되지 않는다면 아래 글을 참고하여 splice에 대해 이해하고 오는것을 추천한다.
2-1-2. returnItemsForColumn
...
const returnItemsForColumn = (columnName: string) => {
return items
.filter((item) => item.column === columnName)
.map((item, index) => (
<MovableItem
key={item.id}
name={item.name}
setItems={setItems}
index={index}
moveCardHandler={moveCardHandler}
/>
));
};
...
매개변수로 받는 columnName
을 가지고 있는 item을 MovableItem
컴포넌트로 return해주는 함수다.
MovableItem
에 대해서는 조금 뒤에 살펴보자.
2-1-3. return
...
return (
<div className="container">
<DndProvider backend={HTML5Backend}>
<Column title={DO_IT} className="column first-column">
{returnItemsForColumn(DO_IT)}
</Column>
<Column title={IN_PROGRESS} className="column second-column">
{returnItemsForColumn(IN_PROGRESS)}
</Column>
<Column title={AWAITING_REVIEW} className="column first-column">
{returnItemsForColumn(AWAITING_REVIEW)}
</Column>
<Column title={DONE} className="column second-column">
{returnItemsForColumn(DONE)}
</Column>
</DndProvider>
</div>
);
...
App
컴포넌트의 return 값으로, 여기서 살펴봐야 할 것은 DndProvider
이다.
DndProvider
는 DndProvider
안에 구성 요소들을 래핑함으로써 드래그와 드롭이 가능하게 해주는 역할을 한다.
따라서 만약 DnD 기능을 사용하고 싶은 요소가 있다면, 해당 요소를 DndProvider
에 래핑해줘야 한다.
또한 backend
props에 HTML5Backend
를 넘겨줘야 하는것도 잊지말자.
2-2. Column.tsx
import { useDrop } from "react-dnd";
import { ITEM_TYPE } from "@/common/contants";
import { ColumnProps } from "@/common/type";
const Column = ({ children, className, title }: ColumnProps) => {
const [, drop] = useDrop({
accept: ITEM_TYPE,
drop: () => ({ name: title }),
});
return (
<div ref={drop} className={className}>
{title}
{children}
</div>
);
};
export default Column;
부모 컴포넌트에서 받는 props 중 title에 따라 column을 만들어주는 컴포넌트다.
여기서부터 React-DnD에서 제공해주는 함수인 useDrop
이 사용된다.
useDrag
, useDrop
에 대해서는 MovableItem
컴포넌트에서 둘 다 사용하기 때문에 해당 파트에서 같이 알아보자.
2-3. MovableItem.tsx
import { useRef } from "react";
import { useDrag, useDrop } from "react-dnd";
import { ItemState } from "@/common/types";
import { COLUMN_NAMES, ITEM_TYPE } from "@/common/contants";
const MovableItem = ({ name, index, moveCardHandler, setItems }: any) => {
const changeItemColumn = (currentItem: any, columnName: string) => {
setItems((prevState: ItemState[]) =>
prevState.map((e: ItemState) => {
return {
...e,
column: e.name === currentItem.name ? columnName : e.column,
};
})
);
};
const ref = useRef<HTMLDivElement>(null);
// 마우스가 hover item 높이의 절반을 넘을 경우에만 이동을 수행하도록 설계
// - 아래로 드래그할 때 커서가 50% 이하일 때만 이동
// - 위로 드래그할 때 커서가 50% 이상일 때만 이동
const [, drop] = useDrop({
accept: ITEM_TYPE,
hover(item: { index: number; name: string }, monitor: any) {
if (!ref.current) {
return;
}
const dragIndex = item.index;
const hoverIndex = index;
if (dragIndex === hoverIndex) {
return;
}
const hoverBoundingRect = ref.current?.getBoundingClientRect();
const hoverMiddleY =
(hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
const clientOffset = monitor.getClientOffset();
const hoverClientY = clientOffset.y - hoverBoundingRect.top;
// 아래로 드래깅
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
return;
}
// 위로 드래깅
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
return;
}
moveCardHandler(dragIndex, hoverIndex);
item.index = hoverIndex;
},
});
const [{ isDragging }, drag] = useDrag({
type: ITEM_TYPE,
item: { index, name },
end: (item, monitor) => {
const dropResult: any = monitor.getDropResult();
if (dropResult) {
const { name } = dropResult;
const { DO_IT, IN_PROGRESS, AWAITING_REVIEW, DONE } = COLUMN_NAMES;
switch (name) {
case DO_IT:
changeItemColumn(item, DO_IT);
break;
case IN_PROGRESS:
changeItemColumn(item, IN_PROGRESS);
break;
case AWAITING_REVIEW:
changeItemColumn(item, AWAITING_REVIEW);
break;
case DONE:
changeItemColumn(item, DONE);
break;
}
}
},
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
});
const opacity = isDragging ? 0.4 : 1;
drag(drop(ref));
return (
<div ref={ref} className="movable-item" style={{ opacity }}>
{name}
</div>
);
};
export default MovableItem;
MovableItem
컴포넌트의 핵심은 react-dnd의 useDrag
와 useDrop
이다.
2-3-1. useDrag, useDrop
두 함수는 moniter, collect라는 react-dnd의 고유 개념을 가지는데, 이는 DnD 이벤트에 따라 변화되는 state를 반환하는 것이 특징이다.
isDragging
: item을 드래깅 중이라면 true, 아니면 false를 return
drag, drop
: useRef 처럼 작동한다. 드래그 또는 드랍될 요소에 ref를 선언해주면 된다.
type, accept
: 앞으로 드래깅 될 아이템이 어떤 타입인지를 선언해준다. useDrag
에는 type, useDrop
에는 accept를 선언해야 하고 이 둘(type, accept)은 서로 같아야 연동된다.
item
: 선언한 타입의 드래깅되는 물체 안에 넣어줄 정보를 선언한다.
2-3-2. Element.getBoundingClientRect()
해당 코드에서 useDrop 함수에서는 마우스의 hover 위치에 따라 이벤트가 발생하도록 설계되어 있다.
여기서 마우스의 위치를 계산하기 위해서 사용된 메서드가 getBoundingClientRect()
이다.
getBoundingClientRect()
는 DOMRect 요소의 크기와 브라우저 뷰포트에 상대적인 위치에 대한 정보를 제공하는 객체를 반환한다.
반환 값으로는 top, bottom, left, right, x, y, width, height가 있다.
더 자세한 사항은 아래 링크를 통해 확인해보길 바란다.
2-4. Common
해당 폴더에는 constants, tasks, types를 모아뒀다.
constants : 상수들 모음
tasks: 더미 데이터
types: 타입 선언
2-4-1. constants.ts
export const COLUMN_NAMES = {
DO_IT: "Do it",
IN_PROGRESS: "In Progress",
AWAITING_REVIEW: "Awaiting review",
DONE: "Done",
};
export const ITEM_TYPE = "BOARD_VIEW";
2-4-2. tasks.ts
import { COLUMN_NAMES } from "./contants";
const { DO_IT } = COLUMN_NAMES;
export const tasks = [
{ id: 1, name: "Item 1", column: DO_IT },
{ id: 2, name: "Item 2", column: DO_IT },
{ id: 3, name: "Item 3", column: DO_IT },
{ id: 4, name: "Item 4", column: DO_IT },
];
2-4-3. types.ts
export interface ItemState {
id: number;
name: string;
column: string;
}
export interface ColumnProps {
children: JSX.Element[];
className: string;
title: string;
}
3. 완성
위 과정에서 문제없이 여기까지 왔다면 이제 결과물을 확인해볼 차례이다.
자~ 지금부터 확인 들어가겠습니다잉~
그런데 뭔가 이상하다. 눈치가 빠르신 분들은 뭔가 이상함을 느꼈을 것이다.
gif 초반에는 item들의 위치가 잘 바뀌지만, 마지막에 item들을 다시 모으는 장면을 보면 item들의 순서가 엉뚱하게 나열되는 것을 확인할 수 있다.
하지만 오늘 우리가 목표로 했던 것은 DnD 기능을 구현하는 것이기 때문에 조금 찝찝하지만 목표는 이루었다고 생각한다. 😋
그렇다고 이대로 넘어갈수도 없기에 필자는 해당 기능을 수정해서 다시 찾아오겠다.
독자분들도 해당 기능을 수정해보면 공부에 도움이 될 것이라 생각한다.
마치며
react-dnd를 사용해보기 전에는 라이브러리니까 사용 방법만 알면 금방 다룰수 있을거라 생각했지만, 아주 큰 착각이었다. 사용 방법을 이해하려면 해당 메서드에 입력해야 하는 각각의 요소들이 어떤 역할을 하는지 알아야 했다. 이 과정에서 많이 헤맸는데, 사실 아직까지 제대로 이해하지 못한 부분이 있다. 따라서 react-dnd 2편을 작성할 계획이고, 2편에서는 제대로 이해해서 좀 더 자세하고 정확하게 설명해보려 한다.
※
해당 블로그는 개인적으로 작성한 코드를 정리 및 복습을 목적으로 글을 작성하고 있습니다.
글에서 잘못된 부분이나 오타가 있다면 댓글로 알려주세요. 최대한 빠르게 피드백 후 반영하겠습니다 :)
부족한 부분이 있더라도 너그럽게 이해해주시면 감사하겠습니다. 🙇♂️
'FE > Next.js' 카테고리의 다른 글
[Next.js] 시계, input 텍스트 입력시 구글에서 검색되게 만들기 (2) | 2022.12.16 |
---|---|
[Next.js] Todo List 만들기 (0) | 2022.11.30 |