프런트엔드가 아름답게 잘 구축된 웹사이트에서 Drag & drop이 구현 된 경우를 흔히 볼 수 있을 것이다.
React에서도 이를 편하게 구현할 수 있는 패키기지가 있는데, react-beautiful-dnd라는 패키지이다.
다음과 같이 react 프로젝트에 추가를 해준다
npm install react-beautiful-dnd
npm 페이지에 자세하게 설명나와 있지만 Droppable은 특정 아이템을 "드랍"할 수 있는 리스트의 개념이고, Draggable은 해당 리스트에 "드래그"되어 드랍되는 아이템이라고 생각하면 간단하다.
위 이미지에서 Finn 그리고 Princess bubblegum의 리스트가 Dropppable이라고 보면 되고 그 리스트 안에 들어있는 메세지 "Don't you always call sweatpanst 'give up on life pants,' Jake?" 라는 항목이 Draggable이라고 생각하면 된다.
먼저 Drag& Drop을 사용하기 위해서 index.ts에 있는 StrictMode를 제거해준다.
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<RecoilRoot>
<ThemeProvider theme={darkTheme}>
<App/>
</ThemeProvider>
</RecoilRoot>
</React.StrictMode>
);
이와 같이 render하는 부분에서 React.StrictMode가 있는것을 아래와 같이 지워준다
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<RecoilRoot>
<ThemeProvider theme={darkTheme}>
<App/>
</ThemeProvider>
</RecoilRoot>
);
그 후 App.tsx를 다음과 같이 수정해준다.
import React from 'react';
import {DragDropContext, Draggable, Droppable} from "react-beautiful-dnd";
function App() {
const onDragEnd = () => {};
return (
<DragDropContext onDragEnd={onDragEnd}>
<div>
<Droppable droppableId="one">
{(provided) => (
<ul ref={provided.innerRef}{...provided.droppableProps}>
<Draggable draggableId="first" index={0}>
{(provided) => (
<li ref={provided.innerRef} {...provided.draggableProps}>
<span {...provided.dragHandleProps}>🔥</span>
One
</li>
)}
</Draggable>
<Draggable draggableId="second" index={1}>
{(provided) => <li ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps}>🔥Two</li>}
</Draggable>
</ul>
)}
</Droppable>
</div>
</DragDropContext>
);
}
export default App;
여기서 Droppable과 Draggable의 child는 반드시 함수여야 하는데, provided이라는 prop를 전달해줘야 하기 때문이다.
Draggable에서 유저가 클릭할 수 있는 부분을 지정해주는 함수로 위의 코드를 본다면 첫번째 건은 span을 추가해 해당 이모티콘에만 dragHandleProps를 주고 두번째건은 따로 span을 추가하지 않고 텍스트 전체에 dragHandleProps를 준것을 볼 수 있다.
그리고 해당 코드를 실행해 본다면 첫번째건은 이모티콘이 있는 부분을 선택해야만 드래그를 할 수 있고, 두번째는 텍스트 전체를 선택해 드래그를 할 수 있는것을 볼 수 있다.
드래그를 한 결과값이 어때야 한다고 알려주는 함수이다.
먼저 recoil로 다음과 같은 atom을 생성해준다.
import {atom} from "recoil";
export const toDoState = atom({
key: "toDo",
default: ["a", "b", "c", "d", "e", "f"],
});
그리고 useRecoilState을 통해 해당 atom의 값을 갖고 오고 수정하도록한다.
const [toDos,setToDos] = useRecoilState(toDoState);
const onDragEnd = ({ draggableId, destination, source }: DropResult) => {
if (!destination) return;
setToDos((oldToDos) => {
const toDosCopy = [...oldToDos];
// 1) Delete item on source.index
toDosCopy.splice(source.index, 1);
// 2) Put back the item on the destination.index
toDosCopy.splice(destination?.index, 0, draggableId);
return toDosCopy;
});
};
위 코드를 보면 우선 toDo들을 복사해 toDosCopy라는 변수에 저장한 후, source 인덱스 즉 유저가 드래그하려고 선택한 인덱스에 있는 값을 splice함수로 recoil에 담아둔 리스트에서 제거를 해준다.
그 후 유저가 해당 항목을 드랍할 index 즉 destination.index에 해당 draggableId 값을 넣어주는 함수이다.
해당 함수를 적용해준다면 유저가 드래그하고 드랍한 값으로 리스트가 유지가 되는 것을 볼 수 있다.
여러개의 보드를 도입하기 위해 App.tsx와 atoms.tsx를 다음과 같이 변경하겠다
import React from 'react';
import {DragDropContext, Draggable, Droppable, DropResult} from "react-beautiful-dnd";
import styled from "styled-components";
import {useRecoilState} from "recoil";
import {toDoState} from "./atoms";
import Board from "./Components/Board";
const Wrapper = styled.div`
display: flex;
max-width: 480px;
width: 100vw;
margin: 0 auto;
justify-content: center;
align-items: center;
height: 100vh;
`;
const Boards = styled.div`
display: flex;
justify-content: center;
align-items: flex-start;
width: 100%;
gap:10px;
grid-template-columns: repeat(3, 1fr);
`;
function App() {
const [toDos, setToDos] = useRecoilState(toDoState);
const onDragEnd = (info: DropResult) => {
console.log(info);
const { destination, draggableId, source } = info;
// when there is no destination just return
if(!destination) return;
// same board movement.
if (destination?.droppableId === source.droppableId) {
setToDos((allBoards) => {
const boardCopy = [...allBoards[source.droppableId]];
boardCopy.splice(source.index, 1);
boardCopy.splice(destination?.index, 0, draggableId);
return {
...allBoards,
[source.droppableId]: boardCopy,
};
});
}
//different board movement
if(destination.droppableId !== source.droppableId){
setToDos((allBoards)=>{
const sourceBoard = [...allBoards[source.droppableId]];
const targetBoard = [...allBoards[destination.droppableId]];
sourceBoard.splice(source.index,1);
targetBoard.splice(destination?.index,0,draggableId);
return{
...allBoards,
[source.droppableId]:sourceBoard,
[destination.droppableId]:targetBoard,
}
})
};
};
return (
<DragDropContext onDragEnd={onDragEnd}>
<Wrapper>
<Boards>
{Object.keys(toDos).map((boardId) => (
<Board boardId={boardId} key={boardId} toDos={toDos[boardId]} />
))}
</Boards>
</Wrapper>
</DragDropContext>
);
}
export default App;
import {atom} from "recoil";
interface IToDoState {
[key: string]: string[];
}
export const toDoState = atom<IToDoState>({
key: "toDo",
default: {
"To Do":["a", "b"],
"Doing":["c", "d"],
"Done":["e", "f"],
},
});
atom 파일에서 toDo의 ID를 "To Do", "Doing", "Done"으로 하는 3개의 스트링 리스트를 기본 값으로 설정해주고
App.tsx에서 recoil을 통해 가져온 toDoState atom에서 boardId를 기반으로 Board라는 객체를 랜더링 해주는데 Board 객체는 다음과 같다
import {Droppable} from "react-beautiful-dnd";
import React from "react";
import styled from "styled-components";
import DraggableCard from "./DraggableCard";
const Wrapper = styled.div`
padding: 20px 10px;
padding-top: 10px;
width: 300px;
background-color: ${(props) => props.theme.boardColor};
border-radius: 5px;
min-height: 300px;
`;
const Title = styled.h2`
text-align: center;
font-weight: 600;
margin-bottom: 10px;
font-size: 18px;
`;
interface IBoardProps {
toDos: string[];
boardId: string;
}
function Board({ toDos, boardId }: IBoardProps) {
return (
<Wrapper>
<Title>{boardId}</Title>
<Droppable droppableId={boardId}>
{(provided) => (
<div ref={provided.innerRef} {...provided.droppableProps}>
{toDos.map((toDo, index) => (
<DraggableCard key={toDo} index={index} toDo={toDo} />
))}
{provided.placeholder}
</div>
)}
</Droppable>
</Wrapper>
);
}
export default Board;
Board에서는 기존 App.tsx에서 랜더링하던 Droppable 컴포넌트를 그리는 부분이다.
App.tsx에서 onDragEnd함수에서 한 보드에서 다른 보드로 옮기는 작업을 한다.
우선 destination이 없는 경우에는 그냥 return을 해준다.
그리고 destination의 droppableId가 선택한(source)의 droppableId와 같을 경우에는 해당 보드만 boardCopy라는 변수에 복사를 해주고 해당 droppableComponent를 splice해서 제거를 해주고, 다시 splice 함수를 사용해 원하는 index에 넣어주면 된다.
이 후 나머지 보드들은 그대로 return하고 변경이 발생한 보드는 변경된 이전에 복사했던 변수를 반환해주면 된다.
다른 보드로 이동을 할 경우도 splice함수를 사용하는 것은 똑같은데, 먼저 이동을 할 항목이 있는 보드를 sourceBoard라는 변수에 복사를 해주고 옮기려는 보드는 targetBoard라는 변수에 저장해준다.
그 후 이동할 보드에서 해당 toDo를 splice로 제거하고 targetboard에 마찬가지로 splice 함수로 넣어주면 된다.
그 후 함수를 반환할때 변경이 없는 보드는 그대로, 선택이 이루어진 보드들은 각각 복사된 값인 sourceBoard와 targetBoard를 반환해준다.