다들 한번씩은 만들어보는 TODO 리스트.
Drag and Drop으로 구현해보는 것이 FE 개발자로서의 로망 아닌가 싶다.
그렇다면 한번 뽀개보도록 하자!
우선 라이브러리를 통해서 어떻게 구현하고 어떻게 동작하는지를 알고
라이브러리 없이 한번 구현해보도록 하자.
야너두 할 수 있어!
바쁘신 분들은 바로 최종 코드로 🚀
TL;DR
create beautiful drag and drop for lists that anyone can use.
Atlassian에서 만든 React DND 라이브러리이다.
Trello, Jira를 만든 회사이니 근본력은 설명 필요없이 그저 믿고 쓰면 된다!
https://github.com/atlassian/react-beautiful-dnd
예시도 많고 github star도 많지만 개인적으로 공식문서는 조금 진입장벽이 있었다.
친절하지만 '그래서 어떻게 해?'하는 느낌?..이었다.
우선 내가 생각한 공식문서의 핵심을 정리해보았다.
TL;DR
라이브러리를 구성하는데 생각보다 삽질을 많이 했다 🥲
- strictMode 끄기
- requestAnimationFrame 실행 후 컴포넌트 마운트하기
우선, 라이브러리를 설치하도록 하자.
yarn add react-beautiful-dnd
yarn add -D @types/react-beautiful-dnd
아직 React18과 아직 호환이 잘 안된다.
https://github.com/atlassian/react-beautiful-dnd/issues/2399#issuecomment-1111169234
https://github.com/atlassian/react-beautiful-dnd/issues/2350
React18에서는 strictMode를 꺼서 사용하라고 권장하고 있다.
분명히 시키는 대로 했는데 droppable-id
를 계속 인식하지 못한다는 에러가 떴었다.
진짜 미치는 줄 같았다.. 😡
next.config.js
에서 이를 쉽게 설정할 수 있다.
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: false,
swcMinify: true,
};
module.exports = nextConfig;
그리고 requestAnimationFrame이 호출된 이후에 DND 컴포넌트를 렌더링하자.
잘은 모르겠지만, 브라우저가 부드러운 애니메이션을 렌더링할 준비되고 앱을 실행한다고 생각하고 넘어가면 될 것 같다.
https://github.com/atlassian/react-beautiful-dnd/issues/2399#issuecomment-1175638194
const [enabled, setEnabled] = useState(false);
useEffect(() => {
const animation = requestAnimationFrame(() => setEnabled(true));
return () => {
cancelAnimationFrame(animation);
setEnabled(false);
};
}, []);
if (!enabled) {
return null;
}
3가지 컴포넌트를 활용하여 DND가 이뤄진다.
컴포넌트 이름 | 설명 |
---|---|
DragDropContext | ContextAPI의 Provider처럼 DND의 상태를 제공해주는 녀석이라고 보면 될 것 같다. 여기서 onDragEnd 등 다양한 DND 이벤트를 등록 해준다. |
Droppable | DROP할 수 있는 영역으로 명시한다. |
Draggable | DRAG할 수 있게 컴포넌트를 감싸준다. |
이름이 좀 헷갈리지만 아주 깔끔한 것 같다.
그렇다면, 스타일 없이 아주 간단한 페이지를 만들어 동작을 살펴보도록 하자. 코드는 깔끔하지 않다..
쉽게 커스텀할 수 있기 위해서인지 제공되는 API가 좀 복잡하다.
이를 잘 구분하고 본인 프로젝트에 맞게 조립해서 사용해야 한다.
import { useEffect, useState } from 'react';
import { DragDropContext, Draggable, Droppable, DropResult } from 'react-beautiful-dnd';
export default function App() {
// --- Mock 데이터
const items = [...Array(4)].map((_, i) => ({ id: `${i}${i}${i}`, content: `item-${i}` }));
// --- Draggable이 Droppable로 드래그 되었을 때 실행되는 이벤트
const onDragEnd = ({ source, destination }: DropResult) => {
console.log('>>> source', source);
console.log('>>> destination', destination);
};
// --- requestAnimationFrame 초기화
const [enabled, setEnabled] = useState(false);
useEffect(() => {
const animation = requestAnimationFrame(() => setEnabled(true));
return () => {
cancelAnimationFrame(animation);
setEnabled(false);
};
}, []);
if (!enabled) {
return null;
}
// --- requestAnimationFrame 초기화 END
return (
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="droppable">
{(provided) => (
<div ref={provided.innerRef} {...provided.droppableProps}>
{items.map((item, index) => (
<Draggable key={item.id} draggableId={item.id} index={index}>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
{item.content}
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
);
}
체크 포인트
key
와 droppableId
, draggableId
는 같아야 한다!Droppable
의 provided
와 Draggable
의 provided
는 다른 녀석임을 유의해야 한다.아래 내용들은 그냥 넘어가도 무방합니다.
<Droppable droppableId="droppable">
{(provided, snapshot) => (
<div ref={provided.innerRef} {...provided.droppableProps}>
{...}
{provided.placeholder}
</div>
)}
</Droppable>
droppableId
ex) todo, doing, done
provided
provided.innerRef
provided.droppableProps
props
를 라이브러리에서 사용할 수 있는 형태로 DOM data에 등록시켜주는 것 같다.provided.placeholder
snapshot
snapshot.isDraggingOver
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
style={{ background: snapshot.isDraggingOver ? 'red' : 'blue' }}
>
{...}
{provided.placeholder}
</div>
)}
draggingOverWith
draggingFromThisWith
isUsingPlaceholder
{items.map((item, index) => (
<Draggable key={item.id} draggableId={item.id} index={index}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
{item.content}
</div>
)}
</Draggable>
))}
draggableId
index
⛔️ [1,2,4,8]
provided
provided.innerRef
provided.draggableProps
provided.dragHandleProps
data-rbd-drag-handle-draggable-id
data-rbd-drag-handle-context-id
aria-labelledby
tabIndex
draggable
onDragStart
snapshot
snapshot.isDragging
provided.draggableProps.style
도 넣어주는 것을 잃지 말자.{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
style={{
backgroundColor: snapshot.isDragging ? 'blue' : 'grey',
...provided.draggableProps.style,
}}
>
{item.content}
</div>
)}
snapshot.isDropAnimating
snapshot.dropAnimation
snapshot.draggingOver
snapshot.combineWith
snapshot.combineTargetFor
snapshot.mode
보면 source
, destination
가 인자로 전달 받는다.
이상한 곳으로 드래그하면 destination
이 null
로 응답을 받는다.
여기서 특이한 점은 draggableId
는 없고 index
와 droppableId
만 있다.
이것을 활용해 우리가 알아서 상태를 변경시켜야 한다.
splice
를 활용하면 이를 쉽게 구현할 수 있다.
const [items, setItems] = useState(
[...Array(4)].map((_, i) => ({ id: `${i}${i}${i}`, content: `item-${i}` })),
);
const onDragEnd = ({ source, destination }: DropResult) => {
if (!destination) return;
// 깊은 복사
const _items = JSON.parse(JSON.stringify(items)) as typeof items;
// 기존 아이템 뽑아내기
const [targetItem] = _items.splice(source.index, 1);
// 기존 아이템을 새로운 위치에 삽입하기
_items.splice(destination.index, 0, targetItem);
// 상태 변경
setItems(_items);
};
라이브러리에서는 어떻게 엘리먼트에 이동하는 효과를 줄까?
position: fixed
를 두고 절대적 위치를 지정해준다.transition: translate(x, y)
스타일을 수정해준다.transition: translate(0, y)
를 줘서 움직이게 한다.onDropEnd
에서 인자 바탕으로 우리가 DOM을 조작해줘야 한다.스타일링하는 원리는 단순한데 내부적으로 위치를 계산하는 로직은 뭔가 복잡할 것 같다..
마크업 하단에 보면 helptext가 숨겨져 있는 것을 볼 수 있다.
아마 스크린리더에게 도움을 주기 위함인 것 같다.
친절하게 어떤 DND 동작을 수행했는지 알려주며 키보드로 어떻게 동작을 시킬 수 있는지 알려준다.
그래서 덕분에 키보드로 DND를 하게 되었다!!
tab
으로 원하는 엘리먼트 접근 한다.space
로 해당 엘리먼트를 잡는다.space
를 눌러 해당 엘리먼트를 놓다 준다. 만약 esc
를 누르면 동작이 취소 된다.역시 대기업!!
서론이 참 길었다...
이제 본격적으로 TODO 칸반을 만들어 보자!!
먼저 기존 상태를 정의해보도록 하자.
export type TItemStatus = 'todo' | 'doing';
export type TItem = {
id: string;
status: TItemStatus;
title: string;
};
export type TItems = {
[key in TItemStatus]: TItem[];
};
export default function TodoPage() {
const [items, setItems] = useState<TItems>({
todo: [...Array(5)].map((_, i) => ({
id: `${i}${i}${i}`,
title: `Title ${i + 1}000`,
status: 'todo',
})),
doing: [],
});
return (
<>
<TodoLibraryExample items={items} setItems={setItems} />
</>
);
}
개발 편의상 아래와 같은 자료형을 넘겨주도록 하자.
{
todo: [],
doing: [],
}
todo
, doing
별로 Droppable
보드를 만들어 주고
각 보드마다 대응되는 Draggable
리스트를 만들어 준다.
<DragDropContext>
<div className="...">
{Object.keys(items).map((key) => (
<Droppable key={key} droppableId={key}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
>
{items[key as TItemStatus].map((item, index) => (
<Draggable key={item.id} draggableId={item.id} index={index}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<h5>{item.title}</h5>
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
))}
</div>
</DragDropContext>
Drop 가능한 보드위에 Drag시 찐한 shadow를 추가해준다.
<Droppable key={key} droppableId={key}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
className={'기존 스타일 ' + (snapshot.isDraggingOver ? 'shadow-lg' : 'shadow')}
>
...
</div>
>
</Droppable>
Drag시 해당 아이템에 찐한 shadow를 추가해준다.
<Draggable key={item.id} draggableId={item.id} index={index}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className={
'기존 스타일 ' +
(snapshot.isDragging
? 'bg-opacity-90 shadow-2xl shadow-gray-400'
: 'shadow')
}
>
...
</div>
</Draggable>
splice는 배열에서 아이템 첨삭할 때 유용한 친구이다.
꼭 직접 다뤄보길 바란다.
const onDragEnd = ({ source, destination }: DropResult) => {
// 유효하지 않는 곳으로 drag시 이벤트를 종료한다.
if (!destination) return;
// 타입스크립트 호환되도록 타입을 지정해준다.
const scourceKey = source.droppableId as TItemStatus;
const destinationKey = destination.droppableId as TItemStatus;
// 깊은 복사
const _items = JSON.parse(JSON.stringify(items)) as typeof items;
// drag를 시작한 리스트에서 drag한 아이템을 뺀다.
const [targetItem] = _items[scourceKey].splice(source.index, 1);
// drop되는 리스트에 알맞는 위치에 아이템을 추가해준다.
_items[destinationKey].splice(destination.index, 0, targetItem);
// 상태를 변경해준다.
setItems(_items);
};
return <>
<DragDropContext onDragEnd={onDragEnd}>
...
</DragDropContext>
</>
스타일은 tailwind css를 활용했다.
./page/todo.tsx
import TodoLibraryExample from '@/components/todo/TodoLibraryExample';
import { useState } from 'react';
export type TItemStatus = 'todo' | 'doing';
export type TItem = {
id: string;
status: TItemStatus;
title: string;
};
export type TItems = {
[key in TItemStatus]: TItem[];
};
export default function TodoPage() {
const [items, setItems] = useState<TItems>({
todo: [...Array(5)].map((_, i) => ({
id: `${i}${i}${i}`,
title: `Title ${i + 1}000`,
status: 'todo',
})),
doing: [],
});
return (
<>
<TodoLibraryExample items={items} setItems={setItems} />
</>
);
}
./components/todo/TodoLibraryExample.tsx
import { TItems, TItemStatus } from '@/pages/todo';
import { $ } from '@/utils';
import { useEffect, useState } from 'react';
import { DragDropContext, Draggable, Droppable, DropResult } from 'react-beautiful-dnd';
export default function TodoLibraryExample({
items,
setItems,
}: {
items: TItems;
setItems: (items: TItems) => void;
}) {
const onDragEnd = ({ source, destination }: DropResult) => {
if (!destination) return;
const scourceKey = source.droppableId as TItemStatus;
const destinationKey = destination.droppableId as TItemStatus;
const _items = JSON.parse(JSON.stringify(items)) as typeof items;
const [targetItem] = _items[scourceKey].splice(source.index, 1);
_items[destinationKey].splice(destination.index, 0, targetItem);
setItems(_items);
};
// --- requestAnimationFrame 초기화
const [enabled, setEnabled] = useState(false);
useEffect(() => {
const animation = requestAnimationFrame(() => setEnabled(true));
return () => {
cancelAnimationFrame(animation);
setEnabled(false);
};
}, []);
if (!enabled) {
return null;
}
// --- requestAnimationFrame 초기화 END
return (
<div className="p-4">
<div className="mb-2">
<h1 className="text-3xl font-bold">react-beautiful-dnd</h1>
<span>with react library</span>
</div>
<div className="mt-4 flex">
<DragDropContext onDragEnd={onDragEnd}>
<div className="grid flex-1 select-none grid-cols-2 gap-4 rounded-lg">
{Object.keys(items).map((key) => (
<Droppable key={key} droppableId={key}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
className={$(
'flex flex-col gap-3 rounded-xl bg-gray-200 p-4 ring-1 ring-gray-300 transition-shadow dark:bg-[#000000]',
snapshot.isDraggingOver ? 'shadow-lg' : 'shadow',
)}
>
<span className="text-xs font-semibold">{key.toLocaleUpperCase()}</span>
{items[key as TItemStatus].map((item, index) => (
<Draggable key={item.id} draggableId={item.id} index={index}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className={$(
'rounded-lg bg-white p-4 transition-shadow dark:bg-[#121212]',
snapshot.isDragging
? 'bg-opacity-90 shadow-2xl shadow-gray-400'
: 'shadow',
)}
>
<h5 className="font-semibold">{item.title}</h5>
<span className="text-sm text-gray-500">Make the world beatiful</span>
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
))}
</div>
</DragDropContext>
</div>
</div>
);
}
생각보다 코드 길이는 짧다 ✨✨
키보드로 조작한다면?
다음 세션에서 이를 라이브러리 없이 구현해보자!!
참고 자료
실제 동작은 아래 링크에서 볼 수 있습니다.
전체 코드는 아래 깃허브 링크에서 살펴보면 됩니다.
글 이전