VanillaJS를 이용한 리스트 순서 변경: 드래그 앤 드롭 방식
- Published on
리스트의 순서를 사용자가 직접 변경할 수 있게 하는 기능은 많은 웹 애플리케이션에서 필수적입니다. 예를 들어, 할 일 목록(todo list) 앱이나 플레이리스트 관리 등에서 사용자가 항목의 우선 순위를 조정할 수 있도록 해줍니다.
이런 기능을 위해서는 라이브러리를 주로 사용하는데 저는 @hello-pangea/dnd
를 사용하고 있었습니다.
최근에 Drag and Drop으로 리스트의 순서를 변경하는 좋은 VanilaJS 튜토리얼이 있어서 소개해드리려고 가져와봤습니다.
Tutorial: https://tahazsh.com/blog/seamless-ui-with-js-drag-to-reorder-example
Drag and Drop 기능에 대해 이해하는데 많은 도움이 되어서 이 튜토리얼을 간략하게 정리해보았습니다.
HTML 구조
드래그 하는 div 태그는 기본적으로 .item
클래스를 가지며 대기중일 때는 .is-idle
클래스를 가집니다. 드래그를 위한 손잡이는 .drag-handle
클래스를 가집니다.
<div class="list">
<div class="item is-idle">
사과
<div class="drag-handle"></div>
</div>
<div class="item is-idle">
바나나
<div class="drag-handle"></div>
</div>
<div class="item is-idle">
망고
<div class="drag-handle"></div>
</div>
<div class="item is-idle">
파인애플
<div class="drag-handle"></div>
</div>
</div>
CSS 스타일링
드래그 요소는 .item.is-idle
이 기본이며, 드래그 중인 아이템은 .item.is-dragabble
로 변경됩니다.
드래그 손잡이인 .drag-handle
의 기본적인 마우스는 .cursor:grab
이며, 드래그 중일 때에 .drag-handle
과 .item
의 마우스 모양은 cursor:grabbing
으로 변경합니다.
/* 드래그 가능한 아이템 컨테이너 */
.list {
display: flex;
flex-direction: column;
max-width: 500px;
width: 100%;
gap: 10px 0;
}
/* 드래그 가능한 아이템 */
.item {
width: 100%;
background: white;
padding: 15px;
border-radius: 5px;
color: #222;
font-weight: 500;
font-size: 18px;
display: flex;
align-items: center;
position: relative;
will-change: transform;
}
/* 드래그 손잡이 */
.drag-handle {
position: absolute;
right: 0;
width: 44px;
height: 44px;
display: flex;
justify-content: center;
align-items: center;
}
.drag-handle::after {
content: '⠿';
font-size: 25px;
color: #00000099;
}
/* 대기 중 */
.item.is-idle .drag-handle {
cursor: grab;
}
.item.is-idle {
transition: 0.25s ease transform;
}
/* 드래그 중 */
.item.is-draggable,
.item.is-draggable .drag-handle {
cursor: grabbing;
}
.item.is-draggable {
z-index: 10;
}
자바스크립트로 동작 추가하기
전역 변수
필요한 변수를 미리 선언합니다. 드래그 dom 요소와 마우스 위치, 그리고 거리를 저장할 변수입니다.
let listContainer; // 드래그 아이템 container
let draggableItem; // 드래그 중인 item
let pointerStartX; // 드래그 시작 시 마우스 X
let pointerStartY; // 드래그 시작 시 마우스 Y
let itemsGap = 0; // item 사이의 거리
let items = []; // 드래그 아이템 리스트
Help Function
몇 가지 유틸성 함수입니다.
전체 .item
아이템 또는 드래그 중이 아닌 대기 중인 아이템(.is-idle
클래스를 가진 아이템만)을 가져옵니다.
드래그 중인 아이템보다 위에 배치된 아이템인지 확인하거나 순서가 변경된 아이템인지 확인하는 함수가 있습니다.
// 전체 아이템 가져오기
function getAllItems() {
if (!items?.length) {
items = Array.from(listContainer.querySelectorAll('.item'));
}
return items;
}
// 드래그 중이 아닌 대기 중인 아이템 가져오기
function getIdleItems() {
return getAllItems().filter((item) => item.classList.contains('is-idle'));
}
// 드래그 아이템보다 위에 있는 아이템인지 확인
function isItemAbove(item) {
return item.hasAttribute('data-is-above');
}
// 드래그 중일 때 순서가 변경된 아이템인지 확인
function isItemToggled(item) {
return item.hasAttribute('data-is-toggled');
}
초기 세팅
드래그를 시작하기 위해서 전체 리스트에 mousedown
, touchstart
EventListener를 등록 합니다.
드래그를 종료하기 위해서 document에 mouseup
, touchend
EventListener를 등록합니다.
function setup() {
listContainer = document.querySelector('.list');
if (!listContainer) return;
// 드래그 시작 event listener
listContainer.addEventListener('mousedown', dragStart);
listContainer.addEventListener('touchstart', dragStart);
// 드래그 종료 event listener
document.addEventListener('mouseup', dragEnd);
document.addEventListener('touchend', dragEnd);
}
Drag 시작
mousedown
이나 touchstart
를 통해서 Drag 함수를 시작합니다.
- 마우스 위치값 저장
.item
사이의 거리 구하기- 페이지 스크롤 되지 않도록 body에
overflow:hidden
처리 - 드래그
.item
에.is-idle
클래스 삭제하고.is-dragabble
클래스 추가 .item
중에 드래그.item
보다 위에 있으면data-is-above
표시
/***********************
* Drag Start *
***********************/
function dragStart(e) {
if (e.target.classList.contains('drag-handle')) {
draggableItem = e.target.closest('.item');
}
if (!draggableItem) return;
// 마우스 위치값 저장
pointerStartX = e.clientX || e.touches[0].clientX;
pointerStartY = e.clientY || e.touches[0].clientY;
setItemsGap(); // 아이템 사이의 거리 구하기
disablePageScroll(); // 페이지 스크롤 되지 않도록 body css 처리
initDraggableItem(); // 드래그 아이템 준비
initItemsState(); // 아이템 상태 준비
// 드래그 move listener
document.addEventListener('mousemove', drag);
document.addEventListener('touchmove', drag, { passive: false });
}
// 아이템 사이의 거리 구하기
function setItemsGap() {
if (getIdleItems().length <= 1) {
itemsGap = 0;
return;
}
const item1 = getIdleItems()[0];
const item2 = getIdleItems()[1];
const item1Rect = item1.getBoundingClientRect();
const item2Rect = item2.getBoundingClientRect();
itemsGap = Math.abs(item1Rect.bottom - item2Rect.top);
}
// 페이지 스크롤 비활성화
function disablePageScroll() {
document.body.style.overflow = 'hidden';
document.body.style.touchAction = 'none';
document.body.style.userSelect = 'none';
}
// 드래그 아이템 표시
function initDraggableItem() {
draggableItem.classList.remove('is-idle');
draggableItem.classList.add('is-draggable');
}
// 드래그 중인 아이템보다 위에 있는지 표시
function initItemsState() {
getIdleItems().forEach((item, i) => {
if (getAllItems().indexOf(draggableItem) > i) {
item.dataset.isAbove = ''; // 드래그 중인 아이템보다 위에 배치
}
});
}
Drag 진행중
- 마우스가 움직인 거리를 확인합니다.
- 드래그 중인
.item
의 위치를 마우스 움직인 거리만큼 변경합니다. - 나머지
.item
들의 상태와 위치도 변경합니다.
/***********************
* Drag *
***********************/
function drag(e) {
if (!draggableItem) return;
e.preventDefault();
const clientX = e.clientX || e.touches[0].clientX;
const clientY = e.clientY || e.touches[0].clientY;
// 마우스가 움직인 거리
const pointerOffsetX = clientX - pointerStartX;
const pointerOffsetY = clientY - pointerStartY;
// 드래그 중인 아이템 위치 변경
draggableItem.style.transform = `translate(${pointerOffsetX}px, ${pointerOffsetY}px)`;
// 드래그 중인 아이템을 제외한 나머지 아이템들의 위치 변경
updateIdleItemsStateAndPosition();
}
// 드래그 중일 때 대기중인 아이템의 상태, 위치 설정
function updateIdleItemsStateAndPosition() {
const draggableItemRect = draggableItem.getBoundingClientRect();
const draggableItemY = draggableItemRect.top + draggableItemRect.height / 2;
// 상태 업데이트
getIdleItems().forEach((item) => {
const itemRect = item.getBoundingClientRect();
const itemY = itemRect.top + itemRect.height / 2;
if (isItemAbove(item)) {
// 위에 있는 아이템일 때
if (draggableItemY <= itemY) {
// 드래그 아이템이 더 위로 가서 순서가 변경되었을 때
item.dataset.isToggled = '';
} else {
delete item.dataset.isToggled;
}
} else {
// 아래에 배치된 아이템
if (draggableItemY >= itemY) {
// 드래그 아이템이 더 아래로 가서 순서가 변경되었을 때
item.dataset.isToggled = '';
} else {
delete item.dataset.isToggled;
}
}
});
// 위치 업데이트
getIdleItems().forEach((item) => {
if (isItemToggled(item)) {
const direction = isItemAbove(item) ? 1 : -1;
item.style.transform = `translateY(${
direction * (draggableItemRect.height + itemsGap)
}px)`;
} else {
item.style.transform = '';
}
});
}
Drag 종료
- document의 EventListener로부터 종료를 전파받음
- 순서 재정렬 해서
.item
에 적용 - 드래그를 위해
.item
에 추가했던 값들(.is-draggable
,dataset.isAbove
,dataset.isToggled
) 삭제 - body의 페이지 스크롤 다시 활성화
/***********************
* Drag End *
***********************/
// 드래그 종료
function dragEnd() {
if (!draggableItem) return;
applyNewItemsOrder();
cleanup();
}
// 드래그 종료 후 순서 재정렬
function applyNewItemsOrder() {
const reorderedItems = []; // 재정렬한 아이템 리스트
getAllItems().forEach((item, index) => {
if (item === draggableItem) {
// 드래그 아이템은 제외 / undefined가 들어가있음
return;
}
if (!isItemToggled(item)) {
// 변동사항이 없는 아이템
reorderedItems[index] = item;
return;
}
// 변동사항이 있는 아이템
const newIndex = isItemAbove(item) ? index + 1 : index - 1;
reorderedItems[newIndex] = item;
});
// 드래그 아이템을 reorderedItems에 추가
for (let index = 0; index < getAllItems().length; index++) {
const item = reorderedItems[index];
if (typeof item === 'undefined') {
// 비어있는 곳
reorderedItems[index] = draggableItem;
}
}
reorderedItems.forEach((item) => {
listContainer.appendChild(item);
});
}
// 초기화
function cleanup() {
itemsGap = 0;
items = [];
unsetDraggableItem(); // 드래그아이템 초기화
unsetItemState(); // 전체 아이템 상태 초기화
enablePageScroll(); // 페이지 스크롤 활성화
document.removeEventListener('mousemove', drag);
document.removeEventListener('touchmove', drag);
}
// 드래그 아이템 초기화
function unsetDraggableItem() {
draggableItem.style = null;
draggableItem.classList.remove('is-draggable');
draggableItem.classList.add('is-idle');
draggableItem = null;
}
// 아이템 상태 초기화
function unsetItemState() {
getIdleItems().forEach((item, i) => {
delete item.dataset.isAbove;
delete item.dataset.isToggled;
item.style.transform = '';
});
}
// 페이지 스크롤 다시 활성화
function enablePageScroll() {
document.body.style.overflow = '';
document.body.style.touchAction = '';
document.body.style.userSelect = '';
}
Drag and Drop을 이용하여 VanillaJS로 리스트 순서를 변경하는 방법에 대해 알아보았습니다. 이 기능을 통해 사용자는 직관적인 방식으로 웹 페이지 내의 요소들의 순서를 조정할 수 있게 됩니다. 이런 상호작용은 웹 앱의 사용성을 크게 향상시키며, 사용자 경험을 개선합니다.
참조
- Tutorial: https://tahazsh.com/blog/seamless-ui-with-js-drag-to-reorder-example
- Github: https://github.com/TahaSh/drag-to-reorder
전체 코드
아래는 이 튜토리얼의 전체 코드입니다.
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="initial-scale=1.0, minimum-scale=1.0, width=device-width, viewport-fit=cover"
/>
<title>VanilaJS Drag and Drop</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background: #c4c4c4;
display: flex;
align-items: center;
justify-content: center;
padding: 10px;
}
/* 드래그 가능한 아이템 컨테이너 */
.list {
display: flex;
flex-direction: column;
max-width: 500px;
width: 100%;
gap: 10px 0;
}
/* 드래그 가능한 아이템 */
.item {
width: 100%;
background: white;
padding: 15px;
border-radius: 5px;
color: #222;
font-weight: 500;
font-size: 18px;
display: flex;
align-items: center;
position: relative;
will-change: transform;
}
/* 드래그 손잡이 */
.drag-handle {
position: absolute;
right: 0;
width: 44px;
height: 44px;
display: flex;
justify-content: center;
align-items: center;
}
.drag-handle::after {
content: '⠿';
font-size: 25px;
color: #00000099;
}
/* 대기 중 */
.item.is-idle .drag-handle {
cursor: grab;
}
.item.is-idle {
transition: 0.25s ease transform;
}
/* 드래그 중 */
.item.is-draggable,
.item.is-draggable .drag-handle {
cursor: grabbing;
}
.item.is-draggable {
z-index: 10;
}
</style>
</head>
<body>
<div class="list">
<div class="item is-idle">
사과
<div class="drag-handle"></div>
</div>
<div class="item is-idle">
바나나
<div class="drag-handle"></div>
</div>
<div class="item is-idle">
망고
<div class="drag-handle"></div>
</div>
<div class="item is-idle">
파인애플
<div class="drag-handle"></div>
</div>
</div>
<script>
// Tutorial: https://tahazsh.com/blog/seamless-ui-with-js-drag-to-reorder-example
/***********************
* Variables *
***********************/
let listContainer; // 드래그 아이템 container
let draggableItem; // 드래그 중인 item
let pointerStartX; // 드래그 시작 시 마우스 X
let pointerStartY; // 드래그 시작 시 마우스 Y
let itemsGap = 0; // item 사이의 거리
let items = []; // 드래그 아이템 리스트
/***********************
* Helper Functions *
***********************/
// 전체 아이템 가져오기
function getAllItems() {
if (!items?.length) {
items = Array.from(listContainer.querySelectorAll('.item'));
}
return items;
}
// 드래그 중이 아닌 대기중인 아이템 가져오기
function getIdleItems() {
return getAllItems().filter((item) =>
item.classList.contains('is-idle'),
);
}
// 드래그 아이템보다 위에 있는 아이템인지 확인
function isItemAbove(item) {
return item.hasAttribute('data-is-above');
}
// 드래그 중일 때 순서가 변경된 아이템인지 확인
function isItemToggled(item) {
return item.hasAttribute('data-is-toggled');
}
/***********************
* Setup *
***********************/
function setup() {
listContainer = document.querySelector('.list');
if (!listContainer) return;
// 드래그 시작 event listener
listContainer.addEventListener('mousedown', dragStart);
listContainer.addEventListener('touchstart', dragStart);
// 드래그 종료 event listener
document.addEventListener('mouseup', dragEnd);
document.addEventListener('touchend', dragEnd);
}
/***********************
* Drag Start *
***********************/
function dragStart(e) {
if (e.target.classList.contains('drag-handle')) {
draggableItem = e.target.closest('.item');
}
if (!draggableItem) return;
// 마우스 위치값 저장
pointerStartX = e.clientX || e.touches[0].clientX;
pointerStartY = e.clientY || e.touches[0].clientY;
setItemsGap(); // 아이템 사이의 거리 구하기
disablePageScroll(); // 페이지 스크롤 되지 않도록 body css 처리
initDraggableItem(); // 드래그 아이템 준비
initItemsState(); // 아이템 상태 준비
// 드래그 move listener
document.addEventListener('mousemove', drag);
document.addEventListener('touchmove', drag, { passive: false });
}
// 아이템 사이의 거리 구하기
function setItemsGap() {
if (getIdleItems().length <= 1) {
itemsGap = 0;
return;
}
const item1 = getIdleItems()[0];
const item2 = getIdleItems()[1];
const item1Rect = item1.getBoundingClientRect();
const item2Rect = item2.getBoundingClientRect();
itemsGap = Math.abs(item1Rect.bottom - item2Rect.top);
}
// 페이지 스크롤 비활성화
function disablePageScroll() {
document.body.style.overflow = 'hidden';
document.body.style.touchAction = 'none';
document.body.style.userSelect = 'none';
}
// 드래그 아이템 표시
function initDraggableItem() {
draggableItem.classList.remove('is-idle');
draggableItem.classList.add('is-draggable');
}
// 드래그 중인 아이템보다 위에 있는지 표시
function initItemsState() {
getIdleItems().forEach((item, i) => {
if (getAllItems().indexOf(draggableItem) > i) {
item.dataset.isAbove = ''; // 드래그 중인 아이템보다 위에 배치
}
});
}
/***********************
* Drag *
***********************/
function drag(e) {
if (!draggableItem) return;
e.preventDefault();
const clientX = e.clientX || e.touches[0].clientX;
const clientY = e.clientY || e.touches[0].clientY;
// 마우스가 움직인 거리
const pointerOffsetX = clientX - pointerStartX;
const pointerOffsetY = clientY - pointerStartY;
// 드래그 중인 아이템 위치 변경
draggableItem.style.transform = `translate(${pointerOffsetX}px, ${pointerOffsetY}px)`;
// 드래그 중인 아이템을 제외한 나머지 아이템들의 위치 변경
updateIdleItemsStateAndPosition();
}
// 드래그 중일 때 대기중인 아이템의 상태, 위치 설정
function updateIdleItemsStateAndPosition() {
const draggableItemRect = draggableItem.getBoundingClientRect();
const draggableItemY =
draggableItemRect.top + draggableItemRect.height / 2;
// 상태 업데이트
getIdleItems().forEach((item) => {
const itemRect = item.getBoundingClientRect();
const itemY = itemRect.top + itemRect.height / 2;
if (isItemAbove(item)) {
// 위에 있는 아이템일 때
if (draggableItemY <= itemY) {
// 드래그 아이템이 더 위로 가서 순서가 변경되었을 때
item.dataset.isToggled = '';
} else {
delete item.dataset.isToggled;
}
} else {
// 아래에 배치된 아이템
if (draggableItemY >= itemY) {
// 드래그 아이템이 더 아래로 가서 순서가 변경되었을 때
item.dataset.isToggled = '';
} else {
delete item.dataset.isToggled;
}
}
});
// 위치 업데이트
getIdleItems().forEach((item) => {
if (isItemToggled(item)) {
const direction = isItemAbove(item) ? 1 : -1;
item.style.transform = `translateY(${
direction * (draggableItemRect.height + itemsGap)
}px)`;
} else {
item.style.transform = '';
}
});
}
/***********************
* Drag End *
***********************/
// 드래그 종료
function dragEnd() {
if (!draggableItem) return;
applyNewItemsOrder();
cleanup();
}
// 드래그 종료 후 순서 재정렬
function applyNewItemsOrder() {
const reorderedItems = []; // 재정렬한 아이템 리스트
getAllItems().forEach((item, index) => {
if (item === draggableItem) {
// 드래그 아이템은 제외 / undefined가 들어가있음
return;
}
if (!isItemToggled(item)) {
// 변동사항이 없는 아이템
reorderedItems[index] = item;
return;
}
// 변동사항이 있는 아이템
const newIndex = isItemAbove(item) ? index + 1 : index - 1;
reorderedItems[newIndex] = item;
});
// 드래그 아이템을 reorderedItems에 추가
for (let index = 0; index < getAllItems().length; index++) {
const item = reorderedItems[index];
if (typeof item === 'undefined') {
// 비어있는 곳
reorderedItems[index] = draggableItem;
}
}
reorderedItems.forEach((item) => {
listContainer.appendChild(item);
});
}
// 초기화
function cleanup() {
itemsGap = 0;
items = [];
unsetDraggableItem(); // 드래그아이템 초기화
unsetItemState(); // 전체 아이템 상태 초기화
enablePageScroll(); // 페이지 스크롤 활성화
document.removeEventListener('mousemove', drag);
document.removeEventListener('touchmove', drag);
}
// 드래그 아이템 초기화
function unsetDraggableItem() {
draggableItem.style = null;
draggableItem.classList.remove('is-draggable');
draggableItem.classList.add('is-idle');
draggableItem = null;
}
// 아이템 상태 초기화
function unsetItemState() {
getIdleItems().forEach((item, i) => {
delete item.dataset.isAbove;
delete item.dataset.isToggled;
item.style.transform = '';
});
}
// 페이지 스크롤 다시 활성화
function enablePageScroll() {
document.body.style.overflow = '';
document.body.style.touchAction = '';
document.body.style.userSelect = '';
}
setup();
</script>
</body>
</html>