본문 바로가기
Web/Frontend

[리액트] 드래그 앤 드롭, 리사이징 리스트 만들기

by r4bb1t 2020. 6. 2.
반응형

결과물 미리보기

 

 

개요 |

드래그 앤 드롭으로 순서 변경 및 리사이징까지 가능한 리스트를 만들어보았다. 코드 예시는 맨 밑에 있다.


모듈 설치 |

우선 React DnD 모듈을 사용하기로 했다.

더보기

 

그래서 후기 쓰는 중

 

$yarn add react-dnd react-dnd-html5-backend immutable

 

드래그 앤 드롭을 원활하게 사용하기 위해 react-dnd 모듈을, Card들을 배열로 관리하기 용이하게 immutable 모듈을 설치해준다.


컴포넌트 만들기 |

 

- Table

Table이라는 전체 총괄 컴포넌트 안에 Card 컴포넌트 여러 개가 있고, 카드의 위치 변경이나 리사이즈 등 변화는 전부 Table에서 관리한다.

Card에 props로 주는 정보는 카드의 id, 카드의 내용 (children), 카드의 크기(height) 세 가지다.

 

const cardItems = List([
  Map({
    id: 0,
    children: "고양이다.",
    height: 3,
  }),
  Map({
    id: 1,
    children: "인생은 인생이다",
    height: 2,
  }),
  Map({
    id: 2,
    children: "냥냔냥",
    height: 2,
  }),
  Map({
    id: 3,
    children: "열라면에 다진마늘",
    height: 2,
  }),
  Map({
    id: 4,
    children: "Life is life!",
    height: 1,
  }),
]);

 

Table에서 관리하는 초기 Card들의 정보이다. children은 전부 string으로 써놓았지만, 다른 자료형이나 컴포넌트를 넣어 사용할 수 있다.

height는 각 숫자가 비율을 나타낸다. 즉 height가 2면 표시되는 Card는 총 Table 높이의 20%이다.

 

const [cards, setCards] = useState(cardItems);

 

당연히 cards는 state로 관리한다.

 

return (
  <>
    <S.Table>
      {cards.map((card, i) => (
        <Card
          key={i}
          height={card.get("height")}
          id={card.get("id")}
          moveCard={moveCard}
          findCard={findCard}
          children={card.get("children")}
        />
      ))}
    </S.Table>
  </>
);

 

cards.map을 이용해 각 카드들을 표시해준다. Card 컴포넌트는 position: relative로 둬서 알아서 순서대로 쌓이게 해준다.

moveCard와 findCard는 카드 순서를 변경할 때 Card에서 호출해줄 함수다.


 

카드 순서 변경 |

 

- Table

const findCard = (id: any) => {
  const card = cards.filter((c) => c.get("id") === id).get(0);
  return {
    card,
    index: cards.indexOf(card),
  };
};

 

해당 id를 가진 카드의 cards 내의 index, 즉 현재 순서를 찾는 함수다. 그 카드와 위치를 반환한다.

 

const moveCard = (id: number, toIndex: number) => {
  const { card, index } = findCard(id);
  let newCards = cards;
  newCards = newCards.splice(index, 1).splice(toIndex, 0, card);
  setCards(newCards);
};

 

해당 id의 카드를 목표 index를 가진 카드와 바꾸는 함수다.

이 두 함수를 Card 컴포넌트에 props로 준다.

 

- Card

 

interface Item {
  type: string;
  id: number;
  originalIndex: number;
}

 

드래그 아이템의 interface이다. 적당히 선언해준다.

 

const originalIndex = findCard(id).index;
const [{ isDragging }, drag, preview] = useDrag({
  item: { type: "card", id, originalIndex },
  collect: (monitor) => ({
    isDragging: monitor.isDragging(),
  }),
  end: (dropResult, monitor) => {
    const { id: droppedId, originalIndex } = monitor.getItem();
    const didDrop = monitor.didDrop();
    if (!didDrop) {
      moveCard(droppedId, originalIndex);
    }
  },
});

 

originalIndex는 카드를 화면 밖에서 드롭했을 때 원상복귀시키기 위해 체크해둔다.

react-dnd의 useDrag 함수를 쓴다.

item은 드래그되는 엘리먼트의 정보인데, type은 "card"로, 나머지는 그대로 넣어준다.

collect에서 isDragging을 가져온다. 현재 엘리먼트가 드래그되고 있는지 여부를 반환하는데, 드래그 중이면 색을 변하게 한다던가 하는 부분에 사용할 수 있다.

end는 드래그가 끝날 때 실행되는 함수인데, 만약 제대로 drop이 가능한 엘리먼트(즉, 다른 카드)에 드롭되지 않고 드래그가 끝났으면 originalIndex로 다시 돌아간다.

그럼 카드 순서는 언제 바뀌느냐? drop 이벤트에서 바꿔준다.

 

const [{ isOver }, drop] = useDrop({
  accept: "card",
  collect: (monitor) => ({
    isOver: monitor.isOver(),
  }),
  hover({ id: draggedId }: Item) {
    if (draggedId !== id) {
      const { index: overIndex } = findCard(id);
      moveCard(draggedId, overIndex);
    }
  }
});

 

accept는 어떤 엘리먼트가 자신 위로 drop되는 것을 허용할지 설정한다. 위의 drag에서 item의 type을 "card"로 설정했으니 똑같이 "card"로 써준다.

isOver은 다른 카드가 현재 엘리먼트 위로 지나가고 있는지를 반환하는데, 이것도 역시 hover 중인 것을 표시할 때 쓸 수 있다. 아래 예시 코드에서는 isOver이 true면 배경이 하늘색으로 변한다.

드래그 중인 엘리먼트와 닿은 엘리먼트가 실시간으로 위치가 바뀌게 하려면 hover 이벤트에 moveCard를 실행시켜주면 된다.

만약 drop시에만 위치가 바뀌게 하고 싶다면 hover을 drop으로 바꿔주면 된다. 디자인에 따라, 취향에 따라 설정하면 될 것 같다.

 

return (
  <>
    <S.Card ref={(node) => drop(preview(node))} isDragging={isDragging} isOver={isOver} height={height}>
      <S.DragHandle ref={(node) => drag(node)}>
        <DragHandleIcon style={{ color: "lightgray" }} />
      </S.DragHandle>
      id : {id} <br /> children : {children}
    </S.Card>
  </>
);

 

drag ref는 카드 전체가 아니라 DragHandle에만 주고, 카드에는 drop ref를 준다.

여기까지만 하면 순서 변경이 가능한 리스트가 된다!

 


 

리사이즈 |

 

사실 순서 변경은 좀 수월하게 짰는데 (공식 문서에 참고할 만한 예시가 있어서..) 리사이즈가 조금 어려웠다.

 

- Table

const [, resizeDrop] = useDrop({
  accept: "resize",
  hover(item, monitor) {
    const { index, height } = monitor.getItem();
    let sum = cards.getIn([index, "height"]) + cards.getIn([index + 1, "height"]);
    let temp = Math.max(Math.round(height + monitor.getDifferenceFromInitialOffset().y / 100), 1);
    if (temp < sum) {
      let newCards = cards;
      newCards = newCards.setIn([index, "height"], temp);
      newCards = newCards.setIn([index + 1, "height"], sum - temp);
      setCards(newCards);
    }
  },
});

 

이번에는 Card가 아니라 Table에 useDrop을 써주는데, accept는 "card"가 아니라 "resize"다. "card"를 받으면.. 순서변경 drop 할 때도 이벤트가 실행되지 않을까?... 아무튼...

hover되면서 height가 변하는게 자연스러우므로 역시 drop이 아니라 hover 이벤트에 넣는다.

선택한 Card의 height가 줄어들면, 바로 아래에 있는 Card의 height는 늘어나면서 두 카드의 height의 합은 유지해야 한다.

그래서 그 합을 sum에 미리 넣어두고, 선택한 Card의 height와 함께 바로 아래 Card의 height도 맞추어서 수정해주는 것이다.

height는 무조건 정수가 되게 하려고 (내 맘임) Math.round를 사용했고, 최소 1 이상을 적용하기 위해 Math.max를 썼다.

monitor.getDifferenceFromInitialOffset()는 드래그 중인 엘리먼트가 최초의 위치에서 얼마나 이동했는지를 알려주는데, 이게 pixel 단위라서 대충 100으로 나눴더니 자연스럽길래 그냥 냅뒀다... 이건 내가 height를 percent로 줘서 그렇다.

 

- Card

 

const [{ isDragging: isResizing }, resize, resizePreview] = useDrag({
  item: { type: "resize", index: findCard(id).index, height },
  options: {
    dropEffect: "copy",
  },
  collect: (monitor) => ({
    isDragging: monitor.isDragging(),
  }),
});

 

위의 순서 변경하는 useDrag랑 비슷하다.

 

return (
  <>
    <S.Card ref={(node) => drop(preview(node))} isDragging={isDragging} isOver={isOver} height={height}>
      <S.DragHandle ref={(node) => drag(node)}>
        <DragHandleIcon style={{ color: "lightgray" }} />
      </S.DragHandle>
      id : {id} <br /> children : {children}
      {isResizable ? (
        <S.BorderLine isResizing={isResizing} ref={(node) => resize(node)}>
          <DragPreviewImage connect={resizePreview} src={"nullPreview.png"} />
          <S.ResizeHandle ref={(node) => resize(node)}>
            <HeightIcon style={{ color: "lightgray" }} />
          </S.ResizeHandle>
        </S.BorderLine>
      ) : (
        <></>
      )}
    </S.Card>
  </>
);

 

맨 밑 카드같은 경우 사이즈 조절이 가능하면 좀 이상하니까 isResizable을 props로 미리 넣어주기로 한다.

return 안에 isResizable이면 구분선과 리사이즈 행들을 넣어주고, resize drag ref도 이 핸들에 달아준다.


결과물 |

CodeSandBox를 이용해보았다.

 

 

끝!

반응형

댓글