[React] 드래그 앤 드롭 처음부터 공부, UI 만들기, 관련 라이브러리 추천
목표
드래그 앤 드롭 기능은 웹 UI에서 흔히 사용되는 기능이다.
이 글은 드래그 앤 드롭을 처음부터 실무 적용까지 공부했던 기록이다.
목차
1. 드래그 앤 드롭의 기본 원리 탐구
2. 실무에서 사용하는 대표 기능
- 파일 드래그 앤 드롭
- 박스 내 정렬 및 이동
- 자유 이동
3. 유용한 라이브러리
- react-dropzone
- dnd-kit
드래그 앤 드롭의 기본 원리 탐구
나는 드래그 앤 드롭 기반 기능의 구조 구상이나 이벤트에 대해 전혀 몰랐는데, 아래 글 두개면 충분했다.
빠르게 기능을 파악하고 싶다면
🌐 드래그 앤 드롭(Drag and Drop) 기능 이해 & 구현하기
HTML 드래그 앤 드롭 사용법 드래그(drag)와 드롭(drop)은 컴퓨터를 이용하면서 정말 많이 사용하는 기능 중에 하나일 것이다. 파일 애플리케이션에서 문서를 복사해 이동하는 것 부터 주문 하려는
inpa.tistory.com
정석으로 개요부터 심화 이론까지 한번 훑어보고 싶다면 - mdn문서
https://developer.mozilla.org/ko/docs/Web/API/HTML_Drag_and_Drop_API
HTML 드래그 앤 드롭 API - Web API | MDN
Invalid slug for templ/sidebar: HTML 드래그 앤 드롭 API
developer.mozilla.org
드래그 앤 드롭 이벤트는 드래그 대상과 드래그 오버 및 드롭 대상 이 두가지에 설정한다.
그 중에서 몇 가지는 필수 이벤트인데, 마우스 이벤트랑 다르게, 드래그 이벤트는 몇 가지 이벤트를 필수로 설정하지 않으면 다음 동작이 막히거나 에러가 생기기 때문이다.
아래는 자주 사용하거나 필수인 이벤트이다.
드래그 대상
드래그를 하는 대상이다.
- 드래그 활성화
- html에서 드래그 요소에게 attribute로 draggable={true}를 명시한다. 이러면 드래그가 가능해진다.
- 요소 중 몇 가지는 기본적으로 드래그가 가능하다. a 태그, 마우스로 드래그한 텍스트가 대표적이다.
- html에서 드래그 요소에게 attribute로 draggable={true}를 명시한다. 이러면 드래그가 가능해진다.
- dragStart | 드래그가 시작될 때 호출된다.
- drag | 드래그 중일 때 계속 호출된다.
- dragEnd | 드래그를 끝낼 시 호출된다.
별거 아닌 팁
draggable로 지정한 요소는 크롬의 경우, user-select:none으로 변경되며 text 드래그가 안된다.
이게 싫다면 user-select:auto를 추가
드롭 대상
드래그 대상을 드롭하는 위치에 있는 대상이다.
- dragOver(필수) | 드롭 대상 위에 드래그 대상이 있을 때 계속 호출된다.
- 반드시 event.preventDefault()를 호출해야 한다. 그래야 다음 이벤트인 drop 이벤트가 호출된다. 또한, event.preventDefault()를 호출하지 않으면 드래그 대상의 drag 이벤트가 한번 더 실행되는 이상한 오류가 하나 있다. (이 때 event.clientX같은 일부 값이 제대로 들어오지 않아서 로직에 에러가 생길 수 있다. (관련 글))
- drop | 드롭 대상 위에 드래그 대상을 드롭할 때 호출된다.
- event.preventDefault()를 호출하는 게 좋다. 왜냐하면 일부 기본 동작으로 원치 않는 동작을 한다. 예컨대, 파일을 드래그 앤 드롭하면 자동으로 새 창이 열리고 실행된다.
- 주의사항
- 드롭 이벤트는 이벤트 버블링에 의해 일어나므로, event.target은 drop을 예상하는 dom의 자식일 수도 있다. 따라서 closet으로 원하는 드롭 대상을 선택해주는 등의 방식이 필요하다.
그 외 자잘한 팁
effectAllowed : 드래그할 때 예상 동작을 설정 - dragStart에서 설정 => 드래그 시 브라우저의 마우스 모양을 설정함
dropEffect : 드래그 실제 동작 - dragover, drop 설정 => 브라우저의 마우스 모양을 설정함
DataTransfer 객체
드래그 대상에서 드롭 대상으로 데이터를 전달하는 방법 중 하나다.
드래그 관련 이벤트 핸들러에 event.dataTransfer로 참조가능하다.
두 가지 타입의 데이터를 넣을 수 있는데
- 하나는 File 타입.(바이너리 데이터를 다루기 위한 객체. OS에서 브라우저로 올린 파일이 대표적이다) event.dataTransfer.files 로 접근 가능
- 다른 하나는 문자열 타입. event.dataTransfer.getData() 로 접근 가능
- getData(format, text) 인데, format을 text/plain 같은 거 외에 일반적인 객체 키 값처럼 지정해도 된다.
- event.dataTransfer.items라고 있는데, 문자열만 전송 가능하고 잘 사용하지는 않는 것 같다.
문제는 객체를 전달할 수 없어서, 드롭 대상의 이벤트에서 드래그 대상의 dom을 바로 참조할 수 없다.
대안으로 바닐라 자바스크립트에서는 드래그 시 드래그 대상에 .dragging 같은 클래스를 부여하고 드롭 이벤트에서 document.querySelector('.dragging') 같은 식으로 찾는다.
리엑트에서도 동일한 방식이 가능한데, 컴포넌트 함수단에서 dragging dom을 다룬다는 걸 명확히 보여주기 위해서 useRef나 useState로 dom을 참조하는 방식도 있다(개인적으로는 이 방식이 더 선언적이라 읽기 편하다)
드래그 앤 드롭은 접근성 측면에서 시각장애인이나 키보드만 사용하는 유저에게 좋지 않다.
브라우저는 마크다운 구조를 기반으로 tab키 기반의 선택 기능을 제공하는데, 드래그 앤 드롭으로 구현된 UI는 마우스 없이 쓰기가 불편하기 때문이다. 따라서, 접근성을 고려하면 이 부분은 별도의 접근성 처리가 필요하다.
자세한 내용은 toss의 아래 글을 참고
https://toss.tech/article/27752
실무에서 사용하는 대표 기능
아래는 실무에서 많이 사용하는 드래그 앤 드롭 기반 기능이다.
파일 드래그 앤 드롭
로컬OS에서 파일을 드래그하여 브라우저로 올리는 기능이다.
dataTransfer.files를 이용한다.
보통은 업로드 기능 외에 파일 업로드 버튼과 업로드한 파일을 확인하는 파일 리스트 UI를 함께 사용한다.
여기서는 파일 업로드 버튼을 빼고 나머지 두가지를 리엑트로 구현했다.
로직을 자연어로 적으면 대략 이렇다.
로컬OS에서 파일을 드래그하여 브라우저 내 드롭 박스 위에 놓는다 => 드롭한 파일들이 리스트로 표출된다..
= 드롭 박스에 onDrop 이벤트를 건다 => onDrop 이벤트에서 event.dataTransfer.files를 참조하고, 이 파일들을 uploadFiles 라는 state에 저장한다. => uploadFiles.map() 으로 파일 리스트를 렌더링한다
// @ts-check
import React, { useState } from "react";
import "./DragAndDropStyle.css";
/**
* @type {React.DragEventHandler<HTMLDivElement>}
*/
function handleDragOver(ev) {
ev.preventDefault();
}
function handleDrop(setUploadFiles) {
/**
* @type {React.DragEventHandler<HTMLDivElement>}
*/
return (ev) => {
ev.preventDefault();
console.log(ev.dataTransfer.files);
setUploadFiles && setUploadFiles((prev) => [...ev.dataTransfer.files]);
};
}
export default function DADuploadFIles() {
const [uploadFiles, setUploadFiles] = useState([]);
return (
<>
<div className="DADroot">
<div
className={`dropzone`}
style={{
backgroundColor: "aliceblue",
width: "500px",
height: "500px",
}}
onDragOver={handleDragOver}
onDrop={handleDrop(setUploadFiles)}
>
<p>파일을 드래그 하세요</p>
</div>
<ul className="upload_file_list">
{uploadFiles.map((el, idx) => (
<li key={idx}>
<h3>{el.name}</h3>
<p>타입: {el.type}</p>
<p>사이즈: {el.size}</p>
</li>
))}
</ul>
</div>
</>
);
}
박스 내 정렬 및 이동
todo list같은 박스 내 요소들 간에 순서를 드래그하여 변경하거나 다른 박스로 이동하는 등의 기능이다.
바닐라 자바스크립트이면 dom을 쿼리 셀렉터로 찾아서 직접 append 하는 방식이겠지만, dnd-kit 같은 라이브러리 방식을 보면 ref를 사용하는 게 더 간편해 보인다.
여기서는 바닐라 자바스크립트 방식으로 해보겠다.(ref 방식이 어려워서 간단한 바닐라 js 방식만 해보았다)
로직을 자연어로 적으면 대략 이렇다.
드래그 대상을 드래그한다 => 드롭 대상에 드롭하여 옮긴다.
= 드래그 대상에게 draggable=true 설정 후 dragStart에서 'dragging'이라는 클래스를 추가한다. => 드롭 대상의 dragOver 이벤트에서 dragging클래스 요소를 찾아서 append 해준다. => dragEnd로 드래그 대상에게서 dragging 클래스를 제거한다
// @ts-check
import React, { useState } from "react";
import "./DragAndDropStyle.css";
const randomList1 = Array.from(Array(6)).map(
(el, idx) => idx + (Math.random() * 100).toFixed(0)
);
const randomList2 = Array.from(Array(6)).map(
(el, idx) => idx + (Math.random() * 100).toFixed(0)
);
const dragClassName = "dragging";
const dragLiClassName = "draggable";
const dropZoneClassName = "droppable";
/**
* @type {React.DragEventHandler<HTMLLIElement>}
*/
function handleDragStart(ev) {
ev.target.classList.add(dragClassName);
}
/**
* @type {React.DragEventHandler<HTMLLIElement>}
*/
function handleDragEnd(ev) {
ev.target.classList.remove(dragClassName);
}
/**
* @type {React.DragEventHandler<HTMLUListElement>}
*/
function handleDragOver(ev) {
ev.preventDefault();
const dragEl = document.querySelector(`.${dragClassName}`);
if (!dragEl) return;
const dragDestination1 = ev.target.closest(`.${dragLiClassName}`);
const dragDestination2 = ev.target.closest(`.${dropZoneClassName}`);
if (!dragDestination1 && !dragDestination2) return;
if (dragDestination1) {
function calculateSetPosition(target, dest, ev2) {
const destBoundRect = dest.getBoundingClientRect();
if (!destBoundRect) return;
const destMiddlePointY = (destBoundRect.top + destBoundRect.bottom) / 2;
return destMiddlePointY > ev2.clientY ? "top" : "bottom";
}
const setPosition = calculateSetPosition(dragEl, dragDestination1, ev);
dragDestination1.insertAdjacentElement(
setPosition === "top" ? "beforebegin" : "afterend",
dragEl
);
} else if (dragDestination2) {
dragDestination2.append(dragEl);
}
}
export default function DADsortBoxCopy() {
const [count, setCount] = useState(0);
return (
<>
{/* 리렌더링 시 dom 에러가 나는지 확인하기 위함 */}
<div>
<p>{count}</p>
<button onClick={() => setCount((prev) => prev + 1)}>count add </button>
</div>
<div className="DADroot">
DADroot
<ul onDragOver={handleDragOver} className={`ul1 ${dropZoneClassName}`}>
{randomList1.map((el) => (
<li
draggable={true}
className={`${dragLiClassName}`}
key={el}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<h6>{el}</h6>
<p>{el}</p>
</li>
))}
</ul>
<ul onDragOver={handleDragOver} className={`ul1 ${dropZoneClassName}`}>
{randomList2.map((el) => (
<li
draggable={true}
className={`${dragLiClassName}`}
key={el}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<h6>{el}</h6>
<p>{el}</p>
</li>
))}
</ul>
</div>
</>
);
}
자유 이동
대상을 드래그하여 마우스가 이동하는 위치로 이동시키는 기능이다. 박스 간의 이동과 다르게, 드롭 대상을 고려하는 대신, 드래그하는 마우스의 위치 계산이 중요하다.
이번에는 리엑트로 구현할 것이다.
로직을 자연어로 적으면 대략 이렇다.
드래그 대상을 드래그한다 => 마우스를 이동한다. => 드래그하며 이동하는 마우스를 따라 드래그 대상이 이동한다.
= draggable=true 설정 후 드래그 시작 시 마우스의 위치를 startPosition state로 저장한다. =>
=> 드래그 대상의 drag이벤트에서 현 시점 마우스의 위치를 시작 위치에서 뺀다. => 계산 값을 transform: translate()로 적용한다.
핵심은 드래그 스타트와 드래그 오버 간에 마우스 좌표 차이를 통해 이동 거리를 계산하는 것이다.
위치 이동 시 주의 사항
transform은 position 관련 위치 속성(top, left, right, bottom)에 비해, GPU가속으로 처리하고 리플로우를 일으키지 않아 성능적으로 우수하다.
하지만, 실제로 요소의 레이아웃이 업데이트 되는 것이 아니므로, dom의 일부 위치 요소 값이 변경된 위치로 나오지 않는다.
예컨대, element.offsetLeft 같은 값들이 변경 이전 위치로 나오니 주의하자.
(element.getBoundingClientRect()는 transform으로 변경한 값이 반영된다.)
drag 이벤트 관련 오류
drop하는 위치에 onDragOver 핸들러와 ev.preventDefault()가 없으면 drop할 때 drag 이벤트가 한번 더 호출된다. 이 때 이벤트 객체에 clientX같은 위치 값이 0으로 나와서 에러가 나오니, 루트단에onDragOver 이벤트 핸들러와 ev.preventDefault()를 추가하자.
// @ts-check
import React, { useState } from "react";
function handleDragStart(setStartPosition, position) {
/**
* @type {React.DragEventHandler}
*/
return (ev) => {
console.log(ev.clientX - position.x, ev.clientY - position.y);
setStartPosition({
x: ev.clientX - position.x,
y: ev.clientY - position.y,
});
// 잔상 생성 방지
ev.dataTransfer.setDragImage(new Image(), 0, 0);
};
}
function handleDrag(startPosition, setPosition) {
/**
* @type {React.DragEventHandler}
*/
return (ev) => {
if (ev.clientX === 0 || ev.clientY === 0) return;
const xCoordiname = ev.clientX - startPosition.x;
const yCoordiname = ev.clientY - startPosition.y;
setPosition({ x: xCoordiname, y: yCoordiname });
};
}
export default function DADMoveItemCopy() {
const [position, setPosition] = useState({ x: 0, y: 0 });
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
return (
<div
draggable={true}
style={{
width: "100px",
height: "100px",
backgroundColor: "bisque",
borderRadius: "20px",
cursor: "grab",
position: "absolute", // translate3d 적용 시 필요할 수 있음
touchAction: "none", // 모바일 터치 대응
willChange: "transform", // 성능 최적화
transform: `translate3d(${position.x}px, ${position.y}px, 0)`,
}}
onDragStart={handleDragStart(setStartPosition, position)}
onDrag={handleDrag(startPosition, setPosition)}
></div>
);
}
그 외에 드래그 앤 드롭이 사용되는 기능은 메일함이나 웹 드라이브에서 파일 등을 드래그로 옮기는 정도가 있겠다.
- 사용자 입장에서 마우스로 드래그하여 옮기는 것이 직관적인 기능은 대개 다 포함된다.
- 모바일의 경우, 몇 초 이상 터치하고 있으면 드래그 기능으로 전환되는 방식인데, 이 글에서 다루진 않는다.
유용한 라이브러리
위에서 다룬 기능들은 간단하게 직접 구현이 가능하지만 기능의 확장과 깔끔한 코드, 그리고 부드러운 애니메이션을 지원하기 위해서는 라이브러리를 추천한다.
아래는 npm 다운로드 수도 높고 대표적으로 많이 사용하는 라이브러리 두 가지이다.
react-dropzone
React에서 파일 드래그 앤 드롭 기능을 구현하기 위한 라이브러리이다.
react-hook-form과 비슷한 디자인 패턴인데, 프록시 상태 방식의 훅으로 드롭존과 인풋 jsx에 연결하는 방식이라 디자인이 자유롭다. 확장자 검사 기능은 간단하게 추가가 가능하다.
https://react-dropzone.js.org/
react-dropzone
react-dropzone.js.org
dnd-kit
React에서 박스 내 정렬 및 이동과 자유 이동 기능을 구현하는데 유용한 라이브러리이다.
과거에는 react-beautiful-dnd를 사용했었는데, 해당 팀이 지원 중단을 선언한 이후 대안으로 언급하였다. 현재는 npm-trend 다운로드가 더 많다.데모 문서가 잘 되어 있어서 원하는 기능을 찾고 적용하기 간편하다.
dnd kit – a modern drag and drop toolkit for React
A modular, lightweight, performant, accessible and extensible drag & drop toolkit for React.
dndkit.com
react-draggable
dnd-kit가 드래그 앤 드롭 기능의 커스터마이징 가능성이 높지만 상당히 복잡한 반면,
react-draggable은 단순히 드래그 무브 자체에 초점 맞춘 보다 간단한 라이브러리이다.
간단하게 드래그 앤 위치 이동을 구현할 것이라면 이걸 추천한다.
https://www.npmjs.com/package/react-draggable