本文由 简悦 SimpRead 转码, 原文地址 mp.weixin.qq.com
本文作者:来自 MoonWebTeam 的 acejhli
本文编辑:kanedongliu
- 引言 =====
低代码编辑器主要有物料系统、配置表单、组件编排三部分组成,实现组件编排核心能力则是拖拽能力,它是编辑器的交互基础,它能极大地提升用户在使用系统时的交互体验,因为它通常意味着用户可以直观地操作界面,实现所见即所得,大大提高了使用效率。
更重要的是,这种直观的操作方式也减少了用户的学习成本,让用户在短时间内就能上手操作,从而提高了用户的满意度和系统的使用率。
除此之外,拖拽技术在各种场景中都可以得到非常有效的应用。例如,在进行项目或元素的排序时,我们可以通过拖拽来轻松地改变它们的顺序。在文件上传的过程中,拖拽能够使用户更方便地选择和提交文件。在进行逻辑编排时,拖拽技术可以帮助用户更直观地理解和操作流程。
本文将会带大家了解浏览器中拖拽相关的 API 有哪些,对比市面上常见的拖拽库都有什么区别,最后一起探索一下 moveable 这个库的实现原理,并带大家简单地实现一个 moveable able 来扩展 moveable 的能力。
- 浏览器中如何实现拖拽能力 ===============
2.1. 浏览器中的实现方式
如果你有仔细观察,你会发现浏览器中有些元素默认就是可拖拽的,某些元素也是默认可放置的。默认可拖拽的元素包括:选中的文本、图片、链接,而输入框默认也可以作为文本的可放置元素。
如果你想自定义拖拽元素和放置的位置,那么就可以使用到浏览器提供的拖放操作 API:Drag and Drop API.
2.1.1.HTML5 Drag and Drop API
DnD( Drag and Drop API)是在 HTML5 中新增的,提供了原生支持的拖拽功能。通过使用该 API,开发者不仅可以方便元素拖拽,也能跟操作系统交互,从其他程序中拖拽文件到浏览器中放置。
它主要有两个概念 drag source 和 drop target,允许拖动的元素 drag source 通过鼠标长按拖拽放置到到 drop target 上。
该 API 组主要通过以下事件实现相关的功能:
dragstart:当用户开始拖动元素时触发。
drag:当元素被拖动时连续触发。
dragenter:当拖动的元素进入放置目标时触发。
dragleave:当拖动的元素离开放置目标时触发。
dragover:当元素被拖动到放置目标上方时连续触发。默认情况下,数据 / 元素不能放置在其他元素上。要允许放置,我们必须阻止对此事件的默认处理。
drop:当元素被放置到放置目标时触发。
dragend:当拖动操作结束时(释放鼠标按钮或按下 ESC 键)触发。
2.1.2. 使用 mouseenter、mousedown 和 mouseup 等事件接口
另一种常见的实现方式是使用鼠标事件接口,如 mouseenter、mousedown 和 mouseup 等。通过监听这些事件,开发者可以捕获用户的拖拽意图, 并相应地调整元素。
这种方式相对灵活,可以根据具体需求进行定制,但可能需要更多的逻辑处理来确保拖拽的准确性和流畅性。可以不局限于拖拽 & 放置这种固定操作模式,比如直接拖动改变位置、双击改变大小等。
在移动端场景,我们则需要 touchstart、touchend、touchmove 等事件来实现,同时也能通过多指操作来实现更复杂的逻辑。
2.1.3. 使用 Canvas 实现
上面说到的方式都是与 html 元素在交互,但是在更复杂的场景,我们可能会使用到 canvas。事实上在 canvas 中也是通过鼠标事件来实现拖拽能力的,这里单独提出来是因为相对于其他方式,它的使用场景更加特殊,实现起来也会复杂许多。
他通常用于高性能、大量元素、自定义要求高的场景。例如:在线版的 PS、在线版的 CAD、以及 figma 等新一代画图软件。
2.2. 各种实现方式的对比
| HTML5 Drag and Drop API | 使用鼠标事件接口 | 使用 Canvas 实现 | |
|---|---|---|---|
| 实现方式 | 使用 HTML5 标准中的 Drag and Drop API,提供原生的拖拽功能 | 使用鼠标事件接口,通过监听这些事件,可以捕获用户的拖拽意图, 并相应地移动元素 | 使用 Canvas API,通过在 Canvas 上绘制元素,并使用鼠标事件来监听和处理拖拽操作 |
| 难易程度 | 相对简单,但操作模式相对固定,可能需要更多的代码来处理复杂的拖拽逻辑 | 相对复杂,可能需要更多的逻辑处理来确保拖拽的准确性和流畅性 | 很复杂,需要绘制元素以及自己计算元素的位置,可能需要更多的计算资源,需要处理更多的性能问题 |
| 灵活性 | 较低、一般用于从一个容器拖动到另一个容器 | 较高,可以根据具体需求进行定制 | 最高,可以创建出各种复杂的拖拽交互效果 |
| 可扩展性 | 可以实现文件拖拽、元素拖拽等功能 | 较好,但需要处理多种鼠标事件,通过 touch 事件可以兼容移动端 | 最好,可以实现各种复杂的自定义拖拽效果 |
| 常见的产品 | element-ui 的上传组件、排序组件 | TMagic、钉钉宜搭 | figma、在线 PS |
2.3. 常见的拖拽库有哪些
为了更加方便快速地在项目中引入拖拽能力,我们也许会去看看业内有哪些现成的轮子。我收集了比较常见的几个拖拽的实现,供大家参考。
2.3.1 React DnD
《React DnD》(https://react-dnd.github.io/react-dnd/about) 强调的是拖动和放置,实现的是把一个元素拖动到另一个元素上,这是对原生 DnD(HTML5 Drag and Drop API)接口扩展实现。
React DnD 本身是一个基于 HTML5 的拖放 API 构建的 React 高阶组件。它利用 React 组件的生命周期以及 context API 对拖放状态进行管理。React DnD 的设计理念是把 DOM 操作和事件处理交给开发者,他只负责定义接口和状态管理。
所以 React DnD 并不能单独使用,需要额外的后端模块使用 react-dnd-html5-backend,这个后端模块则是利用浏览器的 drag 和 drop 接口具体实现拖拽的交互。其他的后端模块还有 react-dnd-touch-backend,这可以允许在移动端实现拖拽交互。以及 react-dnd-test-backend 对测试的支持。
React DnD 在使用上,依赖对外暴露的 collect 接口,当被拖拽元素或放置元素的状态发生变化时,就会回调 collect,从而可以根据这个状态更新组件状态。
function DragBox() { const [{isDragging}, dragRef] = useDrag({ type: type, item: {type}, collect(monitor) { return { // 是否在拖动状态 isDragging: monitor.isDragging(), } }, }); return <> <div className={classnames({ drag: true, // 根据状态修改样式 dragging: isDragging, })} ref={dragRef}></div> </>}React DnD 可以用于大多数的拖拽场景,例如拖动排序、低代码平台组件的拖拽。
2.3.2 moveable
《moveable》(https://github.com/daybrush/moveable) 强调的是对元素的操作:移动、缩放、旋转、变形等。
moveable 的实现原理主要是通过监听鼠标和触摸事件,然后根据这些事件计算元素的变换属性,进而在回调中执行对应的操作。
Moveable 支持多种交互操作,包括但不限于拖放、缩放、旋转、扭曲和调整大小等。而且能够支持批量操作,对元素操作做了高度地封装。Moveable 非常适用于那些需要进行复杂交互操作的应用场景,例如设计工具、图形编辑器等。
2.3.3 Interact.js
《Interact.js》(https://github.com/taye/interact.js) 对浏览器原生事件进行封装,通过简洁统一的 API,对多端交互做了统一处理,特别适合在移动端场景使用。
Interact.js 是一个高度灵活且模块化的 JavaScript 库,它提供了一种简洁且一致的 API,可以让你通过监听原生的鼠标和触摸事件,进而实现拖放和其他多种交互操作。
Interact.js 的特点是它提供了一种基于事件的 API,你可以自定义事件处理函数来说实现你的交互逻辑。
// 声明 drop targetinteract('.dropzone').dropzone({ // 声明 drag source accept: '#yes-drop', // 放置时的重叠部分 overlap: 0.75, // 允许放置时 ondropactivate: function (event) { event.target.classList.add('drop-active') }, // 拖拽进入 drop target 时 ondragenter: function (event) { var draggableElement = event.relatedTarget var dropzoneElement = event.target dropzoneElement.classList.add('drop-target') draggableElement.classList.add('can-drop') draggableElement.textContent = 'Dragged in' }, // 拖拽离开 drop target 时 ondragleave: function (event) { event.target.classList.remove('drop-target') event.relatedTarget.classList.remove('can-drop') event.relatedTarget.textContent = 'Dragged out' }, // 已放置 ondrop: function (event) { event.relatedTarget.textContent = 'Dropped' }, // 当不允许放置时 ondropdeactivate: function (event) { event.target.classList.remove('drop-active') event.target.classList.remove('drop-target') }})Interact.js 对触摸、鼠标和指针事件做了统一处理,这意味着你可以在多种设备上提供一致的交互体验。Interact.js 非常适合那些需要在多种设备上提供一致交互体验的应用,例如可视化数据分析工具、缩放旋转等交互操作的实现。
- moveable 库介绍 ===============
上面提到的拖拽库各有所长,可以结合具体的场景是使用。在笔者参与的低代码编辑器项目中,正是使用到了 moveable 这个库的能力,所以接下来让我们一起探索一下 moveable 的实现原理吧。
3.1. 实现原理
moveable 是一个支持多 UI 框架的库,react-moveable 是它的核心实现,其他 UI 框架的支持都是直接或间接通过 react-moveable 实现的。
个人猜测这么做的原因主要有两个:一个是 react 的灵活性比较大,保证灵活性的同时也能利用框架能力提效;另一个则因为该库是作者 scena 项目的基础组件,moveable 的首要用户是他们自己,react 则是他们团队或项目的技术栈。
preact-moveable 的实现非常简单,实际并没有增加任何的代码,只是修改了它的 ts 类型,以便在 preact 中使用。
import Moveable from "react-moveable";import Preact from "preact";import { PreactMoveableInterface } from "./types";export default Moveable as any as new (...args: any[]) => PreactMoveableInterface;在原生 js 和其他框架的适配上,moveable 并没有直接使用完整的 react 来渲染,不然的话就有点杀鸡用牛刀了。它选择自研的类 react 库—— 《croact》(https://github.com/daybrush/croact)。croact 和 preact 的做法有些相似,使用更少的抽象层和逻辑来打造更轻量级的 react 渲染方案,而它的主要目标就是为了方便将基于 react 构建的组件用于其他 UI 框架。所以 moveable 在此之上适配到其他框架,都是通过 croact 来渲染的。
因为 react-moveable 的方法都是挂载到组件之上的,要在 react 外调用组件的方法,可以通过组件的 ref 参数,在创建真实 DOM 时执行会 ref 指定的函数,将元素作为第一个参数传递传入,这时候我们也就能拿到该组件的实例。
containerProvider = renderSelf( <InnerMoveable ref={ref(this, "innerMoveable")} {...nextOptions} {...events} /> as any, selfElement,);export function ref(target: any, name: string) { return (e: any) => { e && (target[name] = e); };}moveable 的核心都在 《MoveableManager》(https://github.com/daybrush/moveable/blob/master/packages/react-moveable/src/MoveableManager.tsx) 这个类上,它本身是一个 react 的组件,利用 react 的生命周期方法来管理自身的状态。
moveable 通过两种类型的事件来实现各种功能,这两种事件也会通过回调函数的方式暴露给 able 和使用方。一种是浏览器原生的 mouseEnter 和 mouseLeave 事件,另一种则是通过 《Gesto》(https://github.com/daybrush/gesto) 封装的事件:dragStart、drag、dragEnd、pinchStart、pinch、pinchEnd。
事件操作的对象包括目标元素和控制元素,控制元素指的是 able render 出来的组件。分别对各操作对象的不同处理,就能做不同的交互了。
例如:监听目标元素的 drag 事件,修改元素的位置,可以实现 draggable 的能力。监听 control 元素的 drag 事件,更新 control 的位置和修改目标元素的大小,可以实现 scalable 和 resiable 的能力。
3.2. 能力扩展
moveable 不仅是一个基础功能丰富的库,而且本身也非常灵活和可扩展,允许用户实现更加复杂有趣的功能。
moveable 是通过定义 able 的方式来实现功能的扩展,例如 Tmagic 选中组件后上面的操作按钮就是通过 able 的方式扩展的。
- 实现一个 moveable able =====================
看完 moveable 的实现原理之后,我们不妨上手做一个 able 来扩展一下它的能力。
4.1. 如何自定义一个 able
官方仓库中提供了 able 的 api 《文档》(https://github.com/daybrush/moveable/blob/master/packages/react-moveable/src/ables/README.md),在 《storybook》(https://daybrush.com/moveable/storybook/?path=/story/make-custom-able--custom-able-dimension-viewable) 中也有对应的例子,参照文档和官方示例我们就能很实现一个简单的 able。
定义一个最小 able,我们只需要实现提供一个 name 和一个 render 函数即可。
const CustomAble = { name: 'customeAble', render() { return <div class></div>; }}name 即是对 able 的声明,在初始化 moveable 时,需要通过 name 来开启 able 的能力。render 方法返回一个 react node,因为 moveable 的底层是 react 实现的,moveable 的各种功能一般都通过一个元素操作,所以我们这里会把一个 control 组件渲染到页面上。
例如我们可以在这个元素上绑定一个删除元素的点击事件,那我们就能实现删除目标元素的效果。
render(moveable) { function handleClick() { moveable.getTargets()[0].remove(); } return <div class onClick={handleClick}></div>;}4.2. 实现一个智能放置组件的 able
笔者设计这个 able 的功能是:给定一个方框,当目标元素拖动到这个方框的范围时,方框高亮,这时候如果放开鼠标按键,就会把目标的元素的大小位置设定到这个方框里面。
4.2.1. 参考范围
首先给这个 able 取一个唯一的名字,就叫做 snappableSizeAble,还需要定义一个参考范围,用来指定目标元素变化后的大小和位置,就用 Rect 类型表示即可。所以我们可以这样定义。
interface SnapSizeRect { top: number; left: number; width: number; height: number;}export const SnappableSizeAble: Able = { // 定义 able 的名字 name: 'snappableSizeAble', // 定义 able 接收的参数 props: [ 'snapSizeRect' ],};// 使用function App() { const targetRef = useRef(null); return ( <div class> <div className='target' ref={targetRef}></div> <Moveable // 声明需要使用的 ables ables={[SnappableSizeAble]} props={{ // 根据组件的名字将配置设置成 ture 才能启用 able snappableSizeAble: true, // 传入 able 的参数 snapSizeRect: { top: 200, left: 400, width: 200, height: 100, }, }} target={targetRef} // 开启拖拽和缩放的能力 scalable={true} draggable={true} // 当拖拽和缩放时通过 transform 修改元素的位置和大小 onScale={e => { e.target.style.transform = e.drag.transform; }} onDrag={e => { e.target.style.transform = e.transform; }} ></Moveable> </div> );}4.2.2. 渲染参考范围标识
我们需要有一个标识来表示可以放置的位置,以及放置生效时需要有高亮提示。所以我们使用到了 able 的 render 方法,用来渲染一个范围标识。
export const SnappableSizeAble: Able = { // ……省略其他 render(moveable: MoveableManagerInterface<{snapSizeRect: SnapSizeRect}>) { const sizeRect = moveable.props.snapSizeRect; const style = { transform: `translate(${-moveable.state.left}px, ${-moveable.state.top}px)`, position: 'fixed', // 根据参数设置的范围来设置范围标识的大小和位置 top: `${sizeRect.top}px`, left: `${sizeRect.left}px`, width: `${sizeRect.width}px`, height: `${sizeRect.height}px`, // 通过背景图的样式改变来标识是否可放置 // moveable.hitTest 是用来检测是否碰撞的方法 backgroundColor: moveable.hitTest(sizeRect) ? 'rgba(0, 115, 255, 0.146)' : 'transparent', }; return <div // key 是必传的 key='snappable-size' className='moveable-snappable-size' style={style} > </div> },};这时候,我们就能实时检测目标元素的位置并更新范围标识的样式了。
4.2.3. 自适应功能的实现
当拖拽结束后,把 target 元素的大小和位置设置成和 sizeRect 表示的信息一致即可。需要注意的是,在修改 target 的样式后,需要通过 moveable.updateRect() 触发更新。
export const SnappableSizeAble: Able = { // 定义了 dragStart 才会回调 dragEnd dragStart() {}, // 拖拽结束后会调用 dragEnd(moveable: MoveableManagerInterface<{snapSizeRect: SnapSizeRect}>) { const sizeRect = moveable.props.snapSizeRect; if(!moveable.hitTest(sizeRect)) return; // 如果在标识的范围内,就更新 target 的样式 const target = moveable.getTargets()[0]; target.style.width = `${sizeRect.width}px`; target.style.height = `${sizeRect.height}px`; target.style.transform = `translate(${sizeRect.left}px, ${sizeRect.top}px)`; // 修改 target 样式后需要调用这个方法更新 moveable 的状态 moveable.updateRect() }, // ……其他逻辑};至此,我们就实现了拖拽到指定范围内,元素就自动修改成指定的大小和放置到指定位置。
4.3. 应用场景
以上我们实现了一个基本可用的 able ,不妨思考一下,这个能力我们可以用在哪些地方呢?
我目前想到的应用场景是在低代码编辑的时候,可能存在一些特殊的容器组件内部仅包含一个组件,且该宽高位置和容器组件一致,利用这个能力就可以做一个交互优化。
另外一个就是如果编辑器使用的场景是活动,这种场景往往是一个基础背景图 + 组件实现,背景是包含按钮的位置等图案的。如果能结合 AI 的能力识别出背景图中的按钮和其他组件的边框位置,也能提高页面的编排效率。
- 总结 =====
浏览器的拖拽能力在许多地方都会用到,例如我们团队正在开发的低代码编辑器、以及后续计划开发的逻辑编排系统、接口裁剪系统等相关的系统,或者 C 端的一些交互需求,都可能需要用到相关的能力。
本文对常用的几个拖拽库做了一些介绍和对比,也针对 moveable 的实现原理做了简单的介绍,并带读者们通过 moveable able 扩展了拖拽功能。
最后,如果客官觉得文章还不错,👏👏👏欢迎点赞、转发、收藏、关注,这是对小编的最大支持和鼓励,鼓励我们持续产出优质内容。