안녕하세요. 오늘은 React-DND기능을 사용했던 경험에 대해서 공유하려고 합니다. 강의의 순서를 변경하는데 사용했었는데, DND기능을 처음 구현하는 것인 이유도 있었고, hooks를 사용한 코드와 사용하지 않은 코드가 존재했었기에 많이 헷갈려했던 기억이 있습니다.

Sep-29-2019 21-44-02.gif

이랬던 하위스텝 순서이동기능이

Sep-29-2019 21-48-35.gif
이렇게 변했습니다!

DND가 무엇이냐?

Drag And Drop 을 뜻합니다. React-DND 라이브러리는 컴포넌트를 분리 된 상태로 유지하면서 복잡한 드래그 앤 드롭 인터페이스를 구축하는 데 도움이되는 React 유틸리티 세트입니다. 드래그하면 응용 프로그램의 서로 다른 부분간에 데이터를 전송하고 구성 요소가 드래그 앤 드롭 이벤트에 따라 모양과 응용 프로그램 상태를 변경하는 것을 직접 제어할 수 있도록 도와주는 라이브러리입니다.

DND기능 제작

제가 만들어야 할 기능은 여기 에서 제공해주는 예시와 별반 다를 것이 없었습니다. 다만 드래그가 가능한 영역이 다르고, 드롭하는 순간 서버와 연동해서 데이터가 조작되어야 한다는 변경점이 있었을 뿐입니다. 저는 제가 처음에 만든 강의 카드를 랜더링해주는 컴포넌트를 사용해서 제작하였습니다.

아래는 제가 어떻게 동작하는지 파악하기 위해서 위의 예시를 제가 사용할 수 있도록 수정하고, 어떤 동작이 이루어지는지 코드를 파악해서 주석으로 정리한 내용입니다. 편의를 위해서 제가 사용하는 컴포넌트 코드는 모두 제거하였습니다.

import React, { useImperativeHandle, useRef } from "react";
import { DragSource, DropTarget } from "react-dnd";
import ItemTypes from "./ItemTypes";

const LowerStepCard = React.forwardRef(
  (
    {
      lowerStep,// 부모 컴포넌트에서 가져온 강의 데이터
      index,
      isDragging,
      connectDragSource,
      connectDropTarget,
    },
    ref
  ) => {
    const elementRef = useRef(null);
    connectDragSource(elementRef);
    connectDropTarget(elementRef);
    const opacity = isDragging ? 0 : 1;
    useImperativeHandle(ref, () => ({
      getNode: () => elementRef.current,
    }));
    return (
      <Grid item xs={12} ref={elementRef}>
        <{시용할 컴포넌트}>
      </Grid>
    );
  }
);

export default DropTarget(
  ItemTypes.CARD, // 아이템 타입 정의 필요
  {
    hover(props, monitor, component) {
      // 현재 드래그중인 아이템의 parentStep이 다르다면 리턴
      if (props.currentStepId !== monitor.getItem().currentStepId) return null;
      if (!component) return null;
      //node는 imperative API의 HTML div 요소
      const node = component.getNode();
      if (!node) return null;
      // 현재 드래그중인 아이템의 인덱스
      const dragIndex = monitor.getItem().index;
      // 호버된 인덱스 hover이벤트는 호버가 되는 순간 일어나기 때문에
      // props는 마우스가 올라간 순간의 컴포넌트 카드의 props를 가져옴
      const hoverIndex = props.index;
      // 자기들끼리 바꾸지 못하게 만듬
      if (dragIndex === hoverIndex) {
        return;
      }
      // 스크린에서 위치를 가져옴
      const hoverBoundingRect = node.getBoundingClientRect();
      // 호버되는 element의 수직적 중간위치
      const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
      // 마우스의 위치 가져오기
      const clientOffset = monitor.getClientOffset();
      // 사용자의 마우스 위치에서
      const hoverClientY = clientOffset.y - hoverBoundingRect.top;
      // Dragging downwards
      // index, 마우스의 위치가 모두 hover된 것의 이전이면 그대로
      if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
        return;
      }
      // Dragging upwards
      // index, 마우스의 위치가 모두 hover된 것의 이후면 그대로
      if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
        return;
      }
      // 둘다 충족된다면 moveCard함수 실행(state를 변환시켜주어 바꾸어 랜더링 해 줄 수 있음)
      props.moveCard(dragIndex, hoverIndex);
      monitor.getItem().index = hoverIndex;
      // hover되는 곳에는 비싼 연산을 넣지 말아야함!
    },
  },
  connect => ({
    connectDropTarget: connect.dropTarget(),
  })
)(
  DragSource( 
    ItemTypes.CARD,
    {
      // 아래의 props들은 부모의 props입니다.
      beginDrag: props => ({
        id: props.lowerStep.id,
        index: props.index,
        currentStepId: props.currentStepId,
      }), //hover 의 moniter.getItem()함수를 실행했을 때의 객체
      endDrag: props => props.{ServerInteraction}(args),
      //드래그가 끝났을 때의 뮤테이션
    },
    (connect, monitor) => ({
      connectDragSource: connect.dragSource(),
      isDragging: monitor.isDragging(),
    })
  )(LowerStepCard)
);

드래그가 시작되었을 때, 드래그 중일때, 드래그가 끝났을 때 어떻게 동작하는지 파악하려고 노력하며 코드를 작성했던 것으로 기억합니다.

hooks와 TypeScript를 도입한 DND 컴포넌트로 변경하기

위의 DND 기능을 만들고 얼마 지나지 않아 JS로 제작된 코드들을 TS로 변경하기로 했습니다. 컴파일 과정에서 에러를 체크해서 개발속도가 빨라진다는 장점과 type에 대한 에러를 방지할 수 있다는 장점을 보고 도입하기로 한 것입니다. TypeScript로 제작된 위의 예제는 또 달랐습니다.

import React from "react";
import { Link } from "react-router-dom";
//type
import { LowerStepCardProps, Item } from "./LowerStepCard.type";
//react-dnd
import { useDrag, useDrop } from "react-dnd";
import ItemTypes from "./ItemTypes";

const LowerStepCard: React.FC<LowerStepCardProps> = ({
  lowerStep,
  moveCard,
  findCard,
  coursePath,
  stepPath,
  currentStepId,
  classes,
  lowerStepInfoList,
  updateLowerStepIndex,
  handleRemove,
}) => {
  const lowerStepId: string = `${lowerStep.id}`;

  const originalIndex: number = findCard(lowerStepId).index;

  const [{ isDragging }, drag] = useDrag({// 드래그가 되는 item
    item: { type: ItemTypes.CARD, lowerStepId, originalIndex, currentStepId },
    collect: monitor => ({ // 드래그 중일때 실행될 함수
      isDragging: monitor.isDragging(),
    }),
    end() {// 드래그가 끝나면 실행될 함수
      updateLowerStepIndex(lowerStepInfoList);
    },
  });

  const [, drop] = useDrop({
    accept: ItemTypes.CARD,
    canDrop: () => false,
    hover({ lowerStepId: draggedId }: Item, monitor) {
      if (currentStepId !== monitor.getItem().currentStepId) return null;
      if (draggedId !== lowerStepId) {
        const { index: overIndex } = findCard(lowerStepId);
        moveCard(draggedId, overIndex);
      }
    },
  });

  const opacity = isDragging ? 0 : 1;
  return (
    <Grid
      item
      xs={12}
      style={{ opacity }}
      ref={node => drag(drop(node))}
      className={classes.lowerStepWrapper}
    >
      <{사용할 컴포넌트}>
    </Grid>
  );
};

export default LowerStepCard;

어떤가요? 좀 더 간편해지고 보기 좋아지지 않았나요? 저는 예제 코드를 보고 제가 사용하는 컴포넌트를 넣었다 뿐이지 자세히 내부동작이 어떻게 흘러가는지는 모두 살펴보지 못했습니다. 제가 사용한 기능보다 훨씬 많은 기능들이 React-DND라이브러리에서 사용되기도 하구요.

마무리

자세한 내용은 공식문서를 참조하시면 좋을 것 같습니다. 각각 사용되는 메서드의 이름은 왜 저런지, 메서드가 정확하게 어떤 일을 하는지는 공식문서를 보시면 알 수 있습니다 😃 좀 개인적인 팁을 드리자면, 처음에 주어지는 예시는 너무 어려우니 다른 예시를 보여 직접 따라 쳐 보고 자신의 컴포넌트에 도입하는 것을 추천합니다. 정말 불친절한 글임에도 불구하고 끝까지 봐주셔서 감사합니다.