react-beautiful-dnd
drag and drop을 직접 구현하는 것도 방법도 있고
react에서는 react-dnd라는 훌륭한 라이브러리도 있습니다. github.com/react-dnd/react-dnd
그러나 react-beautiful-dnd가 워낙 편하고 예뻐서 이걸 써야겠다고 생각하게 되었습니다. github.com/atlassian/react-beautiful-dnd
패키지는 크게 3가지 컴포넌트로 구성되어 있습니다. 명칭만 읽어도 어떤 역할인지 이해할 수 있을 것입니다.
설치
yarn add react-beautiful-dnd
우선 간단히 세 컴포넌트를 알아보자
DragDropContext에서 onDragEnd는 필수 prop입니다.
onDragStart, onDragUpdate, onDragEnd 콜백이 자주 사용되는 편입니다.
export interface DragDropContextProps {
onBeforeCapture?(before: BeforeCapture): void;
onBeforeDragStart?(initial: DragStart): void;
onDragStart?(initial: DragStart, provided: ResponderProvided): void;
onDragUpdate?(initial: DragUpdate, provided: ResponderProvided): void;
onDragEnd(result: DropResult, provided: ResponderProvided): void;
children: React.ReactNode | null;
dragHandleUsageInstructions?: string;
nonce?: string;
enableDefaultSensors?: boolean;
sensors?: Sensor[];
}
아래 같이 사용할 수 있습니다.
<DragDropContext onDragEnd={onDragEnd}>
{state.columnOrder.map((columnId) => {
const column = state.columns[columnId];
const tasks = column.taskIds.map((taskId) => state.tasks[taskId]);
return <Column key={column.id} column={column} tasks={tasks} />;
})}
</DragDropContext>
Droppable에서는 droppableId가 필수 요소입니다. droppable을 식별하기 위한 식별자인거죠.
export interface DroppableProps {
droppableId: DroppableId;
type?: TypeId;
mode?: DroppableMode;
isDropDisabled?: boolean;
isCombineEnabled?: boolean;
direction?: Direction;
ignoreContainerClipping?: boolean;
renderClone?: DraggableChildrenFn;
getContainerForClone?: () => React.ReactElement<HTMLElement>;
children(provided: DroppableProvided, snapshot: DroppableStateSnapshot): React.ReactElement<HTMLElement>;
}
Droppable에서 주의할 것은 child가 함수 형태여야 한다는 것입니다.
위의 inteface를 살펴보시면, children은 (provided, snapshot) => React.ReactElement 꼴의 함수임을 알 수 있습니다. 아래와 같이 말이죠.
그리고 provided는 예시와 같이 전부 할당해주도록 합시다. dnd가 작동하기 위해 필요한 props가 들어 있습니다.
물론 필요에 따라 일부만 사용할수도 있지만 여기서는 기초적인 사용법을 살펴보는 것이니 다 넣어줍시다.
<Container>
<Title>{column.title}</Title>
<Droppable droppableId={column.id}>
{(provided) => (
<TaskList {...provided.droppableProps} ref={provided.innerRef}>
{tasks.map((task, index) => (
<Task key={task.id} task={task} index={index} />
))}
{provided.placeholder} // 필수임. draggable한 영역을 만들어내야 하므로
</TaskList>
)}
</Droppable>
</Container>
실제로 드래그할 수 있는 Draggable은 필수 props이 좀 많습니다.
여기서도 children을 함수 형태로 넘겨야 합니다. 인자는 provided, snapshow, rubric이 있군요.
export type DraggableChildrenFn = (
provided: DraggableProvided,
snapshot: DraggableStateSnapshot,
rubric: DraggableRubric,
) => React.ReactElement<HTMLElement>;
export interface DraggableProps {
draggableId: DraggableId;
index: number;
children: DraggableChildrenFn;
isDragDisabled?: boolean;
disableInteractiveElementBlocking?: boolean;
shouldRespectForcePress?: boolean;
}
어쨌거나 아래와 같이 구성해줄 수 있습니다. provided를 다 넣어줍시다.
<Draggable draggableId={task.id} index={index}>
{(provided) => (
<Container
{...provided.draggableProps}
{...provided.dragHandleProps}
ref={provided.innerRef}
>
{task.content}
</Container>
)}
</Draggable>
dnd 이후 draggable 컴포넌트의 순서를 유지하기 (onDragEnd 사용)
dnd 이후 draggable 컴포넌트의 순서를 유지하기 위해서는 DragDropContext의 onDragEnd 메서드를 이용해야 합니다.
onDragEnd에는 두 인자가 있음. result와 provided.
onDragEnd(result: DropResult, provided: ResponderProvided): void;
result는 DropResult라는 interface 형이어야 하는데, extends가 제법 복잡하다. 알아보고 싶으면 직접 index.d.ts 를 사렾보자. 여튼, 정리하자면 다음과 같다.
여기서 주의깊게 보아야할 속성은 source와 destination이다. source는 현재 위치한 droppable의 위치와 인덱스, destination은 dnd를 마친 후의 droppable의 위치와 인덱스를 나타낸다.
const result = {
draggableId
type
reason: 'DROP' | 'CANCEL';
source: {droppableId, index}
// may not have any destination (drag to nowhere)
destination: {droppableId, index}
// populated when a draggable is dragging over another in combine mode
combine: {droppableId, index}
}
결론적으로는 다음과 같이 onDragEnd를 구성하면 되겠다.
const onDragEnd = (result) => {
// destination이 끝 위치, source가 시작 위치를 의미함
const { destination, source, draggableId } = result;
// dnd를 도중에 멈췄으므로(올바른 droppable 위에 두지 않았으므로) 그냥 리턴
if (!destination) {
return;
}
// 같은 자리에 가져다 두었다면 그냥 리턴
if (
destination.droppableId === source.droppableId &&
destination.index === source.index
) {
return;
}
// 시작한 source의 droppable 위치
const column = state.columns[source.droppableId];
// 새로이 만들어진 해당 컬럼의 task를 array 형태로 반환
const newTaskIds = Array.from(column.taskIds);
// 해당 array를 splic해서 새로 넣는 작업
newTaskIds.splice(source.index, 1);
newTaskIds.splice(destination.index, 0, draggableId);
// 새로운 컬럼
const newColumn = {
...column,
taskIds: newTaskIds,
};
// 기존 state와 새롭게 바뀐 정보를 넣어 새 state로 만듦
const newState = {
...state,
columns: {
...state.columns,
[newColumn.id]: newColumn,
},
};
setstate(newState);
};
snapshot을 이용해 드래그 하는 도중 디자인 바꾸기
draggable의 children 함수의 번째 인자는 snapshot이며 아래와 같은 속성들이 존재합니다.
그래서 draggablestatesnapshot으로 이름이 붙은 거죠.
export interface DraggableStateSnapshot {
isDragging: boolean;
isDropAnimating: boolean;
dropAnimation?: DropAnimation;
draggingOver?: DroppableId;
// the id of a draggable that you are combining with
combineWith?: DraggableId;
// a combine target is being dragged over by
combineTargetFor?: DraggableId;
// What type of movement is being done: 'FLUID' or 'SNAP'
mode?: MovementMode;
}
droppable의 children 함수의 2번째 인자는 snapshot이며 아래와 같은 속성들이 존재합니다.
그래서 이름도 droppablestatesnapshot입니다.
export interface DroppableStateSnapshot {
isDraggingOver: boolean;
draggingOverWith?: DraggableId;
draggingFromThisWith?: DraggableId;
isUsingPlaceholder: boolean;
}
draggable의 isDragging이 true일 때 draggable의 배경색을 바꾸도록 만들어보았습니다.
const Container = styled.div`
border: 1px solid lightgrey;
border-radius: 2px;
padding: 8px;
margin-bottom: 8px;
background-color: ${(props) => (props.isDragging ? "lightgreen" : "white")};
`;
export default function Task({ key, task, index }) {
return (
<Draggable draggableId={task.id} index={index}>
{(provided, snapshot) => (
<Container
{...provided.draggableProps}
{...provided.dragHandleProps}
ref={provided.innerRef}
isDragging={snapshot.isDragging} // 드래그 중일 때의 스타일링을 위해 snapshot 속성을 외부로 가져옴
>
{task.content}
</Container>
)}
</Draggable>
);
}
이번에는 droppable에 draggable을 올렸을 때 배경색이 바뀌도록 해보았습니다. isDraggingOver가 참이면 색을 바꾸면 됩니다.
... 중략
const TaskList = styled.div`
padding: 8px;
background-color: ${(props) => (props.isDraggingOver ? "skyblue" : "white")};
`;
export default function Column({ key, column, tasks }) {
return (
<Container>
<Title>{column.title}</Title>
<Droppable droppableId={column.id}>
{(provided, snapshot) => (
<TaskList
{...provided.droppableProps}
ref={provided.innerRef}
isDraggingOver={snapshot.isDraggingOver}
>
{tasks.map((task, index) => (
<Task key={task.id} task={task} index={index} />
))}
{provided.placeholder}
</TaskList>
)}
</Droppable>
</Container>
);
}
특정 부분을 통해서만 draggable하게 만들고 싶은 경우(provided.dragHandleProps)
아래 부분에서 주황색 네모를 잡고 드래그하게 만들고 싶은 경우
draggable의 provided.dragHandleProps를 다른 해당 부분에 할당하면 됩니다.
전에 Container에 provided.dragHandleProps를 넣어줬던 것을, Handle이라는 다른 컴포넌트에 넣어주었습니다.
export default function Task({ key, task, index }) {
return (
<Draggable draggableId={task.id} index={index}>
{(provided, snapshot) => (
<Container
{...provided.draggableProps}
ref={provided.innerRef}
isDragging={snapshot.isDragging} // 드래그 중일 때의 스타일링을 위해 snapshot 속성을 외부로 가져옴
>
<Handle {...provided.dragHandleProps} />
{task.content}
</Container>
)}
</Draggable>
);
}
여러 Droppable을 운용해보기
DragDropContext에서의 onDragEnd 로직을 수정함으로서 여러 Droppable간 이동도 사용할 수 있습니다.
function App() {
const [state, setstate] = useState(() => initialData);
const onDragEnd = (result) => {
// destination이 끝 위치, source가 시작 위치를 의미함
const { destination, source, draggableId } = result;
// dnd를 도중에 멈췄으므로(올바른 droppable 위에 두지 않았으므로) 그냥 리턴
if (!destination) {
return;
}
// 같은 자리에 가져다 두었다면 그냥 리턴
if (
destination.droppableId === source.droppableId &&
destination.index === source.index
) {
return;
}
// 시작한 source의 droppable 위치
const start = state.columns[source.droppableId];
const finish = state.columns[destination.droppableId];
// 한 droppable 내에서 움직이는 로직. 간단함
if (start === finish) {
// 새로이 만들어진 해당 컬럼의 task를 array 형태로 반환
const newTaskIds = Array.from(start.taskIds);
// 해당 array를 splic해서 새로 넣는 작업
newTaskIds.splice(source.index, 1);
newTaskIds.splice(destination.index, 0, draggableId);
// 새로운 컬럼
const newColumn = {
...start,
taskIds: newTaskIds,
};
// 기존 state와 새롭게 바뀐 정보를 넣어 새 state로 만듦
const newState = {
...state,
columns: {
...state.columns,
[newColumn.id]: newColumn,
},
};
setstate(newState);
return;
}
// 다른 droppable로 옮기기
const startTaskIds = Array.from(start.taskIds);
startTaskIds.splice(source.index, 1);
const newStart = {
...start,
taskIds: startTaskIds,
};
const finishTaskIds = Array.from(finish.taskIds);
finishTaskIds.splice(destination.index, 0, draggableId);
const newFinish = {
...finish,
taskIds: finishTaskIds,
};
const newState = {
...state,
columns: {
...state.columns,
[newStart.id]: newStart,
[newFinish.id]: newFinish,
},
};
setstate(newState);
};
... 이하 컴포넌트 생략
특정 draggable을 드래그 금지 시키기
draggable에 isDragDisabled 속성을 달아줌으로서 드래그를 막을 수 있습니다.
<Draggable
draggableId={task.id}
index={index}
isDragDisabled={task.id === "task-1"}
>
{(provided, snapshot) => ( .. 중략
UX를 위해서 드래그가 불가능한 속성은 아래와 같이 스타일링을 다르게 해줘야 합니다.
const Container = styled.div`
border: 1px solid lightgrey;
border-radius: 2px;
padding: 8px;
margin-bottom: 8px;
background-color: ${(props) =>
props.isDragDisabled
? "lightgrey"
: props.isDragging
? "lightgreen"
: "white"};
`;
export default function Task({ key, task, index }) {
return (
<Draggable
draggableId={task.id}
index={index}
isDragDisabled={task.id === "task-1"}
>
{(provided, snapshot) => (
<Container
{...provided.draggableProps}
{...provided.dragHandleProps}
ref={provided.innerRef}
isDragging={snapshot.isDragging} // 드래그 중일 때의 스타일링을 위해 snapshot 속성을 외부로 가져옴
isDragDisabled={task.id === "task-1"} // 스타일링을 위해
>
특정 droppable만 사용하도록 하기
두 가지 방법이 있습니다.
1. droppable의 type 활용하기
같은 type 끼리만 drop할 수 있습니다. 아래 경우에는 column-3만 done이고 나머지는 active이므로 active끼리만 drop할 수 있습니다.
type의 이름은 마음대로 지으셔도 됩니다.
export default function Column({ key, column, tasks }) {
return (
<Container>
<Title>{column.title}</Title>
<Droppable
droppableId={column.id}
type={column.id === "column-3" ? "done" : "active"}
>
{(provided, snapshot) => ( ... 중략)
2. isDroppDisable
true로 설정되면 drop할 수 없습니다.
세로 말고 가로축 dnd
CSS는 마음대로 하시면 됩니다.
Droppable의 direction을 horizontal로만 바꿔주면 됩니다.
<Droppable droppableId={column.id} direction="horizontal">
* Next.js에서 한 번 렌더된 후 draggable이 동작하지 않는 경우
The resetServerContext function should be used when server side rendering (SSR).
It ensures context state does not persist across multiple renders on the server which would result in client/server markup mismatches after multiple requests are rendered on the server.
SSR이 돌기전에 resetServerContext해주면 됩니다.
github.com/atlassian/react-beautiful-dnd/blob/master/docs/api/reset-server-context.md
특정 페이지에서 reset을 해줘도 괜찮고, document에서 해줘도 괜찮습니다.
여기저기에서 dnd를 쓸 예정이라면 _document에서 해주는게 편하겠죠.
import { resetServerContext } from 'react-beautiful-dnd'
...
static getInitialProps({ renderPage }) {
resetServerContext()
}
...
render () { ...
github.com/atlassian/react-beautiful-dnd/issues/1854
참고)
egghead에 있는 무료 강의. 클래스 컴포넌트로 진행하고, deprecated된 내용도 있다.
필자는 함수형 컴포넌트로 다 바꿔놓았고 최근 내용으로 revision했으니 필자의 github를 참고해도 좋을 것이다.
github.com/DarrenKwonDev/beautiful-dnd-functional-revision.git
교보재
github.com/eggheadio-projects/Beautiful-and-Accessible-Drag-and-Drop-with-react-beautiful-dnd-notes