UI拖拽的原理和实现

1,745 阅读5分钟

本文已参与「掘力星计划」,赢取创作大礼包,挑战创作激励金

欢迎各位加入虾皮,15天带薪年假以及业内顶级的薪酬,每个人都可以参与做技术项目,不需要永远做一颗螺丝钉,你是否心动了呢?快联系我内推(wx: R704369725),加入亚洲市值第三的公司吧!

背景

由于业务上有一些UI拖拽排序的需求,然后经过了解这方面的类库有SortableJsreact-dndreact-sortable-hoc

主要实现的效果为下面这个录屏,代码仓库

GIF 2021-10-18 21-58-26.gif

出于好奇心以及对知识的渴望,我去看了看react-sortable-hoc这个库的具体实现,这个拖拽效果到底是如何实现的呢?

一些前端基础知识

其实拖拽,就是对html元素的位置不断改变,看起来就像是拖动一样

因此先来简单介绍一下关于html中元素定位信息的一些知识

HTML Element Property

  • offsetParent 返回一个指向最近的(指包含层级上的最近)包含该元素的定位元素或者最近的 table,td,th,body元素
  • offsetWidth 用于获取元素的宽度
  • offsetHeight 用于获取元素的高度
  • offsetLeft 用于获取元素左上角相当于HTMLElement.offsetParent的左边距
  • offsetTop 用于获取元素左上角相当于HTMLElement.offsetParent的上边距

HTML Mouse Event

  • PageX 返回的相对于整个文档的x(水平)坐标以像素为单位的只读属性
  • PageY 返回的相对于整个文档的y(纵向)坐标以像素为单位的只读属性

getBoundingClientRect

Element.getBoundingClientRect() 方法返回元素的大小及其相对于视口的位置 image.png

关于demo的说明

拖拽其实只是一种视觉效果。根本原理是监听鼠标拖动事件,并把鼠标的移动距离加到拖动的元素上,于是便形成了拖拽的效果。

demo是阉割版,只保留了最基本的功能,从功能上,可以分为两个组件

SortableElement

  • 用于包裹需要拖动的每个element,用于添加css属性,形成视觉效果。每个拖动的元素,就是一个SortableElement

SortableContainer

  • 用于统一监听omMouseMove、onMouseDown、onMouseUp,然后由Container统一计算拖动对各个Element造成的影响后,下发每个Element的样式。

image.png

接下来就看看拖动效果到底是如何实现的,主要可以分为四步

  1. 元素挂载时(onComponentDidMount)
  2. 拖动前
  3. 拖动时
  4. 拖动后

SortableContainer挂载

获取ref,并传给各Elements

SortableElement挂载

  • 把自己的ref传给Container
  • 缓存自己在数组里的下标
  • 计算自己相对于Container的left和top偏移量,并缓存起来 怎么计算?这里就要用到上面的基础知识了,大概的做法就是从Element节点向上递归,取每一层级的offsetTop和offsetLeft并累加,直到加到Container为止。主要函数如下
export function getEdgeOffset(node, parent, offset = { left: 0, top: 0 }) {
  if (!node) {
    return undefined;
  }

  const nodeOffset = {
    left: offset.left + node.offsetLeft,
    top: offset.top + node.offsetTop,
  };

  if (node.parentNode === parent) {
    return nodeOffset;
  }

  // 递归拿left 和 top
  return getEdgeOffset(node.parentNode, parent, nodeOffset);
}

拖动前

  1. 把拖动的节点visibility设为hidden (请注意,并不是直接给目标元素添加偏移量)
  2. 使用cloneNode拷贝一个拖动节点对象,并用fix布局展示出来,这就是跟着鼠标移动的那个节点
  3. 记录拖动节点的index(这个就是onSortEnd中的oldIndex)
  4. 记录事件发生的坐标(event.pageX, event.pageY) 用于计算后续移动的位置 经过以上几步,相当于以Container的左上角为原点,建立了一个坐标系

image.png

拖动时

  1. 从事件中获取当前的pageX和pageY,并减去上面第四步中事件发生的坐标,得到鼠标目前为止的总偏移量translate
  2. 对于上面第二步中拷贝出来的节点,加上这段偏移量,看起来就像是节点跟随鼠标一起移动了
  3. 遍历全部的节点,全部的节点分为三种:拖拽节点前的节点(就是gif中的aaa、bbb、ccc、ddd、eee)、拖拽节点(gif中的fff)、拖拽节点后的节点(gif中的ggg以及后面的节点)

拖拽节点前的节点.png 拖拽节点前的节点

对于拖拽节点前的节点,有如下判断

// sortingOffset是拖拽节点现在距离Container的left和top
    if (
          now.index < draggedIndex && 这一行说明,这是前面的节点
          ((sortingOffset.left <= node.absoluteOffset.left &&
            sortingOffset.top <= node.absoluteOffset.top) || 这说明拖拽节点被拖拽到前面去了,所以当前节点需要挪动
            sortingOffset.top <= node.absoluteOffset.top) 这说明,拖拽节点被拖到上面去了,所以当前节点需要挪动
        ) {
          translate.x = draggedNode.offsetWidth; // 当前节点需要挪动的距离
          
          // 下面这个判断是,如果往后挪动后,是否会超出距离,如果会,则要换行
          // 换行的话需要根据下一个节点进行运算
          // 打个比方,fff移动到了ddd的前面,那么ddd需要移动
          // ddd的位置是left: 100px, top: 100px,但是eee(ddd的下一个节点)的位置是left: 0px, top: 100
          // 那么,如果ddd往后挪,会超出位置,因此需要换行,那么换行需要移动多少的距离?
          // left: 0 - 300 = -300, top: 100 - 0 = 100,所以ddd的translate就是 left: -300, top: 100
          // 因为translate是基于当前位置生效的,所以看起来ddd就完成了换行,移动到了原本eee的位置
          // 然后eee也会进行计算,发现fff挪动到了更前面的位置,于是eee也往后挪100的位置,就到了原本fff的位置上了
          if (
            node.absoluteOffset.left + translate.x >
            containerRef.current.getBoundingClientRect().width -
              offset.width * 2
          ) {
            if (nextNode) {
              translate.x =
                getEdgeOffset(nextNode, containerRef.current).left -
                node.absoluteOffset.left;
              translate.y =
                getEdgeOffset(nextNode, containerRef.current).top -
                node.absoluteOffset.top;
            }
          }
          setNewIndex(i);
        }

拖拽节点后的节点.png 拖拽节点后的节点 具体原理和上面差不多,就不赘述了,请聪明的读者举一反三

拖动结束

根据拖动时得出的newIndex,以及拖动开始时记录下的oldIndex,回调用户传入的onSortEnd,告知用户拖动的结果,等到用户根据拖动的结果调整数组后,会触发react新一轮的渲染,待新一轮的渲染后,便会按拖动后的顺序呈现。

上图中用户的操作大概可以这样理解

  1. 元素挂载,此时数组是['aaa', 'bbb', 'ccc', 'ddd']
  2. onMouseDown 此时知道正在拖拽的元素下标为3,内容为ddd
  3. onMouseMove 此时可以计算出拖动到了位置2
  4. onMouseMove 此时可以计算出拖动到了位置1
  5. 鼠标松开,回调onSortEnd(1, 3)
  6. 用户根据回调调整数组为['aaa', 'ddd', 'ccc', 'bbb']
  7. 按照变化后的数组重新渲染

React.Context

这里还有一个小小的知识点。SortableContainer以及SortableElement是通过Context来实现父子组件通信的,具体可以参考context

react官方不推荐使用context,因为context会让数据流变得复杂,但是在设计组件的时候,context能有效屏蔽组件内部的逻辑,实现用户无感的父子组件通信,降低用户调用的门槛。