[React] Drag and Drop 기능 구현하기

dosilv·2021년 6월 26일
74
post-thumbnail
post-custom-banner

요소를 드래그 가능하게 만들고, 특정 영역에 드랍하면 관련 데이터를 transfer해서 새로운 요소를 생성하는 기능을 구현해 보았다 (+요소간 데이터 비교해서 제일 큰 값 하이라이트 주기..!)



💻 1. 드래그와 관련된 이벤트

🖱 onDragStart

드래그를 시작하는 순간 실행되는 이벤트. 다음과 같은 함수를 걸어줬다. 옆에 숫자는.. 이해를 돕기 위한...

1  const dragStartHandler = e => {
2   const img = new Image();
3   e.dataTransfer.setDragImage(img, 0, 0);
4
5   posX = e.clientX;
6   posY = e.clientY;
7
8   originalX = e.target.offsetLeft;
9   originalY = e.target.offsetTop;
10  };

2, 3 👉 요소에 draggable 속성을 주면 드래그했을 때 요소가 반투명하게 따라오는데 그걸 없애기 위해 빈 이미지를 생성해 setDragImage로 넣어준 것
5, 6 👉 드래그요소를 실제로 이동시키기 위해 필요한 코드 (💻 2.에서 설명)
8, 9 👉 드래그하던 요소를 드랍했을 때 원래 위치로 돌려놓기 위해 초기 위치값을 저장함


🖱 onDrag

드래그 중일 때 실행되는 이벤트. onDragStart 직후부터 onDragEnd 직전까지 계속 호출됨!

1  const dragHandler = e => {
2    e.target.style.left = `${e.target.offsetLeft + e.clientX - posX}px`;
3    e.target.style.top = `${e.target.offsetTop + e.clientY - posY}px`;
4    posY = e.clientY;
5    posX = e.clientX;
6  };

2~6 👉 드래그커서의 움직임에 따라 요소를 이동시키기 위해 필요한 코드 (💻 2.에서 설명)


🖱 onDragEnd

마우스를 놓았을 때 드래그가 끝나면서 실행되는 이벤트! 여기서 해야할 건
(1) 올바른 영역 안에서 드랍 되었는지 체크 후 새로운 요소 생성하기
(2) 드래그하며 움직였던 요소 제자리에 돌려놓기

1  const dragEndHandler = e => {
2    if (
3      box.left < e.clientX &&
4      e.clientX < box.right &&
5      box.top < e.clientY &&
6      e.clientY < box.bottom
7    ) {
8      setTargets(targets => {
9        const newTargets = [...targets];
10       newTargets.push({
11         id: parseInt(e.timeStamp),
12         top: e.target.offsetTop + e.clientY - posY,
13         left: e.target.offsetLeft + e.clientX - posX,
14         details: STOCK_DATA[e.target.id],
15       });
16       return newTargets;
17     });
18   }
19
20   e.target.style.left = `${originalX}px`;
21   e.target.style.top = `${originalY}px`;
22 };

2~7 👉 '커서가 원하는 영역 안에 들어오면'을 표현한 조건. box는 요소를 드랍할 수 있는 영역의 위치정보를 아래처럼 getBoundingClienRect를 이용해 담아놓은 것..!

//drop할 영역이 위치한 컴포넌트
  
const stockContainer = useRef();
const [targets, setTargets] = useRecoilState(targetsState);

useEffect(() => {
  const box = stockContainer.current.getBoundingClientRect();
  setBox({
    top: box.top,
    left: box.left,
    bottom: box.top + box.height,
    right: box.left + box.width,
  });
}, []);

recoil을 쓴 이유는 드래그할 요소들이 위치한 컴포넌트와 드랍할 타겟 영역이 위치한 컴포넌트가 달라서 전역 상태관리가 필요했기 때문!

8~17 👉 커서가 타겟 영역에 들어왔으면, 드랍 당시 요소의 위치(top, left)드래그한 요소에 따라 관련된 데이터(details)를 저장해서 새로운 요소 생성하기 (💻 3.에서 설명)

20, 21 👉 요소를 드래그 onDrag에서 저장해 둔 원래 위치로 돌려놓기!



💻2. 드래그시 선택한 요소 움직이게 하기

🖱 함수 바깥에 let 변수 선언하기

let posX = 0;
let posY = 0;

let originalX = 0;
let originalY = 0;

🔍 posX, posY

드래그하며 커서가 이동할 때마다 위치를 잡아서 계산해 줘야 하는데, 그걸 시시각각 담을 수 있는 변수가 필요하다.

🔍 originalX, originalY

이건 드래그를 끝냈을 때 요소를 원래 위치로 돌려놓기 위해서 기존의 요소 left, top 좌표를 저장해 놓은 것! 그냥 이동시킨 곳에 그대로 둘 거면 필요 ❌❌

네 변수 모두 하나의 함수에서만 쓰이는 게 아니라 onDragStart, onDrag, onDragEnd에서 호출되는 함수에서 다 필요하기 때문에 함수 바깥에 전역 scope로 선언해 줌!


🖱 커서 위치 계산해서 요소 위치에 반영하기

우선 onDragStart의 콜백함수에서 드래그를 시작했을 때 커서의 위치를 posX, posY에 할당해 준다.

const dragStartHandler = e => {
  ...
  posX = e.clientX;
  posY = e.clientY;
  
  originalX = e.target.offsetLeft;
  originalY = e.target.offsetTop;
};

다음으로 onDrag의 콜백함수에서 요소(e.target)의 위치를 계산&반영해준다. 계산은 요소의 현재위치에서 드래그로 인해 변화한 커서의 위치를 반영해야 하기 때문에 [현재 요소의 좌표(left, top) + 현재 커서의 좌표 - 직전 커서의 좌표]로 해 줌!

const dragHandler = e => {
  e.target.style.left = `${e.target.offsetLeft + e.clientX - posX}px`;
  e.target.style.top = `${e.target.offsetTop + e.clientY - posY}px`;
  posX = e.clientX;
  posY = e.clientY;
};

그후 이전 onDrag시 커서의 위치였던 posX, posY를 현재 커서의 위치로 업데이트해서 다음 onDrag에서 사용할 수 있도록 한다.


그다음 onDragEnd의 콜백 함수에서 요소를 처음 위치로 다시 옮겨준다.

const dragEndHandler = e => {
  ...
  e.target.style.left = `${originalX}px`;
  e.target.style.top = `${originalY}px`;
};

만약 드래그가 끝났을 때 요소를 그냥 그 자리에 두고 싶다면 left/top에 originalX/Y 대신 onDrag에서 계산했던 것처럼 e.target.offsetLeft + e.clientX/Y - posX/Y
를 넣어 주면 된당.



💻3. 드롭 시 해당 위치에 새 요소 생성하기

새로 엘리먼트를 만드는 건... 어떻게 하지? 고민하다가 이것저것 서치해 봤는데, React.createElementcloneNode 같은 방법들이 나왔다. 근데 빈 Array를 만들어서 원하는 대로 map을 돌려 놓고, drop시마다 거기에 데이터를 추가하는 방식으로도 할 수 있을 것 같아서 구현해 봄!

따라서 새로운 요소를 생성하려면 onDropEnd에서 데이터를 전달해 줘야 한다.

사실 데이터 전달은 dataTransfer의 setData, getData를 이용해서 할 수도 있는데.. 데이터 자체가 innerText형식으로 요소 안에 내재되어 있는 게 아니라, STOCK_DATA라는 별도의 변수에서 꺼내야 하다 보니까 dataTransfer 없이도 되길래... 그냥 함.ㅎㅎ;; 대신 각 요소의 id로 인덱스를 설정하고, e.target.id로 추출해서 STOCK_DATA[e.target.id] 형식으로 데이터를 꺼내왔다.

const dragEndHandler = e => {
  if (
    box.left < e.clientX &&
    e.clientX < box.right &&
    box.top < e.clientY &&
    e.clientY < box.bottom
  ) {
    setTargets(targets => {
      const newTargets = [...targets];
      newTargets.push({
        id: parseInt(e.timeStamp),
        top: e.target.offsetTop + e.clientY - posY,
        left: e.target.offsetLeft + e.clientX - posX,
        details: STOCK_DATA[e.target.id],
      });
      return newTargets;
    });
  }
...
};

detail외에 그 밖의 요소는

  • id: 나중에 데이터 삭제할 때 필요해서 불변&고유한 값(e.timeStamp)을 설정
  • top, left: 드랍했을 때 요소의 좌표를 계산해서 전달

그리고 state의 불변성을 지키기 위해 target을 복사한 newTargets을 조작해서 리턴했다.



친절한 글을 쓰고 싶었는데..... 나만 알아볼 수 있는 기록이 된 것 같은 기분>.<;;

profile
DevelOpErUN 성장일기🌈
post-custom-banner

10개의 댓글

comment-user-thumbnail
2021년 7월 5일

갓도은님! 너무 멋있어요!!! 소중한 자료 감사합니다 :) 👍

1개의 답글
comment-user-thumbnail
2021년 7월 5일

역시... 도은님... 잘보고 갑니다~!!👍👍

1개의 답글
comment-user-thumbnail
2022년 2월 24일

안녕하세요. 왕초보 개발자입니다. 위 Gif 처럼 멋있는 drag&drop 웹을 클론코딩 해보고 싶은데 혹시 깃허브 주소나 압축파일 형태로 받을 수 있을까요? 부탁드려요.

답글 달기
comment-user-thumbnail
2022년 9월 1일

const [targets, setTargets] = useRecoilState(targetsState);

이부분에 [ targetsState ] 이거는 어디서 나오는건가요?

답글 달기
comment-user-thumbnail
2023년 5월 30일

전체 적인 코드를 알고 싶은데 가능할까요....

답글 달기
comment-user-thumbnail
2023년 8월 2일

전체적인코드를 알고싶은데 올려놓으신 git있으실까요? 아니면 압축파일로 받을수있을까요?

답글 달기