React & Library/React-beautiful-dnd

[React] React-beautiful-dnd 활용법 Basic

yoonjong Park 2023. 2. 3.

개요

React-beautiful-dnd (이하 rbd)는 zira로 유명한 atlassian에서 만든 오픈소스다. 디자인 시스템을 구축하는 팀에서 주로 작업하는 듯 하다. (오픈소스 운영자 트윗)

만약, zira를 써보았다면, 오픈소스를 사용하면서, 똑같은 느낌을 받을 수 있을 것이다. 비슷한 오픈소스들이 많았지만, zira의 점유율이 높아지면서, 자연스럽게 atlassian 에서 공개한 rbd 라이브러리도 점유율이 매우 높다. 같은 카테고리에서는 과반이상이 되어버린 것 같다.

사용하게 된 계기

nomad coder 수업을 수강하면서 알게되었다. api 구조가 매우 단순하면서도, 편하게 사용할 수 있게 되어 있다. 그래서 앞으로 Trello 형태의 UI를 구현해야하면, rbd를 계속 사용하게 될 것 같다.

소스코드 및 API

// atom.ts

import { atom } from "recoil";

export interface ITodo {
  id: number;
  text: string;
}

interface IToDoState {
  [key: string]: ITodo[];
}

export const toDoState = atom<IToDoState>({
  key: "toDo",
  default: {
    "To Do": [
      { id: 1, text: "hello" },
      { id: 2, text: "fake" },
    ],
    Doing: [],
    Done: [],
  },
});
// App.tsx

import React from "react";
import { DragDropContext, DropResult } from "react-beautiful-dnd";
import { useRecoilState } from "recoil";
import { toDoState } from "./atoms";

import styled from "styled-components";
import Board from "./Components/Board";

const Wrapper = styled.div`
  display: flex;
  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;
`;

function App() {
  const [toDos, setToDos] = useRecoilState(toDoState);

  const onDragEnd = (info: DropResult) => {
    const { destination, draggableId, source } = info;

    if (!destination) return;

    if (destination?.droppableId === source.droppableId) {
      // same board movement
      setToDos(allBoards => {
        const boardCopy = [...allBoards[source.droppableId]];
        const taskObj = boardCopy[source.index];
        boardCopy.splice(source.index, 1);
        boardCopy.splice(destination?.index, 0, taskObj);
        return {
          ...allBoards,
          [source.droppableId]: boardCopy,
        };
      });
    }

    if (destination?.droppableId !== source.droppableId) {
      // cross board movement
      setToDos(allBoards => {
        const sourceBoard = [...allBoards[source.droppableId]];
        const taskObj = sourceBoard[source.index];
        const destinationBoard = [...allBoards[destination.droppableId]];
        sourceBoard.splice(source.index, 1);
        destinationBoard.splice(destination?.index, 0, taskObj);
        return {
          ...allBoards,
          [source.droppableId]: sourceBoard,
          [destination.droppableId]: destinationBoard,
        };
      });
    }
  };

  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;

DragDropContext

전체의 이동을 트래킹해야 하기 때문에 상위 컴포넌트에 Context를 사용해야 한다.

onDragEnd

이동이 종료되었을 때(mouseOver) 동작하는 api. 별도로 함수를 정의해서 넣어주도록 하자.

이벤트의 결과로 전달받게 되는 값은 DropResult 안에 정의되어 있다. 그 중, { destination, draggableId, source } 이 두가지가 중요하다.

destination : 도착점 (drag 마침 정보 정보)
draggableId: 선택된 객체의 ID (in atom.ts)
source : 시작점 (drag 시작 정보)

// Board.tsx

import React, { useRef } from "react";
import { useForm } from "react-hook-form";
import { Droppable } from "react-beautiful-dnd";
import styled from "styled-components";
import DragabbleCard from "./DragabbleCard";
import { ITodo, toDoState } from "../atoms";
import { useSetRecoilState } from "recoil";

const Wrapper = styled.div`
  width: 300px;
  padding-top: 10px;
  background-color: ${props => props.theme.boardColor};
  border-radius: 5px;
  min-height: 300px;
  display: flex;
  flex-direction: column;
`;

const Title = styled.h2`
  text-align: center;
  font-weight: 600;
  margin-bottom: 10px;
  font-size: 18px;
`;

interface IAreaProps {
  isDraggingOver: boolean;
  isDraggingFromThis: boolean;
}

const Area = styled.div<IAreaProps>`
  background-color: ${props =>
    props.isDraggingOver
      ? "#dfe6e9"
      : props.isDraggingFromThis
      ? "#b2bec3"
      : "transparent"};
  flex-grow: 1;
  transition: background-color 0.3s ease-in-out;
  padding: 20px;
`;

const Form = styled.form`
  width: 100%;
  input {
    width: 100%;
  }
`;

interface IBoardProps {
  toDos: ITodo[];
  boardId: string;
}

interface IForm {
  toDo: string;
}

const Board = ({ toDos, boardId }: IBoardProps) => {
  const setToDos = useSetRecoilState(toDoState);
  const { register, handleSubmit, setValue } = useForm<IForm>();
  const onValid = ({ toDo }: IForm) => {
    const newToDo = {
      id: Date.now(),
      text: toDo,
    };
    setToDos(allBoards => {
      return {
        ...allBoards,
        [boardId]: [...allBoards[boardId], newToDo],
      };
    });
    setValue("toDo", "");
  };

  return (
    <Wrapper>
      <Title>{boardId}</Title>
      <Form onSubmit={handleSubmit(onValid)}>
        <input
          {...register("toDo", { required: true })}
          type="text"
          placeholder={`ADD task on ${boardId}`}
        />
      </Form>
      <Droppable droppableId={boardId}>
        {(magic, snapshot) => (
          <Area
            isDraggingOver={snapshot.isDraggingOver}
            isDraggingFromThis={Boolean(snapshot.draggingFromThisWith)}
            ref={magic.innerRef}
            {...magic.droppableProps}>
            {toDos.map((toDo, index) => {
              return (
                <DragabbleCard
                  key={toDo.id}
                  toDoId={toDo.id}
                  toDoText={toDo.text}
                  index={index}
                />
              );
            })}
            {magic.placeholder}
          </Area>
        )}
      </Droppable>
    </Wrapper>
  );
};

export default Board;

magic (provided)

생선된, 생성되는 카드의 정보

snapshot

카드가 이동 중일 때, 판단되는 정보

<Droppable />

카드가 모여있는 리스트를 의미한다. 각 보드별 ID(droppableId)를 이용해서 관리한다.

isDraggingOver : 도착하려는 보드에서 mouseOver 되는지 여부 판단
isDraggingFromThis : 출발하려는 보드에서 카드가 떠났는지 여부 판단

// DragabbleCard.tsx

import React, { memo } from "react";
import { Draggable } from "react-beautiful-dnd";
import styled from "styled-components";

const Card = styled.div<{ isDragging: boolean }>`
  border-radius: 5px;
  margin-bottom: 5px;
  padding: 10px;
  background-color: ${props =>
    props.isDragging ? "#0773E0" : props.theme.cardColor};
  box-shadow: ${props =>
    props.isDragging ? "0px 2px 5px rgba(0,0,0,0.05)" : "none"};
`;

const DragabbleCard = ({ toDoId, toDoText, index }: IDragabbleCardProps) => {
  return (
    <Draggable key={toDoId} draggableId={String(toDoId)} index={index}>
      {(magic, snapshot) => (
        <div
          isDragging={snapshot.isDragging}
          ref={magic.innerRef}
          {...magic.draggableProps}>
          <span {...magic.dragHandleProps}>😆</span>
          {toDoText}
        </div>
      )}
    </Draggable>
  );
};

export default memo(DragabbleCard);

isDragging : 카드의 이동 중 여부

카드는 꼭 memoizing 을 해주도록 하자. 전체 카드가 렌더링 되지 않도록 하기 위해서이다. (*다른 프로젝트에서도 리스트의 아이템은 반드시 memoizing을 하도록 하자.)

작업된 화면

결론

막상 사용해보면, 딱히 어려운 점은 없다. rbd example 을 보고, 내가 구현하고자 하는 것이 어떤 것인지 정확히 아는 게 더 중요하다.
그리고, css와 관련된 Library 특성상, css 작업과의 조합을 좀 더 잘하면, 더 나은 결과물을 얻을 수 있다.

프로그래밍적인 처리 (JavaScript로 하는 조작)는 onDragEnd 에서 대부분 조작하게 된다. 그마저도 본문에 설명한 것처럼 destination, source만 잘 조작하면 되기 때문에 크게 어려울 것은 없어보인다. 이 라이브러리의 목표자체가 복잡한 css animation 처리들을 좀 간편하게 지원해주고자 만들어진 경향이 더 크기 때문으로 본다.

그리고, 카드의 리스트는 당연하게도, id와 함께 관리하면, 좀 더 조작에 용이할 것으로 보인다.

이제 사용해보자. 조으니까...

추후과제

framer/motion 과의 연동을 해보면 어떨까 싶다.
리스트가 너무 유려해지는 부분이 있긴하겠지만, 더 앞서나가는 결과물을 얻게 될 것 같은 직감이 든다.

작성한 소스코드

https://github.com/yoonjong-park/make-boards-using-react-beautiful-dnd

참조

 

GitHub - atlassian/react-beautiful-dnd: Beautiful and accessible drag and drop for lists with React

Beautiful and accessible drag and drop for lists with React - GitHub - atlassian/react-beautiful-dnd: Beautiful and accessible drag and drop for lists with React

github.com

rbd example

 

Storybook

 

react-beautiful-dnd.netlify.app

https://nomadcoders.co/react-masterclass/

 

React JS 마스터클래스 – 노마드 코더 Nomad Coders

Typescript, Recoil, Framer Motion

nomadcoders.co

 

댓글