Drag and Drop API — 네이티브 드래그와 커스텀 구현
Drag and Drop은 사용자 경험에서 자주 쓰이는 인터랙션입니다. HTML5 네이티브 API를 사용하면 복잡한 라이브러리 없이도 드래그 앤 드롭을 구현할 수 있습니다.
기본 드래그 앤 드롭
<div id="draggable" draggable="true">드래그 가능</div>
<div id="dropzone">여기에 놓으세요</div>
const draggable = document.getElementById("draggable");
const dropzone = document.getElementById("dropzone");
// 드래그 시작
draggable.addEventListener("dragstart", (e) => {
e.dataTransfer.setData("text/plain", draggable.id);
e.dataTransfer.effectAllowed = "move";
draggable.classList.add("dragging");
});
// 드래그 끝
draggable.addEventListener("dragend", () => {
draggable.classList.remove("dragging");
});
// 드롭존 이벤트
dropzone.addEventListener("dragover", (e) => {
e.preventDefault(); // 필수! 이걸 해야 drop 이벤트 발생
e.dataTransfer.dropEffect = "move";
dropzone.classList.add("drag-over");
});
dropzone.addEventListener("dragleave", () => {
dropzone.classList.remove("drag-over");
});
dropzone.addEventListener("drop", (e) => {
e.preventDefault();
const id = e.dataTransfer.getData("text/plain");
const element = document.getElementById(id);
dropzone.appendChild(element);
dropzone.classList.remove("drag-over");
});
이벤트 흐름
드래그 앤 드롭의 이벤트 순서를 이해하는 것이 중요합니다.
드래그 대상: dragstart → drag(반복) → dragend
드롭 대상: dragenter → dragover(반복) → dragleave 또는 drop
// 모든 이벤트 정리
// dragstart — 드래그 시작
// drag — 드래그 중 (반복)
// dragenter — 드롭 영역에 진입
// dragover — 드롭 영역 위에 있음 (반복, preventDefault 필수)
// dragleave — 드롭 영역에서 벗어남
// drop — 드롭됨
// dragend — 드래그 종료 (성공 여부 무관)
리스트 정렬 (Sortable)
function makeSortable(list) {
let dragItem = null;
list.addEventListener("dragstart", (e) => {
dragItem = e.target;
e.target.classList.add("dragging");
e.dataTransfer.effectAllowed = "move";
});
list.addEventListener("dragend", (e) => {
e.target.classList.remove("dragging");
dragItem = null;
});
list.addEventListener("dragover", (e) => {
e.preventDefault();
const afterElement = getDragAfterElement(list, e.clientY);
if (afterElement) {
list.insertBefore(dragItem, afterElement);
} else {
list.appendChild(dragItem);
}
});
}
function getDragAfterElement(container, y) {
const elements = [...container.querySelectorAll("li:not(.dragging)")];
return elements.reduce(
(closest, child) => {
const box = child.getBoundingClientRect();
const offset = y - box.top - box.height / 2;
if (offset < 0 && offset > closest.offset) {
return { offset, element: child };
}
return closest;
},
{ offset: Number.NEGATIVE_INFINITY }
).element;
}
파일 드롭
const dropArea = document.getElementById("file-drop");
// 기본 브라우저 동작 방지
["dragenter", "dragover", "dragleave", "drop"].forEach((eventName) => {
dropArea.addEventListener(eventName, (e) => {
e.preventDefault();
e.stopPropagation();
});
});
// 시각적 피드백
["dragenter", "dragover"].forEach((eventName) => {
dropArea.addEventListener(eventName, () => {
dropArea.classList.add("highlight");
});
});
["dragleave", "drop"].forEach((eventName) => {
dropArea.addEventListener(eventName, () => {
dropArea.classList.remove("highlight");
});
});
// 파일 처리
dropArea.addEventListener("drop", (e) => {
const files = [...e.dataTransfer.files];
files.forEach((file) => {
console.log(`파일: ${file.name} (${file.size} bytes, ${file.type})`);
// 이미지 미리보기
if (file.type.startsWith("image/")) {
const reader = new FileReader();
reader.onload = (e) => {
const img = document.createElement("img");
img.src = e.target.result;
dropArea.appendChild(img);
};
reader.readAsDataURL(file);
}
});
});
커스텀 드래그 이미지
draggable.addEventListener("dragstart", (e) => {
// 커스텀 드래그 이미지 설정
const ghost = document.createElement("div");
ghost.textContent = "이동 중...";
ghost.style.cssText = "padding: 8px; background: #3498db; color: white; border-radius: 4px;";
document.body.appendChild(ghost);
e.dataTransfer.setDragImage(ghost, 0, 0);
// 다음 프레임에서 제거 (브라우저가 스냅샷을 찍은 후)
requestAnimationFrame(() => ghost.remove());
});
포인터 이벤트 기반 커스텀 드래그
네이티브 DnD API의 제약이 있을 때 (모바일 지원, 세밀한 제어), 포인터 이벤트로 직접 구현합니다.
function makeDraggable(element) {
let isDragging = false;
let startX, startY, initialX, initialY;
element.addEventListener("pointerdown", (e) => {
isDragging = true;
startX = e.clientX;
startY = e.clientY;
initialX = element.offsetLeft;
initialY = element.offsetTop;
element.setPointerCapture(e.pointerId);
element.style.cursor = "grabbing";
});
element.addEventListener("pointermove", (e) => {
if (!isDragging) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
element.style.left = `${initialX + dx}px`;
element.style.top = `${initialY + dy}px`;
});
element.addEventListener("pointerup", () => {
isDragging = false;
element.style.cursor = "grab";
});
}
네이티브 DnD vs 커스텀 구현
| 항목 | 네이티브 DnD | 포인터 이벤트 |
|---|---|---|
| 구현 난이도 | 보통 | 높음 |
| 모바일 지원 | 제한적 | 완전 |
| 드래그 이미지 | 제한적 커스텀 | 완전 자유 |
| 파일 드롭 | 내장 지원 | 직접 구현 |
| 성능 | 브라우저 최적화 | 직접 최적화 |
**기억하기 **:
dragover에서preventDefault()를 호출해야drop이벤트가 발생합니다. 이걸 빼먹으면 드롭이 동작하지 않습니다. 모바일 지원이 필요하면 포인터 이벤트 기반으로 직접 구현하거나, 라이브러리(SortableJS 등)를 사용하는 것이 좋습니다.
댓글 로딩 중...