카테고리 없음

[라이브러리]DND-kit으로 drag&drop을 구현해보자

아보카도 있었어! 2023. 9. 11. 01:03

DND-kit 라이브러리로 버튼을 누르면 모달도 열리고, 특정 날짜에 drag&drop 가능한 캘린더를 구현해보자.

DND 라이브러리를 사용한 이유는 '충돌감지 알고리즘(Collision detection algorithms)'과 '센서 제약(Activation constraints)'이 구현돼있어서다.

draggable 한 컴포넌트(예: button)을 눌렀을 때 바로 드래그 가능한 컴포넌트가 아니라 모달 open의 기능도 수행해야 하므로, 드래그를 감지하는 센서를 제약해야 했다.

 

DND-kit으로 컴포넌트를 drag&drop 가능하도록 만들어보자.

 

1. drag&drop을 사용하려면 우선 <DndContext>로 감싸야 한다.

 

     
  <DndContext>
          {allDay.map((day, idx) => (
            <DateBox
              key={idx}
              day={day}
              nowDate={nowDate}
              schedule={allSchedule?.find((s) => {
                const scheduleDate = new Date(Date.parse(s.date));
                return (
                  day.getFullYear() === scheduleDate.getFullYear() &&
                  day.getMonth() === scheduleDate.getMonth() &&
                  day.getDate() === scheduleDate.getDate()
                );
              })}
            />
          ))}
        </DndContext>
 
 

 

2. My first Droppable Component

Droppable 컴포넌트는 드래그 가능한 컴포넌트가 드래그 이벤트 뒤에 고정 부착가능한 영역의 컴포넌트를 말한다. 스티커로 비유하면, 스티커를 붙일 수 있는 스티커북에 해당하는 컴포넌트다.

Droppable 컴포넌트를 만들기 위해서는 DOM에 ref를 전달하고, 고유한 id를 주면된다.

 

이번 프로젝트에서는 날짜를 나타내는 DateBox 컴포넌트에 스케줄을 드래그 앤 드롭할 것이므로 DateBox를 useDroppable 훅을 사용해 Droppable 컴포넌트로 만들어준다.

 

 
function DateBox() {
 
  const { isOver, setNodeRef } = useDroppable({
    id: 'unique-id'
  });

  const style = isOver ? { color: "green" } : undefined;

 

id 값은 드래그 이벤트가 발생했을 때 이벤트 객체에 담기는 값이다.

`isOver`는 드래그 가능한 요소가 드롭 가능한 요소 위에 왔을 때를 나타내는 불리언 값이다.

 

Droppable 요소로 만들 DOM에 ref로 setNodeRef를 전달한다.

 
/* .... */  
 return (
    <div
      style={style}
      ref={setNodeRef}
    >
        {dateName}
      </span>
      {schedule && <Schedule day={day} schedule={schedule}></Schedule>}
    </div>
  );
 
export default function Schedule({ schedule, day }) {
  const { contents, date } = schedule;

  return (
    <ul className="schedule">
      {contents.map((c) => (
        <li key={c.id}>
          <ScheduleItem
            day={day}
            content={c.content}
            id={c.id}
          />
        </li>
      ))}
    </ul>
  );
} 
 

 

3. My first Draggable Component

날짜에 해당하는 스케줄을 드래그해서 다른 날짜로 옮길 것이기 때문에 ScheduleItem 컴포넌트가 Draggable 컴포넌트가 된다. Droppable 컴포넌트와 마찬가지로,  Draggable 컴포넌트를 만들기 위해서는 리스너와 드래그하려는 DOM에 ref를 전달하고, 고유 id를 전달하면 된다.

 

드래그할 아이템을 선택하면, `transform` 프로퍼티가 화면에서 항목을 이동하는 데 필요한 변환 좌표로 채워진다.

`tranform` 객체는 다음과 같은 객체 형태이다.

interface transform {
x: number
y: number
scaleX: number
scaleY: number
}

 

Draggable Component를 만들기 위해 useDraggable 훅을 사용한다.

 

 
function ScheduleItem({
  id,
  content,
  day
}) {
 
  const { setNodeRef, listeners, transform } = useDraggable({
    id: `${id}+${day.toString()}`,
  });
 

  const style = transform
    ? { transform: `translate3d(${transform.x}px, ${transform.y}px, 0)` }
    : undefined;

  return (
    <button
      ref={setNodeRef}
      {...listeners}
      style={style}
 
    >
      <div className="schedule__content">{content}</div>
    </button>
  );
}
 

 

Droppable 컴포넌트를 만들 때처럼 드래그 가능한 요소로 만들고자 하는 DOM에 ref를 전달하고, `transform` 객체를 style에 전달해 화면 상에서 드래그 가능한 요소가 움직이는 것을 볼 수 있도록 한다.

id 값은 같은 날짜 내에서 어떤 스케줄이 이동했는지를 확인해야 하므로 해당 정보를 결합한 문자열을 id로 설정하였다.

 

drag & drop?

 

화면에서 드래그 가능한 요소가 드래그돼서 이동하는 것은 볼 수 있지만, Droppable한 요소에서 Droppable한 상태가 되어서(글자색 초록으로 변경) 요소를 놓아도 드래그 가능한 요소가 처음 제자리로 가는 것을 확인할 수 있다.

 

우리가 만든 Draggable 컴포넌트와 Droppable 컴포넌트는 컴포넌트가 드래그 가능하고 드롭 가능하게 만들어줄 뿐, 화면을 구성해주지는 않는다. 원하는 결과를 얻으려면, Schedule Item 컴포넌트가 가진 날짜의 정보를 변경하여 리액트로 하여금 렌더링할 수 있도록 해 주어야 한다.

 

4. onDragEnd 이벤트 리스너를 추가해야 한다.

스케줄을 다른 날짜로 옮길 때, 옮기고자 하는 날짜의 정보는 드래그가 끝난 후 알 수 있으므로, 이벤트가 끝났을 때 스케줄의 상태를 변경하여 옮기고자 하는 날짜의 DateBox 컴포넌트에서 렌더링될 수 있도록 한다.

 

     
 <DndContext
          onDragEnd={handleDragEnd}
        >
          {allDay.map((day, idx) => (
            <DateBox
              key={idx}
              day={day}
              schedule={allSchedule?.find((s) => {
                const scheduleDate = new Date(Date.parse(s.date));
                return (
                  day.getFullYear() === scheduleDate.getFullYear() &&
                  day.getMonth() === scheduleDate.getMonth() &&
                  day.getDate() === scheduleDate.getDate()
                );
              })}
            />
          ))}
        </DndContext>
 

 

 
 const [allSchedule, setAllSchedule] = useState([]);
 
 
function handleDragEnd(event) {
 
    // 옮기고자 하는 날짜 정보
    const { over, active } = event;

    function dateToString(args) {
      const timeStamp = new Date(arg);
      const timeString = `${timeStamp.getFullYear()}-${(
        timeStamp.getMonth() + 1
      )
        .toString()
        .padStart(2, "0")}-${timeStamp.getDate().toString().padStart(2, "0")}`;
      return timeString;
    }

    if (over) {
      // 드래그된 아이템의 정보
      const { id } = active;
      const [editId, editDate] = id.split("+");

      let uniqueId = 2;

      setAllSchedule((prev) => {
        // 수정하고자 하는 날짜 객체 가져오기(draggable)
        const wantEdit = prev?.find((s) => s.date === dateToString(editDate));

        if (prev && wantEdit) {
          // 옮기고난 후 이전 날짜 상태(이전 날짜에서 옮기고자 하는 스케줄 삭제)
          const nextPrevSche = prev
            .map((s) => {
              if (s.date === dateToString(editDate)) {
                const { contents } = s;
                const newPrev = {
                  ...s,
                  contents: contents.filter((c) => c.id !== parseInt(editId)),
                };
               // 옮기고난 후 이전 날짜에 스케줄이 없을 경우
                if (newPrev.contents.length < 1) {
                  console.log(`${s.date}일에 값이 없습니다.`);
                }
                return newPrev;
              }
              return s;
            })
            .filter((s) => s.contents.length !== 0);
 
 
 
           // 다음 렌더링 스케줄 상태
          const nextState = nextPrevSche.map((s) => {
            // 옮기고자 하는 날짜에 스케줄이 있으면
            if (s.date === dateToString(over.id)) {
            // 옮기고자 하는 날짜에 스케줄 가져오기
              const { contents } = wantEdit;
              const editContent = contents.find(
                (c) => c.id === parseInt(editId)
              );

              return {
                ...s,
                id: uniqueId,
                contents: [...s.contents, { ...editContent, id: uniqueId }],
              }
            }
            return s;
          });

          // 옮기고자 하는 날짜에 스케줄이 없으면
          if (
            nextState.findIndex((s) => s.date === dateToString(over.id)) === -1
          ) {
            const newSchedule = [
              ...nextState,
              {
                id: uniqueId++,
                date: dateToString(over.id),
                contents: [
                  {
                    id: uniqueId,
                    content:
                      wantEdit.contents.find((c) => c.id === parseInt(editId))
                        ?.content ?? "",
                  },
                ],
              },
            ];
            return newSchedule;
          }
          return nextState;
        }

        return prev;
      });
    }
  }

 

 

원하는대로 작동하는 drag&drop을 확인할 수 있다.

 

5. 드래그 제약 주기

dnd kit은 드래그 이벤트를 sensor라는 추상화 개념을 통해 감지한다. 마우스로 클릭했을 때 모달을 띄울 것이므로 드래그 이벤트 시작 전 마우스를 클릭한 후 특정 조건을 만족해야 드래그 이벤트로 인식할 수 있도록 제약 조건을 설정할 수 있다.

 

센서는 useSensor 훅과 useSensors 훅을 사용하여 설정할 수 있다.

 
  const mouseSensor = useSensor(MouseSensor, {
    activationConstraint: {
      distance: 10,
    },
  });

  const sensors = useSensors(mouseSensor);

 

마우스로 요소 클릭 후, 10px를 이동해야만 드래그 이벤트로 감지하도록 설정해 주었다.

 

 

아이템을 클릭하면 모달이 open되고, 10px 이상 드래그 하면 요소가 드래그되는 것을 확인할 수 있다.

 

 

배운 점 및 후기

drag&drop을 구현하는 데 작성해야할 코드가 많고, 시간이 오래 걸릴 것으로 예상되어 라이브러리를 활용해 보았다. 

mockdata로 활용한 스케줄 객체의 구조가 복잡해서 드래그가 끝난 후 상태를 변경하는 setter 함수의 구현이 가독성이 떨어지고 난잡해진 것이 아쉽지만, 이번 기회로 구조분해와 배열의 메서드 운용도를 좀더 높일 수 있었다.

그리고 dnd kit이 제공하는 충돌 감지 알고리즘이 흥미로워서 재미있게 개발할 수 있었다.