W5 - 기술정리 | Drag & Drop

yisu.kim·2021년 9월 30일
0

Pre Onboarding

목록 보기
15/16
post-thumbnail

Drag & Drop

The user may select draggable elements with a mouse, drag those elements to a droppable element, and drop them by releasing the mouse button. A translucent representation of the draggable elements follows the pointer during the drag operation.

by MDN

Drag and Drop API를 사용하면 draggable 엘리먼트를 드래그해 droppable 엘리먼트에 드랍할 수 있다.

draggable과 Drag Events

draggable 속성을 통해 엘리먼트의 드래그 가능 여부를 설정할 수 있다. true일 때 드래그가 가능하다.

드래그 이벤트 중 구현 시 사용된 3가지 이벤트만 소개해 본다. 이 외에 다양한 드래그 이벤트는 HTML Drag and Drop API에서 확인할 수 있다.

  • ondragstart: 사용자가 엘리먼트를 드래그하기 시작할 때 발생하는 이벤트이다.
  • ondragend: 사용자가 엘리먼트를 더 이상 드래그하지 않을 때 발생하는 이벤트이다. 마우스 버튼을 떼거나 ESC 키를 누를 경우에 해당한다.
  • ondragenter: 사용자가 드래그한 엘리먼트가 드랍하기에 적합한 대상 위에 올라갔을 때 발생한다.

라이브러리 없이 Drag & Drop을 구현해보자

📦 codesandbox에서 실습이 가능합니다.

이 글에서는 하나의 리스트에서 아이템을 드래그하는 드래그 & 드랍 컴포넌트를 구현하려 한다.

먼저 Context API를 활용해 아래와 같은 상태를 관리해 보자. 이 상태들은 드랍 가능한 컴포넌트에서 유지된다.

  • dragging: 현재 드래그 중인지 아닌지
  • dragItemIndex: 드래그 중인 아이템의 인덱스

IDragContext 인터페이스로 필요한 상태와 액션을 정의한다.

interface IDragContext {
  state: {
    dragging: boolean;
    dragItemIndex: number | null;
  };
  actions: {
    setDragging: Dispatch<SetStateAction<boolean>>;
    updateDragItemIndex: (index: number | null) => void;
  };
}

다음으로 기본값과 함께 createContext()를 통해 DragContext를 생성한다.

const defaultValue: IDragContext = {
  state: {
    dragging: false,
    dragItemIndex: null,
  },
  actions: {
    setDragging: () => {},
    updateDragItemIndex: () => {},
  },
};

const DragContext = createContext<IDragContext>(defaultValue);

이제 상태를 하위 컴포넌트에 제공하는 Provider를 정의해 보자.

interface DragProviderProps {
  children: React.ReactNode;
}

const DragProvider: React.FC<DragProviderProps> = ({ children }) => {
  const [dragging, setDragging] = useState<boolean>(false);
  const dragItem = useRef<number | null>(null);

  const updateDragItemIndex = (index: number | null) => {
    dragItem.current = index;
  };

  const value: IDragContext = {
    state: { dragging, dragItemIndex: dragItem.current },
    actions: { setDragging, updateDragItemIndex },
  };

  return <DragContext.Provider value={value}>{children}</DragContext.Provider>;
};

마지막으로 Context의 Provider와 Consumer를 export해서 사용하기 용이하도록 한다.

const DragConsumer = DragContext.Consumer;

export { DragProvider, DragConsumer };

상태를 관리할 DragContext가 완성되었으므로 본격적으로 DragNDrop 컴포넌트를 만들어보자. 참고로 이 예제에서는 데이터 타입을 number로 가정했다.

DragNDrop 인터페이스는 다음과 같다.

  • itemArray: 전체 아이템 리스트
  • itemIndex: DragNDrop 컴포넌트가 감싸는 자식 컴포넌트(아이템)의 인덱스
  • updateItemArray(data): 드래그해서 아이템의 순서가 변경될 경우 아이템 리스트를 업데이트하기 위한 함수
  • children: 자식 컴포넌트
interface DragNDropProps {
  itemArray: number[];
  itemIndex: number;
  updateItemArray(data: number[]): void;
  children: React.ReactNode;
}

그리고 DragNDrop 컴포넌트는 다음과 같은 구조를 반환한다.

draggable 가능한 div로 전달받은 children을 감싸고 이 div는 dragstart, dragend, dragenter 이벤트를 핸들링한다. 드래그 중이면 isDraggingItem()으로 현재 드래그 중인 아이템과 인덱스가 일치하는 아이템의 스타일을 변경한다.

const DragNDrop ... => {
  ...
  const isDraggingItem = (currentIndex: number): string => {
    if (dragItemIndex === currentIndex) {
      return "dragging";
    }
    return "";
  };

  return (
    <div
      draggable
      onDragStart={(e) => handleDragStart(e, itemIndex)}
      onDragEnd={handleDragEnd}
      onDragEnter={(e) => handleDragEnter(e, itemIndex)}
      className={`draggable ${dragging && isDraggingItem(itemIndex)}`}
    >
      {children}
    </div>
  );
}

이제 처음으로 돌아가자. 먼저 useContext()로 아까 만든 DragContext를 가져온다. 그리고 드래그 중인 노드를 저장하기 위해 dragNode 변수를 선언한다.

const DragNDrop ... => {
  const {
    state: { dragging, dragItemIndex },
    actions: { setDragging, updateDragItemIndex }
  } = useContext(DragContext);

  let dragNode: EventTarget | null = null;
  ...
}

드래그하기 시작할 때 발생하는 이벤트를 핸들링하기 위해 handleDragStart()를 작성해 보자. DragEvent와 아이템의 index를 각각 인자로 받는다. DragEvent의 target은 dragNode에 저장하고 아이템 index는 Context의 updateDragItemIndex에 전달해 갱신한다.

dragging 상태를 true로 변경하는 부분을 setTimeout에 넣은 이유는 드래그를 시작할 때 반투명하게 마우스 포인트를 따라다니는 draggable 엘리먼트의 스타일에 드래그 시작 전 스타일이 적용되게 하기 위해서이다.

const handleDragStart = (e: DragEvent<HTMLDivElement>, dragIndex: number) => {
  updateDragItemIndex(dragIndex);
  dragNode = e.target;
  setTimeout(() => {
    setDragging(true);
  }, 0);
};

드래그가 끝나고 드랍할 때 발생하는 이벤트를 핸들링하기 위해 handleDragEnd()를 작성해 보자. dragItemIndex와 dragNode를 모두 null로 만들고 dragging 상태를 false로 설정하면 된다.

const handleDragEnd = () => {
  updateDragItemIndex(null);
  dragNode = null;
  setDragging(false);
};

마지막으로 드래그한 엘리먼트가 드랍하기에 적합한 대상 위에 올라갔을 때 발생하는 이벤트를 핸들링하기 위해 handleDragEnter()를 작성해 보자. 현재 대상 엘리먼트가 드래그 중인 엘리먼트가 아닐 때 드랍할 위치에 있는 아이템 인덱스를 dragItemIndex로 갱신하고 itemArray는 splice를 사용해 업데이트한다.

const handleDragEnter = (e: DragEvent<HTMLDivElement>, dropIndex: number) => {
  if (!dragging) {
    return;
  }
  if (e.target !== dragNode && dragItemIndex !== null) {
    updateDragItemIndex(dropIndex);
    const newItemArray = [...itemArray];
    const [dragItem] = newItemArray.splice(dragItemIndex, 1);
    newItemArray.splice(dropIndex, 0, dragItem);
    updateItemArray(newItemArray);
  }
};

이제 완성된 DragNDrop 컴포넌트를 실제로 활용해 보자.

먼저 Context로 만든 DragProvider로 드래그 & 드랍 기능을 사용할 리스트 컴포넌트를 감싼다. 그리고 리스트의 아이템은 DragNDrop 컴포넌트로 감싸고 알맞은 props를 넘겨주면 완성된다.

interface ListProps {
  datas: number[];
  handleListItems: (datas: number[]) => void;
}

const List: React.FC<ListProps> = ({ datas, handleListItems, enableDrag }) => {
  return (
    <DragProvider>
      <ul className='list'>
        {datas.map((data, index, array) => (
          <DragNDrop
            key={index}
            itemArray={array}
            itemIndex={index}
            updateItemArray={handleListItems}
          >
            <Item data={data} />
          </DragNDrop>
        ))}
      </ul>
    </DragProvider>
  );
};

완성!

참고자료

잘못된 부분에 대한 지적은 언제든 환영합니다! 😉

0개의 댓글