react-draggable Drag&Drop 구현

요들레이후·2023년 6월 19일
4

프로젝트

목록 보기
3/5

이번 프로젝트에서 채팅 모달창이 연결되는 플로팅 버튼을 구현했어야했다.
관리자와의 채팅 기능을 매 페이지에 띄워놓아야 했고, 고정되어 있다면 사용자의 UX에 좋지 않을 것이라 판단되어 Drag & Drop으로 구현하기로 마음 먹었다. 그리고 이 글은 react-draggable라이브러리로 Drag & Drop을 구현하며 생긴 버그와 해결 과정을 정리해 놓은 글이다.

1. react-draggable을 사용한 이유

일단 우리팀은 모바일 웹 기반 서비스를 제공하자고 기획했으며, 웹으로 페이지에 접근해도 모바일 화면 캔버스 사이징을 지정해 놓고 개발을 시작했다.
하지만 이렇게 개발을 시작 했을 때의 단점이 플로팅 버튼 같은 경우 position: fixed;가 전체 브라우저 크기에 맞춰져서 scss 미디어쿼리 믹스인을 사용하여 매 화면 크기마다 위치를 지정해줬어야 했다.
그래서 순수 자바스크립트로 수직좌표, 수평좌표 컴포넌트의 위치를 계산하기엔 귀찮을 것 같단 생각이 들었고, 여러 라이브러리를 찾다가 react drag&drop을 제공해주는 라이브러리 3가지를 찾게 되었다.

  • react-draggable
    react-draggable은 드래그로 아이템 간의 순서를 변경하는 기능보다, 컴포넌트를 드래그해서 위치를 옮기는 기능에 강점이 있는 라이브러리라고 한다.

  • react-dnd
    react-dnd는 Drag and Drop 기능을 사용할 수 있게 해주는 hook을 제공해주는 라이브러리다.
    해당 라이브러리 Overview에 따르면 react-dnd 라이브러리 내부에서 자체적으로 redux를 사용한다. 그렇기 때문에 react-dnd 개념 중 일부는 flux와 redux 아키텍쳐와 유사하다고 한다.

  • react-beautiful-dnd
    react-beautiful-dnd는 react-dnd에서 조금 더 확장된 느낌으로 UI/UX 또는 퍼포먼스가 좋은 동작이 미리 정의되어있다는 것이 react-dnd와의 차이점이자 특징이다.
    이처럼 좀 더 편리한 기능을 제공해주기 때문에 react-dnd보다 용량이 약 2배 많다.

내가 구현할 기능은 컴포넌트를 드래그해서 위치를 옮기는 기능이었기에, 사용법이 더 복잡한 react-dnd와 react-beautiful-dnd를 사용할 필요가 없었기에 react-draggable을 선택하였다.

2. 구현 방법

1. 라이브러리 설치

npm install react-draggable

2. 사용하고자 하는 컴포넌트에 선언

  • Draggable은 드래그하고자 하는 컴포넌트의 최상위로 감싸준다.
  • DraggableData는 라이브러리가 제공해주는 위치 정보이다.
import Draggable, { DraggableData } from 'react-draggable';

...
  • DraggableData는 이렇게 구성되어있었다.
export interface DraggableData {
  node: HTMLElement,
  x: number, y: number,
  deltaX: number, deltaY: number,
  lastX: number, lastY: number
}

3. 드래그 하려는 컴포넌트를 Draggable로 감싼다.

  • 필요한 옵션과 함수를 props에 전달
  • x, y 좌표를 소수점을 제외한 값을 표시
  • 내가 초반에 사용한 API는 position, onDrag이다.
    • position: {x: number, y: number}을 사용하면 라이브러리가 계산해서 제공해주는 위치를 반환해준다.
    • onDrag: DraggableEventHandler 드래그가 발생할 경우 전달된 드래그 핸들을 자동으로 호출하는 이벤트 리스너이다. 드래그가 되는 동안의 이벤드 핸들러를 전달해준다.

function FloatingButton() {
  const [position, setPosition] = useState({ x: 0, y: 0 });

  ...

  const handleOnDrag = (data: DraggableData) => {
    setPosition({ x: data.x, y: data.y }); // 드래그를 하는 동안 컴포넌트의 위치를 업데이트 해준다.
  };

  ...

  return (
    <>

      ...
      {!hidden && (
        <Draggable
          position={{ x: position.x, y: position.y }} // 업데이트된 컴포넌트의 위치를 설정해준다.
          onDrag={(_, data) => handleOnDrag(data)} // 드래그 중일 때 제어
        >
          <div className={styles.floatingButtonContainer}>
            <button
              className={styles.floatingButton}
              onClick={handleOpenChatModal}
            >
              <div className={styles.chatIcon}>
                <FloatingChat />
              </div>
            </button>
          </div>
        </Draggable>
      )}
    </>
  );
}

export default FloatingButton;

3. 이슈 및 해결방안

📌 이슈

1. 브라우저에서 드래그와 클릭 이벤트가 동시에 발생하는 이슈
버튼을 클릭하면 채팅 모달창이 뜨고, 만약 로그인이 안 된 유저라면 로그인 페이지로 라우팅 처리 했다.
아래 영상을 보면 드래그를 하고 드롭을 하자마자 로그인 페이지로 이동하는 모습을 볼 수 있다.
이는 드래그를 드롭하자마자 클릭 이벤트가 발생한다는 것인데, 드래그와 클릭 이벤트가 동시에 발생하는 것을 알 수 있다.

2. 모바일에서 클릭 이벤트가 먹히지 않는 이슈
같은 코드상에서 모바일로 실행을 했을 때는 드래그는 되지만 클릭이 되질 않았다.

즉 내가 고려해야하는 문제는 적절한 이벤트 충돌 방지 처리와 모바일에서 onTouch이벤트를 적용하는 것이었다.

📌 해결

1. <Draggable>에서 제공하는 API onStop과 useState로 flag변수를 선언하여 드래그 이벤트를 제어해준다.

  • onStop: DraggableEventHandler 드래그가 중지되면 트리거하는 이벤트 리스너이다. 드래그가 끝날 때의 이벤드 핸들러를 전달해준다.
  • useState로 드래그를 하는 동안에 true, 드래그가 끝날 때 false를 나타내는 boolean type의 flag변수를 지정해준다.
  • 💡(핵심 포인트) onStop이벤트 핸들러에서 flag변수를 0.1초 뒤에 업데이트 시켜주는 이유
    • setTimeout함수를 사용하여 비동기적으로 isDragging변수를 업데이트 하면, 드래그가 완전히 종료된 후에 업데이트가 이루어진다. 따라서 정확한 드래그 상태를 파악할 수 있어 드래그 동작과 클릭 이벤트의 충돌을 방지하고 순차적으로 드래그 이벤트와 클릭 이벤트가 동작할 수 있는 것이다.

2. 모바일일 때에도 Touch가 가능하게 onTouchEnd이벤트 리스너를 넣어주어 해결한다.

  • onTouchEnd 화면에서 손을 뗄 때 발생하는 이벤트를 등록한다. 기존의 onClick으로 등록한 이벤트 핸들러와 같은 이벤트를 등록해주었다.
import Draggable, { DraggableData, DraggableEvent } from 'react-draggable';
import { useEffect, useState } from 'react';

...

function FloatingButton() {
  ...
  const [isDragging, setIsDragging] = useState(false);
  const [position, setPosition] = useState({ x: 0, y: 0 });
  
  ...

  const handleOpenChatModal = () => {  // onClick 이벤트 핸들러
    if (isDragging) return;  // 드래그 중일 때는 함수를 실행시키지 않도록 한다.
    if (!userEmail) {
      navigate('/login');
      return;
    }
    if (userEmail === 'elliseusobanggwan@gmail.com') {
      dispatch(openChatListModal());
    } else dispatch(openChatModal());
  };

  const handleOnDrag = (data: DraggableData) => { // 드래그 중일 때 이벤트 핸들러
    setIsDragging(true);  // 드래그 중일 때 flag변수를 true로 업데이트

    setPosition({ x: data.x, y: data.y });
  };
  
  const handleStopDrag = () => {  // 드래그가 끝날 때 이벤트 핸들러
    setTimeout(() => {
      setIsDragging(false);   // 드래그가 끝날 때 flag변수를 false로 업데이트 해준다.
    }, 100);
  };

...

  return (
    <>
      ...
      
      {!hidden && (
        <Draggable
          position={{ x: position.x, y: position.y }}
          onDrag={(e, data) => handleOnDrag(e, data)} // onDrag 이벤트 리스너
          onStop={handleStopDrag}  // onStop 이벤트 리스너
        >
          <div className={styles.floatingButtonContainer}>
            <button
              className={styles.floatingButton}
              onClick={handleOpenChatModal}
              onTouchEnd={handleOpenChatModal}
            >
              <div className={styles.chatIcon}>
                <FloatingChat />
              </div>
            </button>
          </div>
        </Draggable>
      )}
    </>
  );
}

export default FloatingButton;

추가적으로..
프론트엔드 오카방에 처음으로 질문을 달았을 때 이벤트 전파에 대해 말씀하시고 스택오버플로우에도 드래그와 클릭 이벤트 관련해서 e.stopPropagation을 넣어서 해결해보라고 했어서 이벤트 전파의 문제일수도 있겠구나 생각을 했었다. 또한 Draggable태그가 click이벤트가 일어나는 태그를 감싸고 있어서 더더욱 그럴 수도 있겠구나 생각이 들었다.
그래서 모든 이벤트 핸들러 함수에 e.stopPropagation를 넣어주었고 프로젝트 발표 질문에도 이벤트 전파 문제라고 대답을 했었는데 사실 이벤트 전파의 문제는 아니었다. 아우 쪽팔려.. 대답 녹화본 삭제해줘 엘리스..
e.stopPropagation을 넣지 않아도 드래그와 터치가 순차적으로 잘 이루어지는 것을 확인했고, 이로 인해 내가 이벤트 버블링과 캡쳐링 개념이 잘 잡혀있지 않았구나 라는 생각이 들게 되었다. 프로젝트 관련 글들을 작성하고 나서 FE지식으로 이벤트 관련해서 글을 작성해야겠다.

참고 자료
리액트 Draggable 터치 감지 이슈 관련
이벤트 버블링 및 캡쳐링 개념
같은 문제를 겪고 있는 스택오버플로우 질문
리액트 터치 이벤트 공식문서

profile
성공 = 무한도전 + 무한실패

0개의 댓글