Notice
Recent Posts
Recent Comments
Link
«   2025/05   »
1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31
Archives
Today
Total
관리 메뉴

사고쳤어요

[React / ts] Task App 만들기 - ⑥ dnd-kit으로 drag and drop 만들기 본문

웹 풀스택

[React / ts] Task App 만들기 - ⑥ dnd-kit으로 drag and drop 만들기

kevinmj12 2025. 4. 17. 18:26

https://www.npmjs.com/package/@dnd-kit/sortable

 

@dnd-kit/sortable

Official sortable preset and sensors for dnd kit. Latest version: 10.0.0, last published: 4 months ago. Start using @dnd-kit/sortable in your project by running `npm i @dnd-kit/sortable`. There are 1119 other projects in the npm registry using @dnd-kit/sor

www.npmjs.com

dnd-kit은 drag and drop을 구현하는 데 도움을 주는 라이브러리이다.

npm install @dnd-kit/sortable

다음 명령어로 설치를 진행한 뒤 본격적으로 구현을 시작해보자.

 

Task drag and drop

// List.tsx

import React from "react";
import { GrSubtract } from "react-icons/gr";
import { IList, ITask } from "../../types";
import { useTypedDispatch, useTypedSelector } from "../../hooks/redux";
import {
  deleteList,
  setModalActive,
  updateBoards,
} from "../../store/slices/boardsSlice";
import { addLog } from "../../store/slices/loggerSlice";
import { v4 } from "uuid";
import { setModalData } from "../../store/slices/modalSlice";
import Task from "../Task/Task";
import { deleteButton, header, listWrapper, name } from "./List.css";
import ActionButton from "../ActionButton/ActionButton";

import {
  DndContext,
  closestCenter,
  PointerSensor,
  useSensor,
  useSensors,
  DragEndEvent,
} from "@dnd-kit/core";
import {
  arrayMove,
  SortableContext,
  useSortable,
  verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";

type TListProps = {
  list: IList;
  boardId: string;
};

type TSortableItem = {
  task: ITask;
  boardId: string;
  id: string;
  index: number;
};

const SortableItem: React.FC<TSortableItem> = ({
  task,
  boardId,
  id,
  index,
}) => {
  const { attributes, listeners, setNodeRef, transform, transition } =
    useSortable({ id });

  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
    marginBottom: "8px",
    cursor: "grab",
  };

  return (
    <div style={style} ref={setNodeRef} {...attributes} {...listeners}>
      <Task
        taskName={task.taskName}
        taskDescription={task.taskDescription}
        boardId={boardId}
        id={task.taskId}
        index={index}
      />
    </div>
  );
};

const List: React.FC<TListProps> = ({ list, boardId }) => {
  const sensors = useSensors(useSensor(PointerSensor));
  const boards = useTypedSelector((state) => state.boards.boardArray);
  const dispatch = useTypedDispatch();

  const handleListDelete = (listId: string) => {
    dispatch(deleteList({ boardId, listId }));
    dispatch(
      addLog({
        logId: v4(),
        logMessage: "리스트 삭제하기",
        logAuthor: "User",
        logTimestamp: Date.now().toString(),
      })
    );
  };

  const handleTaskChange = (
    boardId: string,
    listId: string,
    taskId: string,
    task: ITask
  ) => {
    dispatch(setModalData({ boardId, listId, task }));
    dispatch(setModalActive(true));
  };

  const findTaskLocation = (taskId: string | number) => {
    for (const board of boards) {
      for (const list of board.lists) {
        const found = list.tasks.find((t) => t.taskId === taskId.toString());
        if (found) {
          return {
            boardId: board.boardId,
            listId: list.listId,
            taskId: found.taskId,
          };
        }
      }
    }
    return null;
  };

  const handleDragEnd = (event: DragEndEvent) => {
    const { active, over } = event;

    if (!over || active.id === over.id) return;

    const source = findTaskLocation(active.id);
    const destination = findTaskLocation(over.id);

    if (source?.listId === destination?.listId && source && destination) {
      const targetBoardIndex = boards.findIndex(
        (b) => b.boardId === source.boardId
      );
      const targetListIndex = boards[targetBoardIndex].lists.findIndex(
        (l) => l.listId === source.listId
      );

      const newBoards = JSON.parse(JSON.stringify(boards));
      const targetList = newBoards[targetBoardIndex].lists[targetListIndex];

      const oldIndex = targetList.tasks.findIndex(
        (t: ITask) => t.taskId === active.id
      );
      const newIndex = targetList.tasks.findIndex(
        (t: ITask) => t.taskId === over.id
      );

      targetList.tasks = arrayMove(targetList.tasks, oldIndex, newIndex);
      dispatch(updateBoards({ boards: newBoards }));
    }
  };

  return (
    <div className={listWrapper}>
      <div className={header}>
        <div className={name}>{list.listName}</div>
        <GrSubtract
          className={deleteButton}
          onClick={() => handleListDelete(list.listId)}
        />
      </div>

      <DndContext
        sensors={sensors}
        collisionDetection={closestCenter}
        onDragEnd={handleDragEnd}
      >
        <SortableContext
          items={list.tasks.map((task) => task.taskId)}
          strategy={verticalListSortingStrategy}
        >
          {list.tasks.map((task, index) => (
            <div
              key={task.taskId}
              onClick={() =>
                handleTaskChange(boardId, list.listId, task.taskId, task)
              }
            >
              <SortableItem
                key={index}
                task={task}
                boardId={boardId}
                id={task.taskId}
                index={index}
              ></SortableItem>
            </div>
          ))}
        </SortableContext>
      </DndContext>
      <ActionButton boardId={boardId} listId={list.listId} />
    </div>
  );
};

export default List;

 

dnd-kit을 사용하여 드래그 앤 드롭을 구현하려면 3가지 컴포넌트가 제 역할을 수행해야 한다.

<DndContext>: 드래그 앤 드롭을 사용하는 영역

<SortableContext>: 드래그한 아이템을 놓을 수 있는 영역

<SortableItem>: 드래그할 아이템

 

<DndContext
    sensors={sensors}
    collisionDetection={closestCenter}
    onDragEnd={handleDragEnd}
  >
    <SortableContext
      items={list.tasks.map((task) => task.taskId)}
      strategy={verticalListSortingStrategy}
    >
      {list.tasks.map((task, index) => (
        <div
          key={task.taskId}
          onClick={() =>
            handleTaskChange(boardId, list.listId, task.taskId, task)
          }
        >
          <SortableItem
            key={index}
            task={task}
            boardId={boardId}
            id={task.taskId}
            index={index}
          ></SortableItem>
        </div>
      ))}
    </SortableContext>
  </DndContext>

위 부분에 해당되는 코드는 다음과 같다. 

sensor, collisionDetection 등은 dnd-kit에서 제공하는 모듈로 간편하게 설정이 가능하다.

우리가 집중해서 구현해주어야 할 부분은 handleDragEnd로, 드래그가 끝났을 때를 처리하는 함수이다.

 

const handleDragEnd = (event: DragEndEvent) => {
    const { active, over } = event;

    if (!over || active.id === over.id) return;

    const source = findTaskLocation(active.id);
    const destination = findTaskLocation(over.id);

    if (source?.listId === destination?.listId && source && destination) {
      const targetBoardIndex = boards.findIndex(
        (b) => b.boardId === source.boardId
      );
      const targetListIndex = boards[targetBoardIndex].lists.findIndex(
        (l) => l.listId === source.listId
      );

      const newBoards = JSON.parse(JSON.stringify(boards));
      const targetList = newBoards[targetBoardIndex].lists[targetListIndex];

      const oldIndex = targetList.tasks.findIndex(
        (t: ITask) => t.taskId === active.id
      );
      const newIndex = targetList.tasks.findIndex(
        (t: ITask) => t.taskId === over.id
      );

      targetList.tasks = arrayMove(targetList.tasks, oldIndex, newIndex);
      dispatch(updateBoards({ boards: newBoards }));
    }
  };

active는 드래그를 시작한 아이템, 즉 옮길 아이템을 뜻하고 over는 드래그를 마친 장소의 아이템을 뜻한다.

즉 active를 클릭하여 over로 드래그하면 active와 over 두 개의 위치가 바뀌는 것이다.

 

findTaskLocation()함수를 정의해주어 task가 위치한 Board와 List의 위치를 찾아준 뒤,

active와 over의 taskId를 통해 두 위치를 변경하여 최종적으로 dispatch(updateBoards())를 통해 redux에 반영해주었다.