한 걸음씩

[React] Drag and Drop 본문

React

[React] Drag and Drop

winter17 2023. 8. 12. 18:08
npm i react-beautiful-dnd

 

npm i --save-dev @types/react-beautiful-dnd

 

react-beautiful-dnd

드래그 앤 드롭: https://github.com/atlassian/react-beautiful-dnd

드래그 앤 드롭이 동작하지 않을 경우: https://github.com/atlassian/react-beautiful-dnd/issues/2350

 

파일구조
코드 실행

// index.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import { RecoilRoot } from "recoil";
import { createGlobalStyle, ThemeProvider } from "styled-components";
import App from "./App";
import { darkTheme } from "./theme";
const root = ReactDOM.createRoot(
  document.getElementById("root") as HTMLElement
);

const GlobalStyle = createGlobalStyle`
@import url('https://fonts.googleapis.com/css2?family=Source+Sans+Pro:wght@300;400&display=swap');
html, body, div, span, applet, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code,
del, dfn, em, img, ins, kbd, q, s, samp,
small, strike, strong, sub, sup, tt, var,
b, u, i, center,
dl, dt, dd, menu, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, embed,
figure, figcaption, footer, header, hgroup,
main, menu, nav, output, ruby, section, summary,
time, mark, audio, video {
  margin: 0;
  padding: 0;
  border: 0;
  font-size: 100%;
  font: inherit;
  vertical-align: baseline;
}
/* HTML5 display-role reset for older browsers */
article, aside, details, figcaption, figure,
footer, header, hgroup, main, menu, nav, section {
  display: block;
}
/* HTML5 hidden-attribute fix for newer browsers */
*[hidden] {
    display: none;
}
body {
  line-height: 1;
}
menu, ol, ul {
  list-style: none;
}
blockquote, q {
  quotes: none;
}
blockquote:before, blockquote:after,
q:before, q:after {
  content: '';
  content: none;
}
table {
  border-collapse: collapse;
  border-spacing: 0;
}
* {
  box-sizing: border-box;
}
body {
  font-weight: 300;
  font-family: 'Source Sans Pro', sans-serif;
  background-color:${(props) => props.theme.bgColor};
  color:${(props) => props.theme.textColor};
  line-height: 1.2;
}
a {
  text-decoration:none;
  color:inherit;
}
`;

root.render(
  // <React.StrictMode>
  // recoil 상태 관리 라이브러리의 루트 컴포넌트
  <RecoilRoot>
    // styled-components의 테마를 설정하는 역할
    <ThemeProvider theme={darkTheme}>
      // styled-components을 사용하여 정의한 전역 스타일을 실제로 적용하는 컴포넌트 
      <GlobalStyle /> 
      // 실제 애플리케이션의 메인 컴포넌트 
      <App />
    </ThemeProvider>
  </RecoilRoot>
  // </React.StrictMode>
);

 

// App.tsx
import { DragDropContext, 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: 680px;
  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);
`;

// const toDos = ["a", "b", "c", "d", "e", "f"];
function App() {

  // Recoil 상태를 사용하여 할 일들의 상태를 관리
  const [toDos, setToDos] = useRecoilState(toDoState);
  
  // 드래그가 끝났을 때 실행되는 함수
  const onDragEnd = (info: DropResult) => {
    const { destination, source, draggableId } = info;
    
    // 드래그를 취소한 경우 또는 이동 위치가 없는 경우 종료
    if (!destination) return;
    
    if (destination?.droppableId === source.droppableId) {
      // 같은 보드 내에서의 이동인 경우
      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) {
      // 다른 보드로 이동한 경우
      setToDos((allBoards) => {
        const sourceBoard = [...allBoards[source.droppableId]];
        const destinationBoard = [...allBoards[destination.droppableId]];
        const taskObj = sourceBoard[source.index];
        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>
        {/* toDos 객체의 각 키(보드 아이디)에 대해 보드 컴포넌트 생성 */}
          {Object.keys(toDos).map((boardId) => (
            <Board boardId={boardId} key={boardId} toDos={toDos[boardId]} />
          ))}
        </Boards>
      </Wrapper>
    </DragDropContext>
  );
}

export default App;

 

// DraggableCard.tsx
import React from "react";
import { Draggable } from "react-beautiful-dnd";
import styled from "styled-components";

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

interface IDraggableCardProps {
  toDoId: number;
  toDoText: string;
  index: number;
}

function DraggableCard({ toDoId, toDoText, index }: IDraggableCardProps) {
  return (
    // Draggable 컴포넌트로 감싸진 카드
    <Draggable draggableId={toDoId + ""} index={index}>
      {(magic, snapshot) => (
        <Card
          isDragging={snapshot.isDragging}
          ref={magic.innerRef}
          {...magic.draggableProps}
          {...magic.dragHandleProps}
        >
          {toDoText}
        </Card>
      )}
    </Draggable>
  );
}
// React에게 prop이 변하지 않았다면 DraggableCard를 다시 렌더링 하지 말라고 알림
// 마지막 리스트를 처음 리스트로 옮기면 전체가 렌더링 됨
// 전체 컴포넌트가 재렌더링된다면 애플리케이션이 무거워지고 느려짐
export default React.memo(DraggableCard);

 

// Board.tsx
import { Droppable } from "react-beautiful-dnd";
import DraggableCard from "./DraggableCard";
import styled from "styled-components";
// import { useRef } from "react";
import { useForm } from "react-hook-form";
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;
  overflow: hidden;
`;

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

interface IAreaProps {
  isDraggingFromThis: boolean;
  isDraggingOver: 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;
}

function Board({ toDos, boardId }: IBoardProps) {
  // Recoil Hook을 사용하여 상태 변경 함수 가져오기
  const setToDos = useSetRecoilState(toDoState);

  // react-hook-form을 사용하여 폼 처리를 위한 필요한 메소드 가져오기
  const { register, setValue, handleSubmit } = useForm<IForm>();

  // 폼 제출 시 호출되는 함수
  const onValid = ({ toDo }: IForm) => {
    // 새로운 할 일 객체 생성
    const newToDo = {
      id: Date.now(),
      text: toDo
    };
    
    // 상태 업데이트
    setToDos((allBoards) => {
      return {
        ...allBoards,
        [boardId]: [newToDo, ...allBoards[boardId]]
      };
    });
    
    // 입력 필드 초기화
    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, info) => (
          <Area
            isDraggingOver={info.isDraggingOver}
            isDraggingFromThis={Boolean(info.draggingFromThisWith)}
            // 자바스크립트로부터 HTML요소를 가져오고 수정하는 방법
            ref={magic.innerRef}
            {...magic.droppableProps}
          >
            {toDos.map((toDo, index) => (
              <DraggableCard
                key={toDo.id}
                index={index}
                toDoId={toDo.id}
                toDoText={toDo.text}
              />
            ))}
            {magic.placeholder}
          </Area>
        )}
      </Droppable>
    </Wrapper>
  );
}
export default Board;

 

// atoms.tsx
import { atom } from "recoil";

// 할 일 정보를 나타내는 인터페이스 정의
export interface ITodo {
  id: number;    // 할 일의 고유 ID
  text: string;  // 할 일의 내용
}

// 할 일 상태를 나타내는 인터페이스 정의
interface IToDoState {
  [key: string]: ITodo[];  // 보드 별로 할 일 배열을 포함하는 객체
}

// Recoil atom을 사용하여 할 일 상태를 관리하는 "toDoState" 정의
export const toDoState = atom<IToDoState>({
  key: "toDo",  // 고유한 식별자 키
  default: {
    "To Do": [],  // "To Do" 보드의 초기 값은 빈 배열
    Doing: [],    // "Doing" 보드의 초기 값은 빈 배열
    Done: []      // "Done" 보드의 초기 값은 빈 배열
  }
});

 

// theme.tsx
import { DefaultTheme } from "styled-components";

export const darkTheme: DefaultTheme = {
  bgColor: "#3F8CF2",
  boardColor: "#DADFE9",
  cardColor: "white"
};

 

// styled.d.ts
declare module "styled-components" {
  export interface DefaultTheme {
    bgColor: string;
    boardColor: string;
    cardColor: string;
  }
}

추가 요구 사항

1. Input 스타일 완성하기

2. task 저장 (localStorage)

3. task 삭제 (드롭하여 삭제)

4. board의 순서 변경

5. board 생성 (react-hook-form을 이용하여 board 이름을 입력하고 엔터치면 보드가 생성)

'React' 카테고리의 다른 글

[React] Gatsby  (0) 2023.08.16
[React] Animations  (0) 2023.08.14
[React] atom, selector를 사용한 minutes, hours 변환기  (0) 2023.08.11
[React] To Do  (0) 2023.08.11
[React] react hook form  (0) 2023.08.10