라이브러리없이 DND TODO 칸반보드 만들기

2022.10.25
16분

대망으로 라이브러리 도움 없이 Drag and Drop이 지원되는 TODO 리스트를 만들어 보자!
지난 포스트에 이어서 React에 Vanilla 스크립트를 붙여서 기능을 구현해봤다.

{
  "source": {
    "droppableId": "todo",
    "index": 1
  },
  "destination": {
    "droppableId": "doing",
    "index": 0
  }
}

작업 후기에 대해 먼저 나누자면,,
왠만하면 라이브러리를 통해서 기능을 구현하자...

React의 DOM 조작과 Vanilla의 DOM 조작이 생각보다 잘 충돌이 되어서 너무 골치가 아팠다..
처음부터 Vanilla로 할껄 ㅠㅠㅠ 😭
생각치 못한 이슈들이 계속 발생되었고 이를 깔끔하게 처리하기 너무 어려웠다.
라이브러리 제작자분들이 진짜 리스빽한다..
그래도 어느정도 만족스로운 결과물을 만들어서 겨우 마무리 짓기로 했다.


동작의 큰 흐름을 살펴보면 아래와 같이 정리가 될 것 같다.

  • 마크업 선언 및 document에 이벤트 등록
  • drag시
    • drag된 element를 클론하여 ghost를 생성하고 기존 element에 placeholder 적용한다.
  • move시
    • 커서에 따라 ghost가 움직이도록 한다.
    • drop이 가능한 새로운 보드에 도착시 placeholder를 해당 보드 끝에 이동시킨다.
    • item에 이동시 상황에 따라 placeholderitem들의 위치를 transform한다.
  • drop시
    • ghostplaceholder 자리로 되돌아가도록하고 제거한다.
    • source destination 정보를 callback으로 전달해주고 상태를 변경시킨다.

마크업 및 이벤트 등록

모바일 기기에서도 터치 드래그가 가능하도록 세팅하고 useEffect에 등록한다.
코드가 좀 길어서 과감하게 넘어가도록 하자.

TodoExample.tsx

import { Dispatch, SetStateAction, useEffect, useState } from 'react';
import registDND from './TodoExample.drag';

export type TItemStatus = 'todo' | 'doing';

export type TItem = {
  id: string;
  status: TItemStatus;
  title: string;
  index: number;
};

export type TItems = {
  [key in TItemStatus]: TItem[];
};

export default function TodoExample() {
  const [items, setItems] = useState<TItems>({
    todo: [...Array(5)].map((_, i) => ({
      id: `${i}${i}${i}`,
      title: `Title ${i + 1}000`,
      status: 'todo',
      index: i,
    })),
    doing: [],
  });

  useEffect(() => {
    const clear = registDND(({ source, destination }) => {
      if (!destination) return;

      const scourceKey = source.droppableId as TItemStatus;
      const destinationKey = destination.droppableId as TItemStatus;

      setItems((items) => {
        const _items = JSON.parse(JSON.stringify(items)) as typeof items;
        const [targetItem] = _items[scourceKey].splice(source.index, 1);
        _items[destinationKey].splice(destination.index, 0, targetItem);
        return _items;
      });
    });
    return () => clear();
  }, [setItems]);

  return (
    <div className="p-4">
      <div className="mt-4 flex">
        <div className="todo grid flex-1 select-none grid-cols-2 gap-4 rounded-lg">
          {Object.keys(items).map((key) => (
            <div
              key={key}
              data-droppable-id={key}
              className="flex flex-col gap-3 rounded-xl bg-gray-200 p-4 ring-1 ring-gray-300 transition-shadow dark:bg-[#000000]"
            >
              <span className="text-xs font-semibold">{key.toLocaleUpperCase()}</span>
              {items[key as TItemStatus].map((item, index) => (
                <div
                  key={item.id}
                  data-index={index}
                  className="dnd-item rounded-lg bg-white p-4 transition-shadow dark:bg-[#121212]"
                >
                  <h5 className="font-semibold">{item.title}</h5>
                  <span className="text-sm text-gray-500">Make the world beatiful</span>
                </div>
              ))}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}
  • react-beatiful-dnd 처럼 콜백을 넘겨주도록 했다.
    {
      "source": {
        "droppableId": "todo",
        "index": 1
      },
      "destination": {
        "droppableId": "doing",
        "index": 0
      }
    }
    

TodoExample.drag.ts

const isTouchScreen =
  typeof window !== 'undefined' && window.matchMedia('(hover: none) and (pointer: coarse)').matches;

const startEventName = isTouchScreen ? 'touchstart' : 'mousedown';
const moveEventName = isTouchScreen ? 'touchmove' : 'mousemove';
const endEventName = isTouchScreen ? 'touchend' : 'mouseup';

const getDelta = (startEvent: MouseEvent | TouchEvent, moveEvent: MouseEvent | TouchEvent) => {
  if (isTouchScreen) {
    const se = startEvent as TouchEvent;
    const me = moveEvent as TouchEvent;

    return {
      deltaX: me.touches[0].pageX - se.touches[0].pageX,
      deltaY: me.touches[0].pageY - se.touches[0].pageY,
    };
  }

  const se = startEvent as MouseEvent;
  const me = moveEvent as MouseEvent;

  return {
    deltaX: me.pageX - se.pageX,
    deltaY: me.pageY - se.pageY,
  };
};

export type DropItem = {
  droppableId: string;
  index: number;
};

export type DropEvent = {
  source: DropItem;
  destination?: DropItem;
};

export default function registDND(onDrop: (event: DropEvent) => void) {
  const startHandler = (startEvent: MouseEvent | TouchEvent) => {
    const moveHandler = (moveEvent: MouseEvent | TouchEvent) => {
	  // Touch 이벤트에서 moveEvent와 scrollEvent가 같이 발생되는 것을 방지한다.
      if (moveEvent.cancelable) moveEvent.preventDefault();
      ...
    }
    const endHandler = () => {...}

    // scrollEvent를 막을 수 있게 `passive: false` 해준다.
    document.addEventListener(moveEventName, moveHandler, { passive: false });
    document.addEventListener(endEventName, endHandler, { once: true });
  }

  document.addEventListener(startEventName, startHandler);
  return () => document.removeEventListener(startEventName, startHandler);
}

Drag

drag된 element를 클론하여 ghost를 생성하고 기존 element에 placeholder 적용한다.
로직은 DND-이벤트-뽀개기에서와 같기 때문에 간단히 코드만 보고 넘어가도록 하자.

const startHandler = (startEvent: MouseEvent | TouchEvent) => {
  const item = (startEvent.target as HTMLElement).closest<HTMLElement>('.dnd-item');

  if (!item || item.classList.contains('moving')) {
    return;
  }

  // 초기 item의 위치, 크기 정보를 미리 할당해놓는다.
  const itemRect = item.getBoundingClientRect();

  const ghostItem = item.cloneNode(true) as HTMLElement;
  ghostItem.classList.add('ghost');
  ghostItem.style.position = 'fixed';
  ghostItem.style.top = `${itemRect.top}px`;
  ghostItem.style.left = `${itemRect.left}px`;
  ghostItem.style.width = `${itemRect.width}px`;
  ghostItem.style.height = `${itemRect.height}px`;
  ghostItem.style.pointerEvents = 'none';

  ghostItem.style.border = '2px solid rgb(96 165 250)';
  ghostItem.style.opacity = '0.95';
  ghostItem.style.boxShadow = '0 30px 60px rgba(0, 0, 0, .2)';
  ghostItem.style.transform = 'scale(1.05)';
  ghostItem.style.transition = 'transform 200ms ease, opacity 200ms ease, boxShadow 200ms ease';

  item.classList.add('placeholder');
  // `global.css`
  //  .todo .dnd-item.placeholder {
  //    @apply border border-blue-500 opacity-50 ring-2 ring-blue-400;
  //  }
  item.style.cursor = 'grabbing';

  document.body.style.cursor = 'grabbing';
  document.body.appendChild(ghostItem);

  //...
};

onDrop에서 값을 넘겨주기 위한 변수를 정의한다.

let destination: HTMLElement | null | undefined;
let destinationItem: HTMLElement | null | undefined;
let destinationIndex: number;
let destinationDroppableId: string;

const source = item.closest<HTMLElement>('[data-droppable-id]');
if (!source) return console.warn('Need `data-droppable-id` at dnd-item parent');
if (!item.dataset.index) return console.warn('Need `data-index` at dnd-item');
// 다른 보드로 이동시 생성하는 임시 sourceItem
let movingItem: HTMLElement;
const sourceIndex = Number(item.dataset.index);
const sourceDroppableId = source.dataset.droppableId!;

기타 아이템들이 살아 움직일 수 있도록 style 세팅도 해주자.

document.querySelectorAll<HTMLElement>('.dnd-item:not(.ghost)').forEach((item) => {
  item.style.transition = 'all 200ms ease';
});

Move

커서의 움직임에 따라 ghost가 움직이도록 한다.

const moveHandler = (moveEvent: MouseEvent | TouchEvent) => {
  //...
  const { deltaX, deltaY } = getDelta(startEvent, moveEvent);
  ghostItem.style.top = `${itemRect.top + deltaY}px`;
  ghostItem.style.left = `${itemRect.left + deltaX}px`;
  //...
};

ghost 중심 위치에 어떤 엘리먼트가 있는지 확인하여 DND에 관련 된 값을 추출해낸다.

const ghostItemRect = ghostItem.getBoundingClientRect();

const pointTarget = document.elementFromPoint(
  ghostItemRect.left + ghostItemRect.width / 2,
  ghostItemRect.top + ghostItemRect.height / 2,
);

const currentDestinationItem = pointTarget?.closest<HTMLElement>('.dnd-item');
const currentDestination = pointTarget?.closest<HTMLElement>('[data-droppable-id]');
const currentDestinationDroppableId = currentDestination?.dataset.droppableId;
const currentDestinationIndex = Number(currentDestinationItem?.dataset.index);

const currentSourceItem = movingItem ?? item;
const currentSourceIndex = Number(currentSourceItem.dataset.index);
const currentSource = currentSourceItem.closest<HTMLElement>('[data-droppable-id]')!;
const currentSourceDroppableId = currentSource.dataset.droppableId;

기존 hover된 보드 스타일을 제거해주고,
현재 drop이 가능한 보드위에 있을 경우 해당 보드에 hover 이벤트를 추가해준다.

// 이후 endHandler 이벤트에서도 사용되기에 재사용할 수 있도록 메소드를 추출해준다.
const clearDroppableShadow = () => {
  document.querySelectorAll<HTMLElement>('[data-droppable-id]').forEach((element) => {
    element.style.boxShadow = 'none';
  });
};

const moveHandler = (moveEvent: MouseEvent | TouchEvent) => {
  //...
  clearDroppableShadow();
  if (currentDestination) {
    currentDestination.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.3)';
  }
  //...
};

같은 위치에 있을 때, 타겟 엘리먼트가 움직이고 있을 땐 이후 동작을 수행하지 않는다.

if (
  currentDestinationItem?.isSameNode(currentSourceItem) ||
  currentDestinationItem?.classList.contains('moving')
) {
  return;
}

이제부터 핵심 로직이다.

핵심 로직 — 다른 보드로 placeholder 이동시키기

개발 편의상, drop이 가능한 보드로 이동시 placeholder를 해당 보드 끝으로 이동시키기로 했다.

위 상황과 같이 아이템 위치까지 이동하기 전에 무조건 보드 위로 진입할거라고 생각했다.
결국 버그를 유발하는 원인이 되었다.

if (
  currentDestination &&
  currentDestinationDroppableId &&
  currentDestinationDroppableId !== currentSourceDroppableId
) {
  if (!movingItem) {
    // 💥 react element의 위치를 이동시키면 react에서 node를 추적할 수 없어 ERROR가 발생된다.
    // 이를 해결하기 위해 눈속임 들어갑니다~!
    movingItem = item.cloneNode(true) as HTMLElement;
    item.classList.remove('dnd-item');
    item.style.display = 'none';
  }

  // 보드 끝에 placeholder를 추가한다.
  currentDestination.appendChild(movingItem);

  // 보드 끝 기준으로 도착지 정보를 갱신해준다.
  destination = currentDestination;
  destinationDroppableId = currentDestinationDroppableId;
  destinationIndex = currentDestination.querySelectorAll('.dnd-item').length - 1;

  // 보드들의 index 정보들을 갱신해준다.
  currentDestination.querySelectorAll<HTMLElement>('.dnd-item').forEach((v, i) => {
    v.dataset.index = i + '';
    v.style.transform = '';
    v.classList.remove('moved');
  });
  currentSource.querySelectorAll<HTMLElement>('.dnd-item').forEach((v, i) => {
    v.dataset.index = i + '';
    v.style.transform = '';
    v.classList.remove('moved');
  });
}

// 만약 위치를 바꿀 타겟이 없다면 이후 동작을 수행하지 않는다.
if (!currentDestinationItem) {
  return;
}

이제 도착지 기준으로 item들의 위치를 조정해주면 된다.


핵심 로직 — item들의 위치를 조정해주기

우선 item의 높이가 고정 되었다고 생각했을 때 이동되어야할 거리를 계산해보자.

const ITEM_MARGIN = 12;
const distance = itemRect.height + ITEM_MARGIN;

이제 index의 차이 바탕으로 item들을 이동시키면 된다.

const transX = indexDiff * distance;
currentSourceItem.style.transform = `translate3d(0, ${transX}px, 0)`;

source indexdestination index 사이에 있는 item들은 한 칸씩 이동시키면 된다.
그럼 여러가지 경우의 수에 대해서 고려해보자.

위에서 아래로 이동할 경우 (index: 0index: 2)
Title 1000는 아래 방향으로 두 칸 이동한다. (2 - 0) * distance
Title 2000 Title 3000은 위 방향으로 한 칸 이동한다. 1 * -distance

아래에서 위로 이동할 경우 (index: 2index: 0)
Title 3000는 위 방향으로 두 칸 이동한다. (0 - 2) * -distance
Title 1000 Title 2000은 아래 방향으로 한 칸 이동한다. 1 * distance

위에서 아래로 이동후 다시 위로 이동할 경우 (index: 0index: 2index: 1)
다시 위로 이동하는 시점에서 index가 꼬이기에 다르게 동작되어야 한다.

다시 올라가는 경우, index 차이에서 1만큼 더 차이나면 된다.
Title 1000는 위 방향으로 두 칸 이동한다. (0 - 1 - 1) * -distance
Title 2000 Title 300는 아래 방향으로 한 칸 이동한다. 1 * distance

애니메이션을 제거하여 보면 이와 같이 동작할 것이다.

코드는 아래와 같이 작성했다.

// 도착지 정보를 target item 기준으로 갱신해준다.
destinationItem = currentDestinationItem;
destination = currentDestinationItem.closest<HTMLElement>('[data-droppable-id]');
destinationDroppableId = destination?.dataset.droppableId + '';

let indexDiff = currentDestinationIndex - currentSourceIndex;
// 위에서 아래로 간다면 (ex. index 1 -> 3)
const isForward = currentSourceIndex < currentDestinationIndex;
// 움직였던 item으로 다시 움직이는지 여부
const isDestinationMoved = destinationItem.classList.contains('moved');

if (isDestinationMoved) {
  indexDiff += isForward ? -1 : 1;
}

destinationIndex = currentSourceIndex + indexDiff;

// indexDiff만큼 placeholder를 이동시킨다.
const transX = indexDiff * distance;
currentSourceItem.style.transform = `translate3d(0, ${transX}px, 0)`;

// indexDiff 사이에 있는 item들을 이동시킨다.
let target = currentDestinationItem;
while (
  target &&
  target.classList.contains('dnd-item') &&
  !target.classList.contains('placeholder')
) {
  if (isDestinationMoved) {
    target.style.transform = '';
    target.classList.remove('moved');
    target = (isForward ? target.nextElementSibling : target.previousElementSibling) as HTMLElement;
  } else {
    target.style.transform = `translate3d(0, ${isForward ? -distance : distance}px, 0)`;
    target.classList.add('moved');
    target = (isForward ? target.previousElementSibling : target.nextElementSibling) as HTMLElement;
  }
}

startHandler에서 추가해줬던 item.style.transition = 'all 200ms ease'에 의해서 item들이 200ms을 거쳐 밀려나는 동안 다시 target으로 트리거되지 않도록 moving 클래스명을 추가해주고 끝나면 다시 제거해준다.

currentDestinationItem.classList.add('moving');
currentDestinationItem.addEventListener(
  'transitionend',
  () => {
    currentDestinationItem?.classList.remove('moving');
  },
  { once: true },
);
// 빈번하게 발생될시 transitionend이 트리거되지않을 수 있어 setTimeout으로도 수행하도록 했다.
setTimeout(() => {
  currentDestinationItem?.classList.remove('moving');
}, 200);


Drop

클릭, 터치를 뗐을 때 endHandler가 수행된다.

const endHandler = () => {
  //...
  document.removeEventListener(moveEventName, moveHandler);
};

ghostplaceholder 자리로 되돌아가도록 한다.

const sourceItem = movingItem ?? item;
// 미관상 placehoder 스타일을 바로 제거해준다.
item.classList.remove('placeholder');
movingItem?.classList.remove('placeholder');

// 초기 지정했던 doucment의 cursor를 초기화 한다.
document.body.removeAttribute('style');
// 모든 보드의 `hover` 상태를 초기화 한다.
clearDroppableShadow();

const itemRect = sourceItem.getBoundingClientRect();
ghostItem.classList.add('moving');
ghostItem.style.left = `${itemRect.left}px`;
ghostItem.style.top = `${itemRect.top}px`;
ghostItem.style.opacity = '1';
ghostItem.style.transform = 'none';
ghostItem.style.borderWidth = '0px';
ghostItem.style.boxShadow = '0 1px 3px rgba(0, 0, 0, 0.15)';
ghostItem.style.transition = 'all 200ms ease';

ghost가 완전히 placehoder로 되돌아가게 되었을 때 ghost를 제거해주고,
style 상태를 초기화하고,
source destination 정보를 callback으로 전달해준다.

ghostItem.addEventListener(
  'transitionend',
  () => {
    ghostItem.remove();

    // 💥 react rerender 이후로 실행되도록하는 꼼수
    setTimeout(() => {
      // transform 된 item들을 초기화 해준다.
      document.querySelectorAll<HTMLElement>('.dnd-item').forEach((item) => {
        item.removeAttribute('style');
        item.classList.remove('moving', 'moved');
      });

      // 꼼수를 위해 숨겨놓은 item을 되돌린다.
      item.classList.add('dnd-item');
      item.removeAttribute('style');
      movingItem?.remove();
    }, 0);

    // DND 정보를 최종적으로 callback으로 전달해준다.
    onDrop({
      source: {
        droppableId: sourceDroppableId,
        index: sourceIndex,
      },
      destination: destination
        ? {
            droppableId: destinationDroppableId,
            index: destinationIndex,
          }
        : undefined,
    });
  },
  { once: true },
);

이제 콜벡을 통해서 react 상태를 변경해주면 끝이다!

registDND(({ source, destination }) => {
  if (!destination) return;

  const scourceKey = source.droppableId as TItemStatus;
  const destinationKey = destination.droppableId as TItemStatus;

  setItems((items) => {
    const _items = JSON.parse(JSON.stringify(items)) as typeof items;
    const [targetItem] = _items[scourceKey].splice(source.index, 1);
    _items[destinationKey].splice(destination.index, 0, targetItem);
    return _items;
  });
});

진짜 끝이다!!!!! 🏄🏻‍♂️

횡방향 DND, 동적인 item 높이, 키보드 접근성 등 추가되어야할 부분이 상당히 많지만,,
더 이상 작업할 여력이 없어 DND 시리즈를 이번 포스트로 마무리합니다.

부족함이 많았던 DND 시리즈에 관심을 주시고 긴 길을 끝까지 읽어주셔서 정말 감사합니다! (_ _)
그럼 20000 👋🏻


참고
https://www.uriports.com/blog/easy-fix-for-intervention-ignored-attempt-to-cancel-a-touchmove-event-with-cancelable-false/

실제 동작은 아래 링크에서 볼 수 있습니다.
https://dnd-playground.vercel.app/todo

전체 코드는 아래 깃허브 링크에서 살펴보면 됩니다.
https://github.com/lavinoys/dnd-playground/blob/main/src/components/todo/TodoLibraryExample.tsx