프로젝트를 기획하며 예전에 보았던 Drag and Drop이 매우 인상적이였기에 프로젝트에 이 기능을 추가하고 싶어 react-beautiful-dnd
라이브러리를 사용하여 DND를 만들어 보았다.
라이브러리여서 처음에는 '크게 어렵지 않을것이다.' 라고 생각을 했지만..
실제로는 '어떤 데이터들을 사용하는가?', 'DB에서 어떻게 관리되는가?' 등등 여러 사항에 따라 코드의 변동이 큰 라이브러리 이기에 DOCS를 읽고 동작원리를 이해 한 후에 부드러운 DND구현이 가능했다.
참고한 자료
NPM : https://www.npmjs.com/package/react-beautiful-dnd
GitHub : https://github.com/atlassian/react-beautiful-dnd
한국어DOCS : https://github.com/LeeHyungGeun/react-beautiful-dnd-kr
예제 : https://codesandbox.io/examples/package/react-beautiful-dnd
작업환경으로는 next.js / node.js / yarn / javascript / typescript 를 이용하였으며
yarn add react-beautiful-dnd
를 통해 dependencies
에설치하였고, 타입스크립트를 사용하였기에
yarn add --dev @types react-beautiful-dnd
를 devDependencies
에 설치해주었다.
react-beautiful-dnd는 많은 기능들을 제공하지만
크게 3가지의 구조와 2가지의 함수로 나뉜다.
DragDropContext은 Drag and Drop이 일어나는 전체영역이다.
Droppable, Dragpable 이 지정된 영역을 포함 하고 있어야하며,
동작의 핵심이 되는
onDragEnd={...}
,onDragStart={...}
두 가지의 함수를 바인딩 하기 때문에 반드시 설정해줘야 하는 부분이다.
예시)
import { DragDropContext} from "react-beautiful-dnd";
<DragDropContext
onDragEnd={props.handleDragEnd}
onDragStart={props.handleDragStart}
>
{props.categoriesData?.fetchProcessCategories.map(
(el: any, index: any) => (
<Droppable
droppableId={String(el.processCategoryId)}
key={index}
>
...
</Droppable>
)
}
<AddColumnBtn
projectId={props.projectData?.fetchProject.projectId}
/>
/DragDropContext>
Droppable은 Drop이 일어나는(가능한) 영역이다.
이 영역에서 Item을 Drop할 경우에 DragDropContext
바인딩 된 onDragEnd={...}
함수가 동작하며 최종적인 Drag and Drop 동작에 대한 dom을 그려주기에
필수적으로 영역을 지정해줘야한다.
Droppable는 droppableId
를 필수적으로 입력해줘야하,
드롭될 영역의 고유 ID를 뜻한다.
아래 예시에서는 Array.map()
메서드를 사용하여 각가의 고유한 영역을 지정하였고,
각각의 영역이 받는 처음 값을 droppableId
로 지정해놨다.(uuid를 활용해도 좋다.)
이 값은 string 형태의 값으로 변환해서 넣어주어야 한다.
provided는 provided.innerRef
를 참조하여 동작을 실행하는 매개변수 이기에
반드시 들어가야하는 사항이며
snapshot은 동작시 dom 이벤트에 대하여 적용될 style 참조를 뜻한다.
선택적항목이다.
예시)
import { Droppable } from "react-beautiful-dnd";
<DragDropContext
onDragEnd={props.handleDragEnd}
onDragStart={props.handleDragStart}
>
{props.categoryName?.map((el: string[], index: number) => (
<Droppable droppableId={el[0]} key={index}>
{(provided, snapshot) => (
<div ref={provided.innerRef} {...provided.droppableProps}>
<ExperienceSMAFDetail
key={index}
el={el}
index={index}
scheduleArray={props.scheduleArray}
/>
{provided.placeholder}
</div>
)}
</Droppable>
))}
<S.AddcolumnBtn>
항목추가
<S.AddCoulumnIcon
onClick={props.AddColumn}
src="/detailPage/addcolumn.png"
></S.AddCoulumnIcon>
</S.AddcolumnBtn>
</DragDropContext>
Dragpable은 Drag가 일어나는 요소들으로 Droppable 영역안에서 움직을 요소들을 정해준다.
Drag이벤트가 시작되면 DragDropContext
바인딩 된 onDragStart={...}
가 동작한다.
draggableId를 필수적으로 받아와야하는데 Droppable 영역에서는 DND가 일어날 영역의 고유값을 입력해 주었다면 Draggable에서는 움직일요소들이 가지는 고유값을 입력해 주어야한다.
Dragpable 역시 provided와 snapshot를 제공하며
provided는 provided.innerRef
를 참조하여 동작을 실행하는 매개변수 이기에
반드시 들어가야하는 사항이며
snapshot은 동작시 dom 이벤트에 대하여 적용될 style 참조를 뜻한다.
선택적항목이다.
예시)
import { Draggable } from "react-beautiful-dnd";
{props.scheduleArray?.[props.categoryIndex]?.map(
(el: string, index: number) => (
<Draggable key={el} index={index} draggableId={el}>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.dragHandleProps}
{...provided.draggableProps}
>
<ExperiencePlanCard
key={index}
el={el}
index={index}
number={index + 1}
categoryNum={props.categoryIndex + 1}
/>
</div>
)}
</Draggable>
)
)}
드래그가 시작되는 시점에 발동되는 라이브러리에서 제공하는 함수이며, optional 함수인 만큼
data 구조에 따라 선택적으로 실행하면 된다.
함수에는 initial
이라는 값을 받아 올 수 있으며, 콘솔에 입력시 아래와 같은형태를 가진다.
souerce(...)
는 시작지점을 의미하며
droppabledId는 요소가 시작할 때의 영역에 대한 지정한 고유값이고,
index는 해당영역에서 Item이 영역의 Item들 중 몇번째 에서 출발했는지를 나타내준다.
위 사진에는 5개의 Item이 있으며 "2번 항목-3"을 옴기려 했을경우 위와 같은 결과가 나오는 것이다.
드래그가 끝나는 시점에 발동되는 함수로 위와 마찬가지로 라이브러리에서 제공되는 함수이다.
단 onDragStart와는 다르게 필수함수 이며 dnd의 동작에 가장 연관성이 깊은 함수이다.
해당 함수는 result
라는 값을 받아오게 되는데, initial과는 다르게 추가된 부분이 존재한다.
드롭을 하는 순간 발생하는 순간이기에 목적지인 destination
을 포함하고 있다.
droppabledId는 요소가 도착할 때의 영역에 대한 지정한 고유값이고,
index는 해당영역에서 Item이 영역의 Item들 중 몇번째 에 도착했는지를 나타내준다.
프로젝트에서는 두 가지의 DND를 구현 하였는데,
하나는 회원이 이용하여 DB에 저장을 해야하는 DND였고,
다른 하나는 비회원이 이용하는 체험판 부분이기에 session storage를 이용해 값을 1회성으로 관리한 DND이다.
그 중에서 session storage를 이용한 부분을 소개하고자 한다.
session storage를 이용한 Drag and Drop
script부분인 container파일이며 tsx로 작성되었으며,
폴더구조를 부모 자식의 형태로 나누었기에 전역 상태관리를 위한
RecoilState가 사용되었다.
recoil 부분
// useEffect() 동작을 위한 부분이기에 Triger이라는 이름을 사용하였다.
export const sessionTriger = atom({
key: "sessionTriger",
default: false
})
script 부분
import ExperienceSMAFHTML from "./expericence.presenter";
import { useState, useEffect } from "react";
import { useRecoilState } from "recoil";
import { sessionTriger } from "../../../../../commons/store/index";
import { DropResult } from "react-beautiful-dnd";
export default function ExperienceSMAF() {
const [categoryName, setCategoryName] = useState<string[][]>([]);
const [session] = useRecoilState(sessionTriger);
const [scheduleArray, setScheduleArray] = useState<Array<string[]>>([[]]);
const [categorys, setCategorys] = useState<string[]>([]);
const [schedules, setSchedules] = useState<string[]>([]);
const [restoreItem, setRestoreItem] = useState<string>();
// 새로 페이지에 들어올 경우 비회원 대상이기에 초기화를 위한 부분이다.
useEffect(() => {
setCategoryName([]);
}, []);
// 데이터를 만들며, sessionStorage에 값을 저장해준다.
useEffect(() => {
DragAndDropData();
sessionStorage.setItem("Column", [...categoryName]);
}, [categoryName, session]);
// 항목을 추가하는 부분
const AddColumn = () => {
// eslint-disable-next-line no-array-constructor
setCategoryName([
...categoryName,
new Array(1).fill(`${Number(1 + categoryName.length)}번 항목`),
]);
};
// DragAndDrop에 사용할 데이터를 만드는 함수이다.
// useEffect를 활용하여 최초 랜더링시, Drop영역생성, Drag요소생성 의 경우 실행된다.
// drag and drop에 사용할 데이터를 상태값으로 저장하는 과정이다.
const DragAndDropData = () => {
const planNum = Number(categoryName.length);
const dataArray: string[][] = [];
const sesstionList = [];
// sessionStorage에 각각 다른 저장공간을 만들기에 for문을 사용해 값을 담아왔다.
for (let i = 1; i <= planNum; i++) {
if (sessionStorage.getItem(`Plan${i}`) !== "") {
sesstionList.push(
[sessionStorage.getItem(`Plan${i}`)].join("").split(",")
);
}
}
// for문과 push에 의하여 2차원 배열의 형태가 된 요소들을 평탄화 시키고,
const schedulesList = sesstionList.flat();
// sessionStorage에서 항목(Droppable가 될 data)를 불러와 배열 형태로 만들었다.
const categoryList: string[] = [sessionStorage.getItem("Column")]
.join("")
.split(",");
// 위 두 데이터에서 forEach와 filter를 사용하여 drag요소들이
// 본인에 맞는 배열에 할당될 수 있게끔 하여 다시 정렬된 2차원 배열을 만들었다.
categoryList?.forEach((category: string) => {
const element = schedulesList?.filter(
(el: string) => String(el.slice(0, 5)) === String(category)
);
dataArray.push(element);
});
// 데이터들을 setState를 통해 state 상태값으로 저장해주었다.
setScheduleArray(dataArray);
setCategorys(categoryList);
setSchedules(schedulesList);
};
// drag동작시 동작하는 함수로 drag되는 아이템(restoreItem)을 이후
// handleDragEnd 에 사용하기 위해 setState의 동작 원리를 고려해 미리 정의를 해주었다.
const handleDragStart = async (initial: { draggableId: string }) => {
const restoreItemArray: string[] = [];
const schedulesList = schedules;
// eslint-disable-next-line array-callback-return
schedulesList.filter((el: string) => {
if (el === initial?.draggableId) {
restoreItemArray.push(el);
}
});
setRestoreItem(restoreItemArray.join(""));
console.log(initial, "initial");
};
// drop시 동작하는 함수로 미리 만들어 놓은 이차원 배열의 형태를 가지는 데이터를 조정하는 부분이다.
// 원본배열을 변환해주는 Array.splice() 메서드의 특징과 앞서만든 restoreItem을 사용해 데이터를 조정하였다.
const handleDragEnd = async (result: DropResult) => {
// Drop 장소가 영역이 아닐경우 초기화 시켜주는 부분
if (!result?.destination) return;
// 앞서만든 restoreItem을 이용해 데이터의 위치를 변경하는 부분
try {
scheduleArray.forEach((el: string[]) => {
if (el.includes(String(restoreItem))) {
const saveItem = el.splice(el.indexOf(String(restoreItem)), 1)[0];
categorys.forEach((category: string, index: number) => {
if (
result?.destination !== undefined &&
result?.destination.droppableId === category
) {
scheduleArray[index].splice(
Number(result?.destination.index),
0,
// @ts-ignore
saveItem
);
}
});
}
});
setScheduleArray(scheduleArray);
} catch (error) {
alert(error);
}
};
return (
<ExperienceSMAFHTML
AddColumn={AddColumn}
categoryName={categoryName}
handleDragStart={handleDragStart}
handleDragEnd={handleDragEnd}
scheduleArray={scheduleArray}
/>
);
}
HTML 부분
UI적인 요소가 많기에 DragDropContext
만 가져왔다.
...
<DragDropContext
onDragEnd={props.handleDragEnd}
onDragStart={props.handleDragStart}
>
{props.categoryName?.map((el: string[], index: number) => (
<Droppable droppableId={el[0]} key={index}>
{(provided, snapshot) => (
<div ref={provided.innerRef} {...provided.droppableProps}>
<ExperienceSMAFDetail
key={index}
el={el}
index={index}
number={index}
scheduleArray={props.scheduleArray}
/>
{provided.placeholder}
</div>
)}
</Droppable>
))}
<S.AddcolumnBtn>
항목추가
<S.AddCoulumnIcon
onClick={props.AddColumn}
src="/detailPage/addcolumn.png"
></S.AddCoulumnIcon>
</S.AddcolumnBtn>
</DragDropContext>
...
script 부분
import ExperienceSMAFDetailHTML from "./experienceSMAFDetail.presenter";
import { useEffect, useState } from "react";
import { sessionTriger } from "../../../../../../commons/store/index";
import { useRecoilState } from "recoil";
import { ExperienceSMAFDetailProps } from "./experienceSMAFDetail.types";
export default function ExperienceSMAFDetail(props: ExperienceSMAFDetailProps) {
const [planCardName, setPlanCardName] = useState<string[][]>([]);
const [, setSession] = useRecoilState(sessionTriger);
// 리랜더시 값을 초기화 하는 부분
useEffect(() => {
setPlanCardName([]);
}, []);
// 새로운 Drag요소를 만들 때 sessionStorage에 추가하며 동작 함수에 신호를 주는 부분
useEffect(() => {
sessionStorage.setItem(`Plan${props.number + 1}`, [...planCardName]);
setSession((prev) => !prev);
}, [planCardName]);
// Drag요소를 생성하는 붑누
const AddPalnCard = () => {
// eslint-disable-next-line no-array-constructor
setPlanCardName([
...planCardName,
new Array(1).fill(`${props.el[0]}-${planCardName.length + 1}`),
]);
};
return (
<ExperienceSMAFDetailHTML
categoryName={props.el[0]}
categoryIndex={props.number}
AddPalnCard={AddPalnCard}
planCardName={planCardName}
scheduleArray={props.scheduleArray}
/>
);
}
HTML부분
{props.scheduleArray?.[props.categoryIndex]?.map(
(el: string, index: number) => (
<Draggable key={el} index={index} draggableId={el}>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.dragHandleProps}
{...provided.draggableProps}
>
// 데이터로 css및 R 작업을 하는 부분이라 생략
<ExperiencePlanCard
key={index}
el={el}
index={index}
number={index + 1}
categoryNum={props.categoryIndex + 1}
/>
</div>
)}
</Draggable>
)
)}