拖拽参考线及吸附工具react-dragline

大概在2017年7月,我司计划开发一款可视化建站的项目。由于团队初建人手短缺,当时只有一年工作经验的我被“赶鸭子上架”,开始了为期一年半的折腾之旅。在众多复杂的交互中,有一项需求是“拖拽对齐吸附及显示参考线”,当时也希望在社区寻找解决方案。很可惜,除了一些简单的DEMO外,并没有可用于生产环境的实践。一年半过去了,项目接近尾声不那么忙。我逐步整理出自己在工作中的解决方案,于是就有了这个开源项目react-dragline

示例

开门见山,首先上一个简单的例子。

import { DraggableContainer, DraggableChild } from 'react-dragline'

const children = [
  { id: 1, position: { x: 100, y: 10 } },
  { id: 2, position: { x: 400, y: 200 } },
]

const containerStyle = {
  height: 600,
  position: 'relative',
}

const childStyle = {
  width: 100,
  height: 100,
  cursor: 'move',
  background: '#8ce8df',
}

export default function Example() {
  return (
    
      {
        children.map(({ id, position }) => (
          
            
)) } ) }

然后你就可以动手拖一拖体验一下啦~ 在线DEMO戳我

关于调用方式,起初参考了react-sortable-hoc,计划使用HOC的写法,但感觉使用HOC会把代码从JSX中分离,复杂度不高的情况下有些过度设计,所以这选择了这更传统的写法。位置属性是通过绝对定位实现的,因此需要使用者自行为DraggableContainer加上定位属性relative/absolute/fixed,本意是检测到没有定位属性时自动加上relative,但是这种方式在服务端渲染的场景下会有丑陋的“跳动”(因为只有在客户端才能检测DOM嘛),因此就把这项功能给去了。更多的options都写在README里了,出自我的“中式英语”大家阅读起来也没什么难度。

实现原理

关于原理,拖拽功能是基于react-draggable的Uncontrolled组件DraggableCore,统一使用left和top作为x,y坐标的映射。DraggableContainerDraggableChild之间的通信是通过React.cloneElement实现的。剩下的,就是把精力集中于实现核心功能参考线和吸附。以下根据拖拽的事件周期onstartondragonstop分别阐述。

onstart

在拖拽初始中获取每个DraggableChild的坐标、宽高、索引等信息。可以将所有的DraggableChild分为两类,target为当前拖拽的元素,compares为其余的元素,可以称为对照组(为了方便行为,下文中target即代表拖拽目标元素,compare即代表当前与target比较的元素)。为什么不在componentDidMount中就获取好呢?因为这些元素的信息可能会变得,比如说增删。相比起这细微的性能损失,维护信息变化的成本显然要高得多。

ondrag

核心代码主要在ondrag的过程中,我们需要不断的去比较targetcompares之间的距离是否小于阈值threshold(默认5px)。考虑过是否需要加上debounce,但是似乎对灵敏度还是有些影响,不是一个太好的选择。
吸附功能的实现相对简单,坐标和对照组的某元素的距离小于阈值threshold时,让其等于对照组的坐标即可:

  // a 为对照组某元素的坐标
  if (Math.abs(a - x) < threshold + 1) {
    x = a
  }

参考线的实现略微复杂一些,以Y轴方向为例,最初的实现是分别取target元素和compare元素上下位置(Element.getBoundingClientRect),组成一个包含四个值的数组[t, b, T, B]target用小写字母表示,compare用大写字母表示),最大差值(排序取首尾值相减)即为参考线的长度,取最小值作为参考线的起点。
这么做似乎也没有什么问题,实际上是有一些细微的误差的。使用DOM元素的位置信息计算具有一定的滞后性,DOM表示的是当前的位置,而计算的是拖拽下一帧的位置,这样“细微的误差”也就可以解释了。解决方式也很简单,将数组中的tb替换为yy + height即可。
结束了吗? 并没有。最初的设计是将计算xy是否需要吸附和参考线在统一流程里,因为有吸附才有会出现参考线,避免了重复的计算。然而,当target元素同时和X轴和Y轴两个compare元素吸附时,Y轴的参考线是会受到Y轴吸附的影响(X轴同理)。见下图:

当水平方向上两个绿色的元素吸附时,Y轴的参考线也必须“突然地增加了一段”。因此后续又做了一次代码封装粒度更小的重构,以在计算完成xy吸附之后再对参考线作出一次修正。

onstop

拖拽结束就比较简单了,将参考线的和一些其它状态清除就好了。

收获与总结

在整理这些项目的过程中,除了核心代码本身,还有一些我觉得更为宝贵的收获。

在构建方式上,我们日常的项目(Application)开发都是把源码和第三方依赖等打包成“可执行文件”,即可以直接扔到浏览器上跑JavaScript代码。但是在打造一款第三方项目(Library)时,这样做是显然不可行的。试想一下,如果一个项目有10个第三方依赖,而每个依赖都引入classnames,如果这些第三方依赖包都把classnames打包到源码中,那对于使用者来说,岂不是有10份重复的classnames代码?实际上我们需要做的是“只编译,不打包”。可否记得你在使用npm install的时候安装数量都是远远大于写在package.json内依赖的数量?没错,所有依赖及依赖的依赖...都是由用户统一安装,这样就可以避免了上述“10份重复的代码”的问题。另外,一般考虑到浏览器用户,确实会提供一份把依赖也打包进源码的UMD文件。

关于前端测试,这也是我之前了解较少的领域。一般前端业务变化频繁,生命周期相对较短,不太具备持续迭代的可能,因此写测试倒说不上是一个性价比高的选择。但是对于需要持续迭代的底层UI(组件库),单元测试的必要性还是很高的。因此我也假模假样地基于jestenzyme写了一些测试用例,以保证后续迭代的不会因为粗心大意而对之前功能有所影响。

细心的朋友可能会发现,我在DraggableChild中使用的defaultPosition而不是position,这里就涉及到一些uncontrolled components的知识。一般来说,合格的React组件是需要提供positiondefaultPosition两种使用方式的。但是考虑到吸附功能是需要对元素的位置具备完全地控制能力,因为初步决定只提供defaultPosition的使用方式。

react-dragline算是我的第一个不那么玩具的开源项目了,欢迎大家交流拍砖~


原文首发于我的博客https://www.vq0599.com/p/44,转载请注明。

你可能感兴趣的:(拖拽参考线及吸附工具react-dragline)