본문 바로가기
Next.Js/Learn

[React] Drag & Drop 기능 구현하기 (react-beautiful-dnd example)

by 3.dev 2023. 9. 11.
반응형

구현해야 하는 기능 중 드래그 & 드롭 기능이 필요해졌다.

 

관련 라이브러리를 여러가지 찾다보니 React-dnd에

애니메이션이 첨가된 React-beautiful-dnd라이브러리를 사용하게 됐다.

 

그래서 오늘은 간단하게 기능을 구현해보고 라이브러리를 체득해보자

 

설치

먼저 프로젝트에 설치부터 해보자.

npm install react-beautiful-dnd

참고로 프로젝트는 React Next.js를 사용해서 구현해볼 것이다.

 

react-beautiful-dnd를 사용하려면 두가지 설정을 필수로 해야한다.

  • strictMode끄기
  • requestAnimationFrame실행 후 랜더링하기

프로젝트 루트에 next.config.js에서 strictMode를 아래와 같이 꺼주면 되며

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: false,
}

module.exports = nextConfig

 

구현하기 전에 dnd가 적용 될 컴포넌트에 작성되어야 할 requestAnimationFrame 셋팅

import { useEffect, useState } from "react";
import { styled } from "styled-components";

export default function App(props) {
  // requestAnimationFrame 실행여부
  const [enabled, setEnabled] = useState(false);

  useEffect(() => {
	// 컴포넌트 랜더링 하기 전 requestAnimationFrame 실행
    const animation = requestAnimationFrame(() => setEnabled(true));

    return () => {
      // requestAnimationFrame 정지
      cancelAnimationFrame(animation);
      setEnabled(false);
    };
  }, []);

  // requestAnimationFrame 실행 이후 랜더링
  if (enabled)
    return (
    <div>
    	드래그앤드롭
    </div>
  );
}

 

다음으로는 드래그&드롭 할 데이터가 필요하기때문에 다음과 같이 임시데이터를 적용해준다.

const [data,setData] = useState([]);

// useEffect 내에서 초기 데이터 생성
setData([
  {id:'id_1',name:'data1'},
  {id:'id_2',name:'data2'},
  {id:'id_3',name:'data3'},
  {id:'id_4',name:'data4'},
  {id:'id_5',name:'data5'},
  {id:'id_6',name:'data6'},
  {id:'id_7',name:'data7'},
  {id:'id_8',name:'data8'},
])

 

구현

각 드래그 아이템을 구분하기 좋게 하기위해 Styled-components를 추가해 Box와 Item에 스타일을 주었다.

import {
  DragDropContext,
  Draggable,
  Droppable
} from "react-beautiful-dnd";

... Component codes

// 아이템을 드래그 했을때 list의 순서를 바꿔주는 함수
const handleDragEnd = ({ source, destination }) => {
    // 드래그가 취소되거나 드랍 위치가 없을 때
    if (!destination) {
      return;
    }

    const newData = Array.from(data);
    const [movedItem] = newData.splice(source.index, 1);
    newData.splice(destination.index, 0, movedItem);
    setData(newData);
};

return (
    <div>
      <DragDropContext onDragEnd={handleDragEnd}>
        <Droppable droppableId="droppable">
          {(dropProvided) => (
            <Box ref={dropProvided.innerRef} {...dropProvided.droppableProps}>
              {data.map((item, index) => (
                <Draggable key={item.id} draggableId={item.id} index={index}>
                  {(dragProvided) => (
                    <Item
                      ref={dragProvided.innerRef}
                      {...dragProvided.draggableProps}
                      {...dragProvided.dragHandleProps}
                    >
                      {item.name}
                    </Item>
                  )}
                </Draggable>
              ))}
              {dropProvided.placeholder}
            </Box>
          )}
        </Droppable>
      </DragDropContext>
    </div>
);

... Component codes


const Box = styled.div`
  background: skyblue;
  padding: 30px;
  display:flex;
  flex-direction:column;
  align-items:center;
  gap: 8px;
`;

const Item = styled.p`
width:100%;
height:100%;
  border: 1px solid red;
  font-size: 24px;
  font-weight: 600;
`;

이제 하나하나 뜯어보자면

  • DragDropContext를 사용해서 Drag&Drop을 사용 할 구역을 정해준다.
  • Droppable을 사용해 아이템을 드래그 후 떨어뜨릴 수 있는 구역을 지정한다.
  • Draggable을 사용해 드래그 할 수 있는 아이템을 지정해 준다.

자세히 보면 Droppable과 Draggable 내부에 provided 객체를 가져와 설정값들을 입력해주는데

위 코드처럼 dropProvided와 dragProvided는 엄연히 다른객체이기 때문에 헷갈리거나 이름을 같게해 가독성을 떨어뜨리지 않는 편이 좋을 것 같다.

 

 

Droppable

<Droppable droppableId="droppable">
  {(dropProvided) => (
    <Box ref={dropProvided.innerRef} {...dropProvided.droppableProps}>
      {data.map((item, index) => (
        ...dragable
      ))}
      {dropProvided.placeholder}
    </Box>
  )}
</Droppable>

* droppableId

  • 드롭 가능한 영역을 구분할 id를 표시한다. ex) todo, doing, done
  • ID가 유닉하지만 재활용된다면 버그를 일으킬 수 있다.

* dropProvided.innerRef

  • 라이브러리에서 우리 컴포넌트 DOM을 조작하기 위해서 필수로 등록해줘야 한다.

* dropProvided.droppableProps

  • It currently contains data attributes that we use for styling and lookups.
  • 그냥 우리가 전달한 props를 라이브러리에서 사용할 수 있는 형태로 DOM data에 등록시켜주는 것 같다.

* dropProvided.placeholder

  • This is used to create space in the <Droppable /> as needed during a drag.
  • drop될 때 공간을 만들기 위해서 필요하다고 한다. 

Draggable

<Draggable key={item.id} draggableId={item.id} index={index}>
  {(dragProvided) => (
    <Item
      ref={dragProvided.innerRef}
      {...dragProvided.draggableProps}
      {...dragProvided.dragHandleProps}
    >
      {item.name}
    </Item>
  )}
</Draggable>

* draggableId

  • 드롭 가능한 영역을 구분할 id를 표시한다. ex) todo, doing, done
  • ID가 유닉하지만 재활용된다면 버그를 일으킬 수 있다.

* index

  • 리스트의 순서대로 입력해야 한다.

* dragProvided.innerRef

  • 라이브러리에서 우리 컴포넌트 DOM을 조작하기 위해서 필수로 등록해줘야 한다.

* dragProvided.draggableProps

  • contains a data attribute and an inline style.
  • drag 스타일을 등록해주는 역할이다. 이게 없다면 엘리먼트가 움직이지 않을 것이다.

* dragProvided.dragHandleProps

  • drag handle를 등록해주는 인자인데 살펴보면 내부로직이 어떻게 구현했는지 조금 힌트를 얻을 수 있다.
  • data-rbd-drag-handle-draggable-id
  • data-rbd-drag-handle-context-id
  • aria-labelledby
    • screen reader가 연관된 엘리먼트를 읽을 수 있도록 해준다.
  • tabIndex
    • 키보드 탭으로 엘리먼트를 접근할 수 있게 해준다.
  • draggable
  • onDragStart
    • onDragStart를 통해서 이벤트를 등록해준다.

 

구현결과 테스트

구현결과

 

정상적으로 잘 동작하는 것을 볼 수 있다.

실제로 구현을 할때 수정해야하는 것은 스타일, drag&drop으로 인해 실행될 handleDragEnd함수내 코드만 수정해주면

데이터가 다르더라도 정상동작할 것 같다.

 

전체코드

이번 연습과 함께 구현해본 전체 코드

import { useEffect, useState } from "react";
import {
  DragDropContext,
  Draggable,
  Droppable
} from "react-beautiful-dnd";
import { styled } from "styled-components";

export default function App(props) {
  // 아이템이 될 data list
  const [data,setData] = useState([]);
  
  // requestAnimationFrame 실행여부
  const [enabled, setEnabled] = useState(false);

  useEffect(() => {

    // 초기 데이터 생성
    setData([
      {id:'id_1',name:'data1'},
      {id:'id_2',name:'data2'},
      {id:'id_3',name:'data3'},
      {id:'id_4',name:'data4'},
      {id:'id_5',name:'data5'},
      {id:'id_6',name:'data6'},
      {id:'id_7',name:'data7'},
      {id:'id_8',name:'data8'},
    ])

    // 컴포넌트 랜더링 하기 전 requestAnimationFrame 실행
    const animation = requestAnimationFrame(() => setEnabled(true));

    return () => {
      // requestAnimationFrame 정지
      cancelAnimationFrame(animation);
      setEnabled(false);
    };
  }, []);

  // 아이템을 드래그 했을때 list의 순서를 바꿔주는 함수
  const handleDragEnd = ({ source, destination }) => {
    // 드래그가 취소되거나 드랍 위치가 없을 때
    if (!destination) {
      return;
    }
    
    const newData = Array.from(data);
    const [movedItem] = newData.splice(source.index, 1);
    newData.splice(destination.index, 0, movedItem);
    setData(newData);
  };

  // requestAnimationFrame 실행 이후 랜더링
  if (enabled)
    return (
    <div>
      <DragDropContext onDragEnd={handleDragEnd}>
        <Droppable droppableId="droppable">
          {(dropProvided) => (
            <Box ref={dropProvided.innerRef} {...dropProvided.droppableProps}>
              {data.map((item, index) => (
                <Draggable key={item.id} draggableId={item.id} index={index}>
                  {(dragProvided) => (
                    <Item
                      ref={dragProvided.innerRef}
                      {...dragProvided.draggableProps}
                      {...dragProvided.dragHandleProps}
                    >
                      {item.name}
                    </Item>
                  )}
                </Draggable>
              ))}
              {dropProvided.placeholder}
            </Box>
          )}
        </Droppable>
      </DragDropContext>
    </div>
  );
}

const Box = styled.div`
  background: skyblue;
  padding: 30px;
  display:flex;
  flex-direction:column;
  align-items:center;
  gap: 8px;
`;

const Item = styled.p`
width:100%;
height:100%;
  border: 1px solid red;
  font-size: 24px;
  font-weight: 600;
`;
반응형