SOITZ

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 기능에 대해 이해하는데 많은 도움이 되어서 이 튜토리얼을 간략하게 정리해보았습니다.

drag-and-drop-order-list

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로 리스트 순서를 변경하는 방법에 대해 알아보았습니다. 이 기능을 통해 사용자는 직관적인 방식으로 웹 페이지 내의 요소들의 순서를 조정할 수 있게 됩니다. 이런 상호작용은 웹 앱의 사용성을 크게 향상시키며, 사용자 경험을 개선합니다.

참조


전체 코드

아래는 이 튜토리얼의 전체 코드입니다.

<!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>