从历史的大潮之中回顾,人类在很早之前就已经具备了将信息图形化的能力,古巴比伦人、埃及人、希腊人和中国人都开发出了以视觉方式表达信息的方法。例如:地图,星象图,日月星全年位置运行图(公元950年,以折线图的形式出现)等。当时的人们还没有数据可视化这一词的概念,但是已经具备了这样的能力和思想。如今通过低代码平台来实现数据可视化是较为流行的方式,而拖拽交互作为低代码平台的核心元素,体现了所见即所得及工具化的易用交互设计理念,它的广泛应用推动数据可视化的发展到达了新的阶段。
悟空作为大屏可视化的低代码开发平台,主要也是依靠拖拉拽操作实现大屏的组件布局,本文带大家了解悟空平台组件拖拉拽的实现原理(该演示demo为基于vue3技术栈)。
悟空的大屏编辑器作为最主要的工作区,组件的布局与调整全部都在此页面通过拖拉拽的方式进行操作。组件的拖拉拽能完成对组件的移动、拉伸,同时组件移动过程中还需要通过辅助线进行对齐,整齐的布局会提升页面的美观度。
本文主要从组件的拖拽移动、组件的拖拽拉伸及组件拖拽对齐三个方面进行简单的技术阐述。
组件的拖拽动作主要涉及到物料区和内容区两个区域,被拖拽元素位于物料区,放置区元素位于内容区。
在悟空内,物料区是大屏编辑器上方的组件选择栏,而内容区是中间占据最大空间的画布。组件选择栏按目录展示了悟空组件库内的丰富组件资源,而画布为大屏编辑预览区域。从组件选择栏挑选好组件后,通过点击或拖拽操作,可以将组件添加到画布内,完成大屏内组件的新增;选中画布内组件,也能以拖拽的方式任意移动组件位置。
组件从选择区到画布区是快速完成大屏组件基础布局的第一步,而要实现组件的位置精准与整体协调,使得大屏组件布局达到产品经理或设计人员的美学要求,往往需要千百次的拖拽调整。
物料区到内容区的拖拽,是基于H5的拖拽属性实现的。
一个典型的拖拽操作是指:用户选中一个可拖拽的(draggable)元素,并将其拖拽(鼠标按住不放)至一个可放置的(droppable)区域上,然后松开鼠标。
实现此操作需要包含两个步骤:
物料区元素被选中后开始拖拽时,绑定dragstart事件;
在内容区通过drop事件添加拖拽组件。
// 内容区添加组件
onDrop(e: DragEvent) {
this.isDragDroping = false;
this.addCard(e.offsetX, e.offsetY);
}
在拖动元素期间,一些与拖放相关的事件会被触发,像drag和dragover类型的事件会被频繁触发。
需要注意的是,dragOver事件的默认行为是:“Reset the current drag operation to "none"”。也就是说,如果不阻止放置元素的dragOver事件,则放置元素不会响应“拖动元素”的“放置行为”。
// 让绑定该事件的元素支持放置
function handleDragOver(e) {
// 阻止默认的重置行为
// 即可成为拖拽元素的放置区
e.preventDefault();
}
// 也可以直接在元素上添加如下代码
@dragover.prevent="() => {}"
内容区里的组件拖拽与物料区到内容区的拖拽不同,内容区的拖拽需要被拖拽元素跟随鼠标移动,实时动态改变元素位置,效果图如下:
实时动态改变组件位置,我们采用了css的transform属性中的translate方法对元素的位置进行改变,translate是基于本身的移动,将自身作为坐标原点(0,0),通过top/left改变组件位置。
具体步骤如下:
mousedown事件,摁下鼠标时,记录组件当前位置,拿到当前坐标;
mousemove事件,每次鼠标移动时,都用当前最新的坐标减去最开始的坐标,用新旧坐标差值,计算出组件移动距离,从而改变组件位置;
mouseup事件,鼠标抬起时结束移动。
代码如下:
html部分
<template>
<div class="vue-drag-resize" ref="vdrag"
:style="style"
@mousedown="onMousedown"
@click.stop.prevent="onClick">
<div class="drag-com">组件内容</div>
</div>
</template>
ts部分
// 定义拖拽组件属性
const left = ref<number>(0);
const top = ref<number>(0);
const width = ref<number>(200);
const height = ref<number>(120);
// 定义拖拽组件样式
const style = computed(() => {
return {
transform: `translate(${left.value}px, ${top.value}px)`,
width: `${width.value}px`,
height: `${height.value}px`,
// zIndex: zIndex,
};
});
// 组件mousedown事件
const onMousedown = (evt: MouseEvent) => {
mouseClickPosition.mouseX = evt.pageX;
mouseClickPosition.mouseY = evt.pageY;
document.documentElement.addEventListener("mousemove", mousemove, true);
document.documentElement.addEventListener("mouseup", mouseup, true);
};
// 计算偏移量,改变组件left和top值
const mousemove = (evt: MouseEvent) => {
left.value = left.value + (evt.pageX - mouseClickPosition.mouseX);
top.value = top.value + (evt.pageY - mouseClickPosition.mouseY);
mouseClickPosition.mouseX = evt.pageX;
mouseClickPosition.mouseY = evt.pageY;
};
组件的拉伸,也即放大缩小,我们通过增加八个辅助点来实现,选中画布上的组件,会出现8个小圆点可以拖动进行放大缩小,演示效果:
思路:
定义一个由8个变量定义的数组["tl", "tm", "tr", "mr", "br", "bm", "bl", "ml"];
与组件同级定义在拖拽组件元素下,并添加mousedown事件;
点击小圆点进行拖放操作:
a.鼠标摁下时判断当前拖放的圆点类型;
b.记录当前初始坐标;
c.向下拖动就用新的 y 坐标减去初始坐标可以得到移动距离,再把距离加上组件的高度计算得到新的高度;
d.上下只能调整高度,左右只能调整宽度,西北东北东南西南方向特殊处理。
与拖拽类似,拉伸是通过实时改变组件宽width\高height等属性来实现。需要给八个角的圆点添加mousedown事件,并对八种不同情况进行处理。
html部分
<template>
<div :class="['vue-drag-resize', { active: enabled }]"
ref="vdrag" :style="style"
"onMousedown" =
"onClick"> .stop.prevent=
<div
v-for="(item, index) in handles"
:key="index"
:style="handleStyle"
:class="['handle', 'handle-' + item]"
"ononMousedownHandle(item, $event)" .stop.prevent=
></div>
<div class="drag-com">组件内容</div>
</div>
</template>
// 定义八个角
const handles = ["tl", "tm", "tr", "mr", "br", "bm", "bl", "ml"];
...
// 以右上缩放为例
const ononMousedownHandle = (item: any, evt: any) => {
if (evt.stopPropagation) evt.stopPropagation();
handle.value = item;
mouseClickPosition.mouseX = evt.pageX;
mouseClickPosition.mouseY = evt.pageY;
document.documentElement.addEventListener("mousemove", handleResize, true);
document.documentElement.addEventListener("mouseup", handleResizeUp, true);
};
const handleResize = (evt: MouseEvent) => {
if (handle.value === "tr" && evt.pageY - mouseClickPosition.mouseY < height.value) {
width.value = width.value + (evt.pageX - mouseClickPosition.mouseX);
height.value = height.value + (mouseClickPosition.mouseY - evt.pageY);
top.value = top.value + (evt.pageY - mouseClickPosition.mouseY);
mouseClickPosition.mouseX = evt.pageX;
mouseClickPosition.mouseY = evt.pageY;
}
}
组件拖拽中的辅助线的生成需要配合画布中的其它组件,因此除了获取当前拖拽组件外,还需要获取画布上非拖拽的其他组件。要在封装的拖拽插件里获取到其它没有被拖拽的组件,需要操作dom。
获取当前拖拽组件:
// 渲染到页面的当前拖拽组件属性
const style = computed(() => {
return {
transform: `translate(${left.value}px, ${top.value}px)`,
width: `${width.value}px`,
height: `${height.value}px`,
// zIndex: zIndex,
};
});
获取画布所有组件:
// 获取当前父节点下所有子节点
const vdrag = ref<any>(null);
const parentNode = vdrag.value.parentNode;
通过遍历parentNode.childNodes,就可以与当前拖拽元素进行一一对比。
以横向x轴方向的辅助线为例,存在以下几种对齐情况:
a.顶对顶辅助线;
b.顶对底辅助线;
c.底对底辅助线;
d.底对顶辅助线;
e.中对中辅助线。
拖拽辅助线通常情况下都是配合吸附效果一起出现的,以顶对顶辅助线的生成为例。
代码如下:
if (top.value > nodeTop - 4 && top.value < nodeTop + 4) {
top.value = nodeTop;
rtlShow = true;
if (left.value > nodeLeft) {
rtlStyle.tlWidth = left.value - nodeLeft + width.value;
rtlStyle.tlLeft = nodeLeft;
} else {
rtlStyle.tlWidth = nodeLeft - left.value + nodeWidth;
rtlStyle.tlLeft = left.value;
}
rtlStyle.tlTop = top.value;
rtlStyle.tlHeight = 1;
rtlStyle.tlDisplay = "inline-block";
}
top和left就是我们当前拖拽元素的translate属性值,nodeTop和nodeLeft即为画布其它某个元素的translate属性值。当拖拽组件top在画布某个组件nodeTop的[-4, +4]范围内时,进行吸附并计算产生辅助线。效果图如下:
考虑到代码计算量和性能,我们使用了第二种方案。首先translate是基于本身的移动, 因此自身的坐标就作为原点(0,0),但是第一种,元素本身的top/left等可能并不为0,计算起来比较复杂。其次,第一种是通过cpu去计算,而第二种是通过gpu去计算,并且会提升到一个新的层,这样做非常有利于页面的性能。
辅助线是为了在大屏的实际配置中,能够有更整齐的排版和布局,而伴随辅助线的吸附效果就是拖拽组件在移动的过程中,与其它组件在接近对齐时自动对齐,这样的效果非常有利于提高大屏配置效率。
当没有找到相邻线时,组件跟随鼠标移动;
当初次找到时,组件便移动一个较大距离吸附过去;
当在吸附线上再次移动时,继续查找相邻线,看是否有下一条吸附线:
如果有,则移动到下一条吸附线上;
如果没有,则在鼠标移动一定距离后,组件离开。
效果图如下:
给每个物料组件增加拖拽方法是不现实的,更合理的我们可以将拖拽单独封装成组件,拖拽元素可以通过插槽的形式加入进来,实现拖拽逻辑与物料组件完全分离。
<div :class="['vue-drag-resize', { active: enabled }]" ref="vdrag" :style="style" @mousedown="onMousedown" @click.stop.prevent="onClick">
<div
v-for="(item, index) in handles"
:key="index"
:style="handleStyle"
:class="['handle', 'handle-' + item]"
@mousedown.stop.prevent="ononMousedownHandle(item, $event)"
></div>
<slot></slot>
</div>