JS 容器拖拽排序

775 阅读3分钟

前言

在业务需求下,容器内的子项通过拖拽进行位置调换(排序)的场景不在少数,多数情况下会借助第三方库 Sortable.js 来去实现功能。

如果让我们自己去实现一套拖拽排序,你该如何去做呢?其实也是借助于 HTML5 拖拽 API 来实现。

对于 HTML5 拖拽事件的用法可以翻阅之前这篇文件:「React 中使用拖拽」

下面,我们通过原生 JS 来分析并实现容器项的拖拽排序,并且伴随动画效果。

容器项排序

这里,我们先初始化环境,创建一个容器并添加几个子项,实现如下:

<!DOCTYPE html>
<html lang="en">
<head>
  <title>drag-sort</title>
  <style>
    *{
      margin: 0;
      padding: 0;
    }
    .list{
      width: 300px;
      list-style: none;
      margin: 100px auto;
      height: 500px;
      padding: 12px;
      border: 2px solid pink;
      overflow: auto;
    }
    .list-item{
      cursor: move;
      background: darkgray;
      border-radius: 4px;
      color: #FFF;
      margin-bottom: 6px;
      height: 50px;
      line-height: 50px;
      text-align: center;
    }
  </style>
</head>
<body>
  <ul class="list" id="container">
    <li class="list-item" draggable="true">列表1</li>
    <li class="list-item" draggable="true">列表2</li>
    <li class="list-item" draggable="true">列表3</li>
    <li class="list-item" draggable="true">列表4</li>
    <li class="list-item" draggable="true">列表5</li>
    <li class="list-item" draggable="true">列表6</li>
  </ul>
</body>
</html>

我们为每个 li 设置 draggable 属性,现在你可以按住 li 进行拖动并看到拖动效果。

有了拖动效果还不够,我们希望拖动一个子项,到另一个子项上时,两者能够发生位置交换。下面我们基于拖拽事件来实现此功能。

<script>
  function _index(el) {
    let index = 0;
    if (!el || !el.parentNode) {
      return -1;
    }
    while (el && (el = el.previousElementSibling)) { // 逆向思维,从当前元素向前查找,确定元素所在位置
      index++;
    }
    return index;
  }

  const container = document.querySelector("#container");
  let dragEl = null;

  container.ondragstart = function(event) {
    dragEl = event.target;
  }
  container.ondragover = function(event) {
    event.preventDefault();
  }
  container.ondragenter = function(event) {
    const target = event.target;
    if (target.nodeName.toLowerCase() === "li" && target !== dragEl) {
      // 非列表内元素排序
      if (!container.contains(dragEl)) return;

      // 进行位置交换
      const after = _index(dragEl) < _index(target);
      target.parentNode.insertBefore(dragEl, after ? target.nextSibling : target);
    }
  }
</script>

实现排序的关键就在于 parentNode.insertBefore 来操作 DOM 交换两个子项的位置,比较重要的一点是 _index 方法的实现。

排序动画

既然容器内的元素可以拖动排序了,但使用体验稍加欠缺。若是能有个过渡动画,拖拽排序起来会感觉很舒服。下面我们加入 animation 动画逻辑。

动画的思路大致如下:

  1. 记录拖拽元素和目标元素交换之前的位置;
  2. 交换后记录拖拽元素和目标元素现在的位置;
  3. 将元素回到交换之前,应用动画并通过 translate 交换后的位置。

关键在于记录位置。

// DOM 操作动画的实现
function _animate(prevRect, el) {
  const animation = 150;
  const currentRect = el.getBoundingClientRect();
  // 1、回到初始位置
  el.style['transition'] = 'none';
  el.style['transform'] = `translate3d(${prevRect.left - currentRect.left}px, ${prevRect.top - currentRect.top}px, 0)`;
  el.offsetWidth; // 触发重绘
  // 2、回到移动后的位置
  el.style['transition'] = `all ${animation}ms`;
  el.style['transform'] = `translate3d(0, 0, 0)`;

  clearTimeout(el.animated);
  el.animated = setTimeout(() => {
    el.style['transition'] = 'none';
    el.style['transform'] = 'none';
    el.animated = undefined;
  }, animation);
}

// 拖拽加入动画逻辑
container.ondragenter = function(event) {
  const target = event.target;
  if (target.nodeName.toLowerCase() === "li" && target !== dragEl) {
    // 非列表内元素排序
    if (!container.contains(dragEl)) return;

    // 防止重复触发动画
    if (target.animated) return;

    // 1、记录移动前的位置
    const preTargetRect = target.getBoundingClientRect();
    const preDragRect = dragEl.getBoundingClientRect();

    // 2、进行位置交换
    const after = _index(dragEl) < _index(target);
    target.parentNode.insertBefore(dragEl, after ? target.nextSibling : target);

    // 3、应用动画
    _animate(preDragRect, dragEl);
    _animate(preTargetRect, target);
  }
}

封装实例

我们可以将上述代码封装为一个构造函数,这有些类似于 Sortable.js,封装如下:

function _index(el) {
  let index = 0;
  if (!el || !el.parentNode) {
    return -1;
  }
  while (el && (el = el.previousElementSibling)) { // 逆向思维,从当前元素向前查找,确定元素所在位置
    index++;
  }
  return index;
}

function _animate(prevRect, el, animation) {
  const currentRect = el.getBoundingClientRect();
  // 1、回到初始位置
  el.style['transition'] = 'none';
  el.style['transform'] = `translate3d(${prevRect.left - currentRect.left}px, ${prevRect.top - currentRect.top}px, 0)`;
  el.offsetWidth; // 触发重绘
  // 2、回到移动后的位置
  el.style['transition'] = `all ${animation}ms`;
  el.style['transform'] = `translate3d(0, 0, 0)`;

  clearTimeout(el.animated);
  el.animated = setTimeout(() => {
    el.style['transition'] = 'none';
    el.style['transform'] = 'none';
    el.animated = undefined;
  }, animation);
}

let dragEl = null;
let dragIndex = -1;

function SortDrag(config) {
  this.container = config.container;
  this.animation = config.animation || 150;
  this.sort = config.sort === false ? false : true;
  this.onSort = config.onSort;
  this.init();
}

SortDrag.prototype.init = function() {
  this.container.ondragstart = event => {
    dragEl = event.target;
    dragIndex = _index(dragEl);
  }
  this.container.ondragover = event => {
    event.preventDefault();
  }
  this.container.ondragend = event => {
    const oldIndex = dragIndex, newIndex = _index(event.target);
    event.oldIndex = oldIndex;
    event.newIndex = newIndex;
    if (oldIndex !== newIndex) {
      this.onSort && this.onSort(event); // 通知排序完成
    }
  }
  this.container.ondragenter = event => {
    const target = event.target;
    if (target.nodeName.toLowerCase() === "li" && target !== dragEl) {
      if (!this.sort) return;

      // 非列表内元素排序
      if (!container.contains(dragEl)) return;

      // 防止重复触发动画
      if (target.animated) return;

      // 1、记录移动前的位置
      const preTargetRect = target.getBoundingClientRect();
      const preDragRect = dragEl.getBoundingClientRect();

      // 2、进行位置交换
      const after = _index(dragEl) < _index(target);
      target.parentNode.insertBefore(dragEl, after ? target.nextSibling : target);

      // 3、应用动画
      _animate(preDragRect, dragEl, this.animation);
      _animate(preTargetRect, target, this.animation);
    }
  }
}

现在,我们只需这样使用即可:

const instance = new SortDrag({
  container: document.querySelector("#container"),
  animation: 150,
  referenceLine: true,
  onSort: event => console.log('event: ', event.oldIndex, event.newIndex)
})

最后

感谢阅读。

参考:
1. 原生 js 元素拖拽动态排序
2. 基于Vue快速实现列表拖拽排序