이번 프로젝트에서 채팅 모달창이 연결되는 플로팅 버튼을 구현했어야했다.
관리자와의 채팅 기능을 매 페이지에 띄워놓아야 했고, 고정되어 있다면 사용자의 UX에 좋지 않을 것이라 판단되어 Drag & Drop으로 구현하기로 마음 먹었다. 그리고 이 글은 react-draggable라이브러리로 Drag & Drop을 구현하며 생긴 버그와 해결 과정을 정리해 놓은 글이다.
일단 우리팀은 모바일 웹 기반 서비스를 제공하자고 기획했으며, 웹으로 페이지에 접근해도 모바일 화면 캔버스 사이징을 지정해 놓고 개발을 시작했다.
하지만 이렇게 개발을 시작 했을 때의 단점이 플로팅 버튼 같은 경우 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을 선택하였다.
1. 라이브러리 설치
npm install react-draggable
2. 사용하고자 하는 컴포넌트에 선언
import Draggable, { DraggableData } from 'react-draggable';
...
export interface DraggableData {
node: HTMLElement,
x: number, y: number,
deltaX: number, deltaY: number,
lastX: number, lastY: number
}
3. 드래그 하려는 컴포넌트를 Draggable로 감싼다.
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;
1. 브라우저에서 드래그와 클릭 이벤트가 동시에 발생하는 이슈
버튼을 클릭하면 채팅 모달창이 뜨고, 만약 로그인이 안 된 유저라면 로그인 페이지로 라우팅 처리 했다.
아래 영상을 보면 드래그를 하고 드롭을 하자마자 로그인 페이지로 이동하는 모습을 볼 수 있다.
이는 드래그를 드롭하자마자 클릭 이벤트가 발생한다는 것인데, 드래그와 클릭 이벤트가 동시에 발생하는 것을 알 수 있다.
2. 모바일에서 클릭 이벤트가 먹히지 않는 이슈
같은 코드상에서 모바일로 실행을 했을 때는 드래그는 되지만 클릭이 되질 않았다.
즉 내가 고려해야하는 문제는 적절한 이벤트 충돌 방지 처리와 모바일에서 onTouch이벤트를 적용하는 것이었다.
1. <Draggable>
에서 제공하는 API onStop
과 useState로 flag변수를 선언하여 드래그 이벤트를 제어해준다.
onStop: DraggableEventHandler
드래그가 중지되면 트리거하는 이벤트 리스너이다. 드래그가 끝날 때의 이벤드 핸들러를 전달해준다.useState
로 드래그를 하는 동안에 true, 드래그가 끝날 때 false를 나타내는 boolean type의 flag변수를 지정해준다.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 터치 감지 이슈 관련
이벤트 버블링 및 캡쳐링 개념
같은 문제를 겪고 있는 스택오버플로우 질문
리액트 터치 이벤트 공식문서