[React] react-beautiful-dnd를 이용한Drag and Drop 구현

Park Bumsoo·2022년 6월 10일
9

프로젝트를 기획하며 예전에 보았던 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

1. 설치

작업환경으로는 next.js / node.js / yarn / javascript / typescript 를 이용하였으며

yarn add react-beautiful-dnd를 통해 dependencies에설치하였고, 타입스크립트를 사용하였기에
yarn add --dev @types react-beautiful-dnddevDependencies에 설치해주었다.

2. 라이브러리 이해

react-beautiful-dnd는 많은 기능들을 제공하지만
크게 3가지의 구조와 2가지의 함수로 나뉜다.

구조

DragDropContext

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

Droppable은 Drop이 일어나는(가능한) 영역이다.
이 영역에서 Item을 Drop할 경우에 DragDropContext 바인딩 된 onDragEnd={...}
함수가 동작하며 최종적인 Drag and Drop 동작에 대한 dom을 그려주기에
필수적으로 영역을 지정해줘야한다.

Droppable는 droppableId 를 필수적으로 입력해줘야하,
드롭될 영역의 고유 ID를 뜻한다.
아래 예시에서는 Array.map() 메서드를 사용하여 각가의 고유한 영역을 지정하였고,
각각의 영역이 받는 처음 값을 droppableId로 지정해놨다.(uuid를 활용해도 좋다.)
이 값은 string 형태의 값으로 변환해서 넣어주어야 한다.

providedprovided.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>

Draggable

Dragpable은 Drag가 일어나는 요소들으로 Droppable 영역안에서 움직을 요소들을 정해준다.
Drag이벤트가 시작되면 DragDropContext 바인딩 된 onDragStart={...}가 동작한다.

draggableId를 필수적으로 받아와야하는데 Droppable 영역에서는 DND가 일어날 영역의 고유값을 입력해 주었다면 Draggable에서는 움직일요소들이 가지는 고유값을 입력해 주어야한다.

Dragpable 역시 provided와 snapshot를 제공하며
providedprovided.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>
            )
          )}

함수

onDragStart (optional)

드래그가 시작되는 시점에 발동되는 라이브러리에서 제공하는 함수이며, optional 함수인 만큼
data 구조에 따라 선택적으로 실행하면 된다.

함수에는 initial이라는 값을 받아 올 수 있으며, 콘솔에 입력시 아래와 같은형태를 가진다.

souerce(...) 는 시작지점을 의미하며
droppabledId는 요소가 시작할 때의 영역에 대한 지정한 고유값이고,
index는 해당영역에서 Item이 영역의 Item들 중 몇번째 에서 출발했는지를 나타내준다.

위 사진에는 5개의 Item이 있으며 "2번 항목-3"을 옴기려 했을경우 위와 같은 결과가 나오는 것이다.

onDragEnd (required)

드래그가 끝나는 시점에 발동되는 함수로 위와 마찬가지로 라이브러리에서 제공되는 함수이다.
단 onDragStart와는 다르게 필수함수 이며 dnd의 동작에 가장 연관성이 깊은 함수이다.

해당 함수는 result라는 값을 받아오게 되는데, initial과는 다르게 추가된 부분이 존재한다.

드롭을 하는 순간 발생하는 순간이기에 목적지인 destination을 포함하고 있다.
droppabledId는 요소가 도착할 때의 영역에 대한 지정한 고유값이고,
index는 해당영역에서 Item이 영역의 Item들 중 몇번째 에 도착했는지를 나타내준다.

3. 활용

프로젝트에서는 두 가지의 DND를 구현 하였는데,
하나는 회원이 이용하여 DB에 저장을 해야하는 DND였고,
다른 하나는 비회원이 이용하는 체험판 부분이기에 session storage를 이용해 값을 1회성으로 관리한 DND이다.

그 중에서 session storage를 이용한 부분을 소개하고자 한다.

session storage를 이용한 Drag and Drop

script부분인 container파일이며 tsx로 작성되었으며,
폴더구조를 부모 자식의 형태로 나누었기에 전역 상태관리를 위한
RecoilState가 사용되었다.

동작함수 및 Drop 항목생성

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>
...

Drag 요소생성

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>
            )
          )}

결과

profile
프론트엔드 주니어 개발자(React, Next.js)

0개의 댓글