[React] Drag&Drop 구현하기

Jimin Lee·2024년 11월 19일
0

DnD

목록 보기
1/2
post-thumbnail

프로젝트 중 Drag&Drop이 필요할 일이 생겼다. 처음엔 라이브러리를 사용해서 커스터마이징하려 했는데 vue에는 마땅한 라이브러리가 없었고...(리액트였으면 react-dnd 사용했을 것 같다.) 요구사항이 많지 않아서 직접 구현하게 됐다.

원래 짠건 vue지만 react로 다시 만들어보자.

1. 인터페이스 설정

  • DropArea: Drop할 영역
  • DragItem: Drag 대상 아이템
    draggable=true로 설정한 부분이 손잡이가 되어서 드래깅되고 그 외에 부분은 드래깅되지 않는다.
  • 순서가 변경되면 onChange 핸들러가 실행되고 변경된 순서를 return한다.
    onChange(updatedOrder: number[])

2. DropArea와 DropItem 생성

DropArea와 DropItem 파일을 생성한다.

// DropArea.tsx
import './droparea.scss';
import React, { useEffect, useRef } from 'react';

type Props = {
  children?: React.ReactNode;
  direction?: 'horizontal' | 'vertical';
};

export const DropArea = ({ children, direction = 'vertical' }: Props) => {
  const areaRef = useRef<HTMLUListElement>(null);

  useEffect(() => {});

  return (
    <ul ref={areaRef} className={'droparea ' + direction}>
      {children}
    </ul>
  );
};

// droparea.scss
.droparea {
  position: relative;
  display: flex;
  width: fit-content;
  height: fit-content;
  flex-wrap: wrap;

  gap: 10px;
  padding: 10px;
  background-color: grey;
  height: 300px;
  width: 300px;

  &.vertical {
    flex-direction: column;
  }
}
// DragItem.tsx
import "./dragitem.scss";

type Props = {
  id: string;
  children?: React.ReactNode;
};

export const DragItem = ({ id, children }: Props) => {
  return (
    <li id={id} className="dragitem">
      {children}
    </li>
  );
};

// dragitem.scss
.dragitem {
  display: flex;
  width: fit-content;
  height: fit-content;
  &.ghost {
    opacity: 0.8;
    pointer-events: none;
  }
}
// App.tsx 일부
        <DropArea>
          {list.map((el, idx) => (
            <DragItem key={el} id={el}>
              <div
                style={{
                  width: '100px',
                  height: '50px',
                  border: '1px dashed black',
                  borderRadius: '5px',
                  display: 'flex',
                  alignItems: 'center',
                  justifyContent: 'center',
                  margin: '5px 0',
                }}
              >
                <button draggable='true'>drag</button>
                <span>{idx}</span>
              </div>
            </DragItem>
          ))}
        </DropArea>



3. DropArea에 마우스 핸들러 추가하기

DragItem의 draggable=true로 설정된 영역에 드래그 핸들러를 추가한다.

// DropArea.tsx
  const handleDragStart = (ev: DragEvent) => {};
  const handleDragEnd = (ev: DragEvent) => {};

  useEffect(() => {
    if (areaRef.current) {
      const dragItems = [...areaRef.current.querySelectorAll('.dragitem')];

      dragItems.forEach((el) => {
        const draggable = el.querySelector('[draggable=true]');
        if (draggable instanceof HTMLElement) {
          draggable.addEventListener('dragstart', handleDragStart);
          draggable.addEventListener('dragend', handleDragEnd);
        }
      });
    }
  });



4. 커스텀 드래그 고스트 이미지 추가하기

draggable=true 요소를 드래그하면 해당 요소가 ghost image로 커서에 딸려나온다. 하지만 우리는 draggable=true인 부분이 해당 요소 그 자체가 아니므로 고스트 이미지를 따로 추가해줘야 한다.
고스트 이미지 추가를 위해 사용할 메서드는 setDragImage이다.

ev.dataTransfer.setDragImage(Element, x, y);

여기서 이미지를 추가하는게 아니라 HTMLElement를 추가하므로

HTMLElement를 클론을 통해 만든 후에 appendChild는 해주되 화면에서 보이지 않도록 한다.

  const makeCustomGhost = (target: Element) => {
    const ghost = target.cloneNode(true) as HTMLElement;
    ghost.classList.add('ghost');
    // 화면에서 가려주기 위해 스타일을 추가
    ghost.style.position = 'absolute';
    ghost.style.top = '-1000px';
    ghost.style.left = '-1000px';
    return ghost;
  };

  // draggable을 기준으로 부모 element 찾기
  const getDragItem = (target: Element) => {
    if (areaRef.current && target) {
      const dragItems = [...areaRef.current.querySelectorAll('.dragitem')];
      const dragItem = dragItems.find((el) => el.contains(target));
      return dragItem;
    }
    return undefined;
  };

  const handleDragStart = (ev: DragEvent) => {
    const draggable = ev.currentTarget as Element;
    const dragItem = getDragItem(draggable);
    if (!dragItem) return;

    const ghost = makeCustomGhost(dragItem);
    ghostRef.current = ghost;
    areaRef.current?.appendChild(ghost);

    ev.dataTransfer?.setDragImage(ghost, 0, 0);
  };

  const handleDragEnd = (ev: DragEvent) => {
    const draggable = ev.currentTarget as Element;
    const dragItem = getDragItem(draggable);
    if (!dragItem) return;
    dragItem.classList.remove('dragging');
  };

여기까지 하면 드래그는 되는데 커서 위치와 포인터가 살짝 어색하다.

위치를 조정하고 커서를 변경해보자



4-1. 드래그되는 위치 조정하기

setDragImage에서 offset을 (0,0)으로 잡아서 발생한다. offset을 요소의 크기에 맞게 수정해주자.

  const getOffset = (draggable: Element, item: Element) => {
    const draggableRect = draggable.getBoundingClientRect();
    const itemRect = item.getBoundingClientRect();

    return {
      x: Math.ceil(
        draggableRect.width / 2 + draggableRect.left - itemRect.left
      ),
      y: Math.ceil(draggableRect.height / 2 + draggableRect.top - itemRect.top),
    };
  };

  const handleDragStart = (ev: DragEvent) => {
    const draggable = ev.currentTarget as Element;
    const dragItem = getDragItem(draggable);
    if (!dragItem) return;

    const ghost = makeCustomGhost(dragItem);
    ghostRef.current = ghost;

    const { x, y } = getOffset(draggable, dragItem);

    // 나중에 해제해야되기 때문에 저장
    areaRef.current?.appendChild(ghost);
    ev.dataTransfer?.setDragImage(ghost, x, y);
  };



0개의 댓글