讲讲两个经典布局组件的实现
① 布局容器组件
配置面板是给用户配置布局容器背景颜色等属性。这里我们不需要关注
定义文件
规定了组件类的类型、标签、图标、默认布局属性、主文件等等。
// index.js
import Container from './container.vue';
class ContainerControl extends BaseControl {
type = 'container';
label = '布局容器';
icon = 'tc-icon-layout';
...
layout = {
w: 30,
h: 15,
minH: 8,
};
// 组件实现的主文件
DashboardComponent = Container;
}
export default new ContainerControl();
入口文件会通过一系列逻辑生成【类型枚举类】,我们最后通过control['container'].DashboardComponent找到主体文件生成组件。这些我们简单了解就好啦。
具体来看看container.vue文件。
组件主体
// container.vue
<template>
<drag-container
v-bind="fieldProps"
@inChildComponent="$emit('inChildComponent', $event)"
@add="handleAdd"
@delete="handleDelete"
@drop="syncDataToStore('add', $event)"
>
<drag-container-layout
v-bind="fieldProps"
:layout.sync="layout"
:fields="fields"
@resized="syncDataToStore('size', $event)"
@moved="syncDataToStore('location', $event)"
@edit="syncDataToStore('edit', $event)"
@delete="syncDataToStore('delete', $event)"
@select="handleSelect"
/>
</drag-container>
</template>
这里的drag-container其实长这样:
// drag-container
<template>
<div
@dragenter="dragenter"
@dragover="dragover"
@dragleave="dragleave"
@drop="drop"
>
<slot />
</div>
</template>
是不是很熟悉?对,就是上一章讲的包裹着组件的drag事件层。用来触发inChildComponent事件的。
drag-container-layout其实就是一个 grid-layout。有运行时和设计时两种情况(设计时可以拖拽组件进去,运行时只是纯展示)
// drag-container-layout.vue
<template>
<grid-layout
:layout.sync="layout"
:col-num="60"
:row-height="15"
:isDraggable="!isRuntime"
:isResizable="!isRuntime"
:useCssTransforms="!isRuntime"
>
<template v-for="layoutItem in layout">
<!-- 运行时 -->
<component
v-if="isRuntime"
:is="Item"
:key="layoutItem.i"
v-bind="getComponentProps(layoutItem)"
/>
<!-- 设计时 -->
<grid-item
v-else
:key="layoutItem.i"
v-bind="getLayoutProps(layoutItem)"
@moved="$emit('moved', layoutItem)"
@resized="$emit('moved', layoutItem)"
@mousedown.native.stop="handlePointerDown"
@mouseup.native.stop="handlePointerUp($event, layoutItem.i)"
>
<component
:is="getComponent(layoutItem)"
v-bind="getComponentProps(layoutItem)"
@deleteComponent="handleDelete({ i: $event })"
/>
</grid-item>
</template>
</grid-layout>
</template>
添加组件
上一节我们已经将过点击添加到布局组件内,所以这节主要展开讲讲拖拽。逻辑跟上一节会有一些不一样,上一节主要还是为了方便理解。
拖拽组件进入布局组件内部时,drag-container层首先响应。触发dragenter事件
/** @name 进入-有效目标 **/
dragenter() {
if (this.limit) return;
this.$emit('inChildComponent', true);
}
当拖拽进来的组件是布局组件时,this.limit为true。这里的业务逻辑是不允许多层嵌套所以在这里做了阻断。此时不会给外界传递inChildComponent事件,仪表盘的gird-layout也不需要改变this.isInChildCom。这里跟上一节讲的不一样,是因为vue-grid-layout这个组件本身不允许组件之间重叠(组件是有碰撞体积的)。所以即使它进入到布局组件内,布局组件内不接管,也会被插件阻拦。
同时触发dragover事件,为了定位拖拽的组件在布局组件内的位置
** @name 移动-有效目标 **/
dragover(e) {
if (this.limit) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
this._dragover(e);
}
@throttle(100, { trailing: false })
_dragover(e) {
if (
this.dragContext.clientX === e.clientX &&
this.dragContext.clientY === e.clientY
)
return;
// 时刻记录鼠标的位置
this.dragContext.clientX = e.clientX;
this.dragContext.clientY = e.clientY;
this.updateInside(e);
this.updateDrag(e);
}
/** @name 拖拽上下文,用于记录鼠标位置 */
dragContext = {
clientX: 0,
clientY: 0,
};
updateInside是为了在拖动的时候更新布局组件内的布局,让拖动元素在布局组件内部形成占位符。这一点在之前几章我都没讲过,是因为vue-grid-layout这个组件对拖拽效果已经做了很好的处理了,此时加上拖拽时占位,只不过是锦上添花的效果罢了。
/** @name 判断拖动元素是否在拖动区域内,是则添加一项(占位符),否则删除一项 **/
updateInside(ev) {
// 获取布局组件内部区域位置大小
const rect = this.$el.getBoundingClientRect();
// 容错率
const errorRate = 10;
// 判断拖动元素是否在拖动区域内
const inside =
ev.clientX > rect.left + errorRate &&
ev.clientX < rect.right - errorRate &&
ev.clientY > rect.top + errorRate &&
ev.clientY < rect.bottom - errorRate;
if (this.dragLayout) {
if (inside) {
this.$emit('add', deepClone(this.dragLayout));
} else {
this.$emit('delete', deepClone(this.dragLayout));
}
}
}
add和delete最终指向是操作drag-container-layout.vue里的this.layout这个属性,也就是布局容器内的布局(add操作会查找this.layout是否重复存在这个拖拽元素)。可以理解为dragover操控更新了布局容器内的布局,而一旦dragleave,则会:
①取消接管仪表盘layout层的拖拽事件。恢复到仪表盘layout层进行接管
②更新布局组件内部
/** @name 离开-有效目标 **/
dragleave(e) {
if (this.limit) return;
this.$emit('inChildComponent', false);
this.updateInside(e);
}
那么最最最关键的一环,无非是drop事件了。它的核心思路是把布局容器当前的layout里的draglayout拿出来,将它的位置属性记录在生成的拖拽组件属性中。并抛出到vuex仓库里进行存储。如果失败,也只需要删除视图层layout里的dragLayout组件罢了。
/** @name 放置-有效目标 **/
async drop() {
if (this.limit) return;
const dragLayout = deepClone(this.dragLayout);
try {
let field = createDashboardField(this.dragType);
// 标记组件为子组件
field.parentId = this.field.pkId;
// 布局
field.widget.layout = pick(dragLayout, 'x', 'y', 'w', 'h');
// 添加到layout
this.$emit(
'add',
{
...field.widget.layout,
i: field.pkId,
},
dragLayout.i,
);
this.$emit('drop', field);
} catch (e) {
this.$emit('delete', dragLayout);
throw e;
}
}
<drag-container
...
@drop="syncDataToStore('add', $event)"
>
</drag-container>
这个syncDataToStore方法会吧数据同步到vuex仓库,包括了新增/删除/变化。我们最后再讲。到这一步,我们已经把视图层关于新增的步骤完成了。
删除组件
// drag-container.vue
/** @name 删除 **/
handleDelete(layout) {
this.$emit('delete', layout);
}
// container.vue
<drag-container-layout
...
@delete="syncDataToStore('delete', $event)"
/>
放大缩小组件/ 改变位置
vue-grid-layout负责抛出
<template v-for="layoutItem in layout">
<grid-item
...
@moved="$emit('moved', layoutItem)"
@resized="$emit('sized', layoutItem)"
>
...
</grid-item>
</template >
这里很巧妙的运用了this.layout属性,vue-grid-layout的官方示例用法是这样的:
可以理解为这两个响应事件是返回了新的位置信息。而项目里的写法是利用了vue-grid-layout在moved或resized之后自身的this.layout也会随着改变,里面的layout-item也会跟随动态变化,所以直接把layout-item当做参数传出
// container.vue
<drag-container-layout
v-bind="fieldProps"
:layout.sync="layout"
:fields="fields"
@resized="syncDataToStore('size', $event)"
@moved="syncDataToStore('location', $event)"
@delete="syncDataToStore('delete', $event)"
/>
和添加组件一样,视图层逻辑到此结束,等待数据层处理
数据层处理
每个项目都有自己的处理方式,到这里视图层已经完成了自己的使命,把数据教辅给数据层进行存储变更。所以参考一下就行啦
/**
* @name 同步到store
* @param { String } type: 添加-add、删除-delete、大小变化-size、位置变化-moved
* @param { Object } value: field、layout
**/
async syncDataToStore(type, value) {
this.updateFields(fields => {
const currentField = fields.find(field => field.pkId === this.field.pkId);
const currentWidget = currentField.widget;
if (type === 'add') {
// 布局组件里面存储普通组件的字段
currentWidget.fields.push(value);
} else if (type === 'moved' || type === 'size') {
// 移动会改变其他元素的位置, 所以整体要重复赋值x,y
const layoutMap = generateMap(this.layout, 'i', layout => layout);
currentWidget.fields.forEach(field => {
field.widget.layout = pick(layoutMap[field.pkId], 'x', 'y', 'w', 'h');
});
} else if (type === 'delete') {
const index = currentWidget.fields.findIndex(
item => item.pkId === value.i,
);
currentWidget.fields.splice(index, 1);
}
return fields;
});
if (type === 'delete') {
await this.$nextTick();
// 记得更新视图,add就不用了,因为在dragover的时候已经更新了this.layout了
this.syncLayout();
}
}
特别注意的是,移动位置或 更改大小需要更新容器内所有组件的位置,因为可能会发生挤压或换行。
区分父容器和布局容器里的点击事件
<grid-item
@mousedown.native.stop="handlePointerDown"></grid-item>
handlePointerDown(ev) {
// 防止和父级选中冲突
setTimeout(() => {
this._pointerContext = {
x: ev.clientX,
y: ev.clientY,
};
});
}
settimeout(fn,0)会让方法在在下一轮“事件循环”开始时执行。从而避免与父容器冲突。
② 分页卡
跟布局容器一样,只是数据存储多了一层嵌套