您现在的位置是:首页 > 技术教程 正文

Vue Grid Layout - 适用Vue.js的栅格布局系统(项目实例)

admin 阅读: 2024-03-31
后台-插件-广告管理-内容页头部广告(手机)

gitee:grid-project-gitee

B站视频:Vue-Grid-Layout

一、搭建项目框架

  1. 创建vue项目

vue create vite-layout // 使用 vue/cli 创建 vue 项目
  1. 下载依赖

npm i vue-grid-layout less less-loader@4 --s
// 下载 vue-grid-layout依赖、less预处理器(两者保持版本相兼容即可)
  1. 项目样式重置

  1. style.css:
  2. * {
  3. margin: 0;
  4. padding: 0;
  5. box-sizing: border-box;
  6. }
  7. body {
  8. display: flex;
  9. justify-content: center;
  10. align-items: center;
  11. width: 100%;
  12. height: 100%;
  13. }
  1. 搭建 vue-grid-layout的环境

文件结构(暂定)
- src
|- components
|- Top
|- Card
|- Layout
Top 文件夹做项目的头部
Card 文件夹做项目的拖拽单元
Layout 文件夹做拖拽的画布
  1. App.vue
  2. <script>
  3. import topVue from "@com/Top/index.vue";
  4. import layout from "@com/Layout/index.vue";
  5. export default {
  6. components: { topVue, layout },
  7. };
  8. script>
  9. <style lang="less" scoped>
  10. .app_main {
  11. // 固定app的宽高是为了后期计算方便,在实际项目中,也会有一个 baseWidth baseHeight (UI设计的原始宽高)
  12. border: solid red 1px;
  13. width: 1500px;
  14. height: 700px;
  15. }
  16. style>

如下:

【注意:我们设计为整数,只是为了等会在计算item的宽高时方便!】

二、 设计头部

  1. 根据自己的项目设计即可,我做演示就不要求了,我直接引用 Data V 的大屏头部、背景图片;

  1. 调整一下样式(目前先这样,后期需要加东西我们再优化):

  1. Top/index.vue:
  2. <template>
  3. <div class="top-header">
  4. <div class="dv-decoration-8 header-left-decoration">
  5. <svg width="384" height="60">
  6. <polyline
  7. stroke="#3f96a5"
  8. stroke-width="2"
  9. fill="transparent"
  10. points="0, 0 30, 30"
  11. >polyline>
  12. <polyline
  13. stroke="#3f96a5"
  14. stroke-width="2"
  15. fill="transparent"
  16. points="20, 0 50, 30 384, 30"
  17. >polyline>
  18. <polyline
  19. stroke="#3f96a5"
  20. fill="transparent"
  21. stroke-width="3"
  22. points="0, 57, 200, 57"
  23. >polyline>
  24. svg>
  25. div>
  26. <div class="dv-decoration-5 header-center-decoration">
  27. <svg width="614" height="60">
  28. <polyline
  29. fill="transparent"
  30. stroke="#3f96a5"
  31. stroke-width="3"
  32. points="0,12 110.52,12 122.80000000000001,24 153.5,24 165.78,36 442.08,36 460.5,24 491.20000000000005,24 503.47999999999996,12 614,12"
  33. >
  34. <animate
  35. attributeName="stroke-dasharray"
  36. attributeType="XML"
  37. from="0, 316.1165429846644, 0, 316.1165429846644"
  38. to="0, 0, 632.2330859693288, 0"
  39. dur="1.2s"
  40. begin="0s"
  41. calcMode="spline"
  42. keyTimes="0;1"
  43. keySplines="0.4,1,0.49,0.98"
  44. repeatCount="indefinite"
  45. >animate>
  46. polyline>
  47. <polyline
  48. fill="transparent"
  49. stroke="#3f96a5"
  50. stroke-width="2"
  51. points="184.2,48 429.79999999999995,48"
  52. >
  53. <animate
  54. attributeName="stroke-dasharray"
  55. attributeType="XML"
  56. from="0, 122.79999999999998, 0, 122.79999999999998"
  57. to="0, 0, 245.59999999999997, 0"
  58. dur="1.2s"
  59. begin="0s"
  60. calcMode="spline"
  61. keyTimes="0;1"
  62. keySplines=".4,1,.49,.98"
  63. repeatCount="indefinite"
  64. >animate>
  65. polyline>
  66. svg>
  67. div>
  68. <div class="dv-decoration-8 header-right-decoration">
  69. <svg width="384" height="60">
  70. <polyline
  71. stroke="#3f96a5"
  72. stroke-width="2"
  73. fill="transparent"
  74. points="384, 0 354, 30"
  75. >polyline>
  76. <polyline
  77. stroke="#3f96a5"
  78. stroke-width="2"
  79. fill="transparent"
  80. points="364, 0 334, 30 0, 30"
  81. >polyline>
  82. <polyline
  83. stroke="#3f96a5"
  84. fill="transparent"
  85. stroke-width="3"
  86. points="384, 57, 184, 57"
  87. >polyline>
  88. svg>
  89. div>
  90. <div class="top-header-title">Vue-Grid-Layoutdiv>
  91. div>
  92. template>
  93. <script>
  94. export default {};
  95. script>
  96. <style lang="less" scoped>
  97. .top-header {
  98. height: 50px;
  99. width: 100%;
  100. display: flex;
  101. align-items: center;
  102. justify-content: space-between;
  103. position: relative;
  104. svg {
  105. height: 50px;
  106. }
  107. &-title {
  108. position: absolute;
  109. top: 0;
  110. left: 50%;
  111. color: #fff;
  112. transform: translateX(-50%);
  113. font-size: 24px;
  114. font-weight: 700;
  115. }
  116. }
  117. style>

三、 拖拽区设计

  1. 设计理念:

分grid-layout、grid-item 文件的目的是,使得 拖拽数据项和 layout 父级分开,通过数据传递实现渲染,因为 item 的事件是很多的,分开写逻辑更清晰。
  1. layout.vue:

// 引入组件
import { GridLayout } from "vue-grid-layout";

// 引入自己的GridItem
import myGridItem from "./item.vue";

  1. <grid-layout
  2. :layout="layout"
  3. :col-num="12"
  4. :row-height="30"
  5. :is-draggable="true"
  6. :is-resizable="true"
  7. :is-mirrored="false"
  8. :vertical-compact="true"
  9. :margin="[10, 10]"
  10. :use-css-transforms="true"
  11. >
  12.      // 通过自定义组件的方式引用 item 数据项
  13. <myGridItem :itemData="layout" />
  14. grid-layout>
  1. item.vue:

import { GridItem } from "vue-grid-layout"; // 在我们定义的 item 组件中,引用grid-item 组件,使用 props 传入的数据进行渲染
  1. v-for="item in itemData"
  2. :x="item.x"
  3. :y="item.y"
  4. :w="item.w"
  5. :h="item.h"
  6. :i="item.i"
  7. :key="item.i"
  8.       class="grid-item"
  9. >
  10. {{ item.i }}
  11. // css:
  12. <style lang="less" scoped>
  13. .grid-item {
  14.   // 我们迎合 DATa v 的样式,模仿实现item
  15. border: solid black 1px;
  16. background-color: #cccccc;
  17. }
  18. style>
  1. 实现的初步效果如下:

注意:实现的效果我是直接使用了 Data V 的样式哈,大家可以自己设计更好看的】

四、拖拽数据项设计

  1. item 数据项的宽度高度计算方法

这个知识点我已经在上一篇文章中详细讲述过了,这里再强调一下!

1. 我们目前的拖拽区 :width: 1500px; height:650px;

2. 我们将宽度分为 10列,每一列是多少px?

不知道这个图会不会更加清晰的看出item间的关系,始终别忘了【 :margin="[10, 10]"】的存在,因此,将一行分为10份,必定有11个间隔(margin)因此,11个间隔占用:11*10px=110px;因此,剩余1390px来平分给 10 个item。故而,每一个item的宽度就是 139px。

我们验证一下: { x: 0, y: 0, w: 1, h: 1, i: "标准1*1" }:

:row-height="50" 这个属性表示每一行的高度是 50px,那么,650px的高度,可以分成多少行?

当我们放下11个的时候,已经显示不全了。

  1. 规避计算误差

我们简单回顾了一下item的计算方式,那我们该如何处理,来规避这种计算带来的误差呢?

高度的处理:经过我们计算,当取10行的时候,(10+1)*10+50*10=610px;因此,我们需要将多余的40px,分给头部【具体的分配方式应该视项目,有底部的,分给底部,我们的目的就是确保拖拽区只有10行!】

底部放置版权等信息还是可行的;

宽度的处理:宽度我们保留10个,item的宽度为139px,那么,整个拖拽区的规格为 10*10 个格子。

  1. layout.vue:
  2. <grid-layout
  3. :layout="layout"
  4. :col-num="10"
  5. :row-height="50"
  6. :is-draggable="true"
  7. :is-resizable="true"
  8. :is-mirrored="false"
  9. :vertical-compact="true"
  10. :margin="[10, 10]"
  11. :use-css-transforms="true"
  12. >
  13. <myGridItem :itemData="layout" />
  14. grid-layout>
  15. item.vue:
  16. <grid-item
  17. v-for="item in itemData"
  18. :x="item.x"
  19. :y="item.y"
  20. :w="item.w"
  21. :h="item.h"
  22. :i="item.i"
  23. :key="item.i"
  24. class="grid-item"
  25. >
  26. {{ item.i }}
  27. grid-item>

  1. 计算方案优化

【考虑:】这种方案是否合适?我们现在的实际宽高和item的宽高比为 1/1=139/50,在实际的项目中是不会考虑这种大格子方案的。原因如下:

  1. 我们需要设计一个 600px * 300px的卡片,那么,它的配置项该怎么写?w:4.3;h:6,我们发现,宽度就不能是整数。这在实际项目中是需要规避的。

因此,我们还需要优化宽度的计算结果:我们假设需要每一列宽度100px,margin 10px,1500px可以取10*100+11*10=1110px;剩余390px我们不用。【现在的格子数还是10*10;当然你也可以多加两个。】我们再取三个格子,13*100+140=1440px,剩60px不用【不用的含义是左右留白,这也是设计中常用的方案】

我们通过反推实现最佳的尺寸:格子尺寸:100*50px,因此 一个单元格与实际尺寸的比值就是 1/1= 100/50.这样,我们设计 600*300 的卡片,w:6,h:6.验证一下:

这样还是会受到实际 margin 值的影响,我们还需要控制margin的影响,具体的方案我在后面再给大家详细说一下这个拖拽数据项的计算及margin的消除,现在我们只需要考虑占几个格子就行了。

总结:我们目前设计的格子数:13*10,格子尺寸为 100*50px,margin:10*10px,加了背景框,效果如上。

五、卡片设计

  1. 基本概念:我们将拖拽的最小单元称为卡片,就是 item数据项

  1. Card 文件夹设计:

  1. - Card
  2.     |- card1
  3.         |- card.vue
  4.         |- config.js
  5.     |- index.js
  6. 1. Card/index.js 是做所有组件导出,注册为全局组件,配合 componentIs 实现动态加载。
  7. 2. 每一个文件夹表示一个卡片,有card.vue、config.js 两个文件,一个是卡片内容,一个是卡片配置项。
  1. 举例说明:

  1. // 默认导出卡片配置项
  2. export const option = {
  3. // 我们目前的单位是格子数哈!后期教大家如何用 px 做单位
  4. kpzsmc: "Card1", // 卡片展示名称
  5. kpid: "card1", // 卡片id【请保持唯一】
  6. kd: 2, // 宽度
  7. gd: 2, // 高度
  8. x: 0, // 初始 x 位置
  9. y: 0, // 初始 y 位置
  10. zxkd: 2, // 最小宽度
  11. zxgd: 2, // 最小高度
  12. zdkd: 4, // 最大宽度
  13. zdgd: 4, // 最大高度
  14. };
  15. <template>
  16. <div>Card1div>
  17. template>
  1. item.vue

  1. v-for="item in itemData"
  2. :x="item.x"
  3. :y="item.y"
  4. :w="item.w"
  5. :h="item.h"
  6. :i="item.i"
  7. :key="item.i"
  8. class="grid-item"
  9. >
  10.     // 使用 component IS 实现动态加载组件【i就是卡片id,就是注册组件的名称】
  11. <component :is="item.i">component>

那么,我们传入 item 的数据就必须是 config.js 配置的真实数据,新建 getCard.js:

  1. export const getCardConfigList = () => {
  2. // 获取 Card 文件夹下的所有 config.js 配置项,并配置成数据,同时满足 item i、w、h、x、y 的数据格式
  3. const config = require.context("../Card/", true, /config.js$/);
  4. // 将得到的上下文作用域转为数组方便遍历
  5. const requireAll = (context) => context.keys().map(context);
  6. const list = [];
  7. requireAll(config).forEach((conf) => {
  8. const item = conf.option;
  9. list.push({
  10. kpzsmc: item.kpzsmc,
  11. kpid: item.kpid,
  12. x: item.x,
  13. y: item.y,
  14. w: item.kd,
  15. h: item.gd,
  16. minW: item.zxkd,
  17. minH: item.zxgd,
  18. maxW: item.zdkd,
  19. maxH: item.zdgd,
  20. i: item.kpid, // 防止 key 重复,【同时,也是组件的name】
  21. });
  22. });
  23. return list;
  24. };
  25. layout.vue 中引用:
  26. // 引入 自定义配置项
  27. import { getCardConfigList } from "./getCard";
  28. this.layout = getCardConfigList();

这样就实现数据联动了:

六、预设拖拽布局

  1. 预设布局指的是我们给一个默认的布局,我们先设计系统的功能,添加菜单栏:

左侧预设、右侧自定义拖拽,预设就是给一套默认的位置关系。我们简单实现一下:(我们知道,拖拽、缩放后,页面会实时保存 layout 数据,是双向绑定的关系,我们直接拖拽好,复制配置项作为默认预设即可,如下图)

如上,简单的预设获取方法,效果如下:

  1. defaultPreview.js:
  2. export const defaultPreviewData = [
  3. [
  4. { x: 10, y: 0, w: 3, h: 3, i: "标准1*7", moved: false },
  5. { x: 3, y: 0, w: 7, h: 3, i: "标准1*8", moved: false },
  6. { x: 0, y: 0, w: 3, h: 3, i: "标准1*1", moved: false },
  7. { x: 0, y: 3, w: 3, h: 3, i: "标准1*12", moved: false },
  8. { x: 3, y: 3, w: 7, h: 7, i: "标准1*13", moved: false },
  9. { x: 0, y: 6, w: 3, h: 4, i: "标准1*14", moved: false },
  10. { x: 10, y: 3, w: 3, h: 3, i: "标准1*15", moved: false },
  11. { x: 10, y: 6, w: 3, h: 4, i: "标准1*16", moved: false },
  12. ],
  13. [
  14. { x: 10, y: 0, w: 3, h: 3, i: "标准1*7", moved: false },
  15. { x: 0, y: 0, w: 3, h: 3, i: "标准1*1", moved: false },
  16. { x: 0, y: 3, w: 3, h: 3, i: "标准1*12", moved: false },
  17. { x: 3, y: 0, w: 7, h: 10, i: "标准1*13", moved: false },
  18. { x: 0, y: 6, w: 3, h: 4, i: "标准1*14", moved: false },
  19. { x: 10, y: 3, w: 3, h: 3, i: "标准1*15", moved: false },
  20. { x: 10, y: 6, w: 3, h: 4, i: "标准1*16", moved: false },
  21. ],
  22. [
  23. { x: 6, y: 0, w: 4, h: 6, i: "标准1*7", moved: false },
  24. { x: 0, y: 0, w: 3, h: 3, i: "标准1*1", moved: false },
  25. { x: 0, y: 3, w: 3, h: 3, i: "标准1*12", moved: false },
  26. { x: 3, y: 0, w: 3, h: 6, i: "标准1*13", moved: false },
  27. { x: 0, y: 6, w: 3, h: 4, i: "标准1*14", moved: false },
  28. { x: 10, y: 0, w: 3, h: 10, i: "标准1*15", moved: false },
  29. { x: 3, y: 6, w: 7, h: 4, i: "标准1*16", moved: false },
  30. ],
  31. [
  32. { x: 6, y: 0, w: 7, h: 3, i: "标准1*7", moved: false },
  33. { x: 0, y: 0, w: 3, h: 3, i: "标准1*1", moved: false },
  34. { x: 0, y: 3, w: 6, h: 3, i: "标准1*12", moved: false },
  35. { x: 3, y: 0, w: 3, h: 3, i: "标准1*13", moved: false },
  36. { x: 0, y: 6, w: 3, h: 4, i: "标准1*14", moved: false },
  37. { x: 6, y: 3, w: 7, h: 3, i: "标准1*15", moved: false },
  38. { x: 3, y: 6, w: 10, h: 4, i: "标准1*16", moved: false },
  39. ],
  40. ];

使用 element-ui 抽屉组件实现预设选择、卡片拖拽

现在涉及到组件间传递数据了,我们采用 vuex 传输,

  1. vuex:

  1. //数据,相当于data
  2. state: {
  3. defaultPreviewIndex: 0,
  4. },
  5. getters: {},
  6. //里面定义方法,操作state方发
  7. mutations: {
  8. setDefaultPreviewIndex(state, data) {
  9. state.defaultPreviewIndex = data;
  10. },
  11. },
  12. // 设置选中的值
  13. Top/index.vue
  14. methods:{
  15.     choosePreview(index) {
  16. // 赋值给vuex
  17. this.$store.commit("setDefaultPreviewIndex", index);
  18. this.$message.success(`已选择默认布局${index + 1}`);
  19. console.log(index);
  20. },
  21. }
  22. // 取值
  23. Layout/layout.vue
  24. computed: {
  25. defaultPreviewIndex() {
  26. return this.$store.state.defaultPreviewIndex;
  27. },
  28. },

效果如下:

如何对应上实际的卡片呢?只需要将 默认预设的i设置为对应卡片的kpid即可。

  1. 将默认预设应用到卡片上:

上一章已经说过,通过 componentIS 实现映射自定义卡片,因此,预设中的数据 i,将其设置为卡片id,即可实现应用到卡片上,剩下的工作就是制作卡片了。如下图:

  1. 默认预设实现效果及难点分析:

在有Echarts的情景下,拖拽大小会导致监听不到 resize 事件,因此,需要借助第三方库实现:

npm i element-resize-detector --s
  1. // 引用
  2. const elementResizeDetectorMaker = require("element-resize-detector");
  3. let erd = elementResizeDetectorMaker();
  4. // 使用
  5. mouted(){
  6. this.$nextTick(() => {
  7.     erd.listenTo(this.$refs.echarts, () => {
  8. this.$nextTick(function () {
  9. //使echarts尺寸重置
  10. this.myChart.resize();
  11. });
  12. });
  13.     })
  14. }
  15. // 这样就能实现 元素 resize 重新渲染Echarts。
  16. // 在后期的卡片拖拽 缩放中,该方法也是同样重要,只是演示效果,卡片的样式就没有那么充实了。

七、自定义拖拽布局

  1. 卡片超市

我们将获取所有配置项的方法封装到工具函数(util)中,根据预设样式,打开卡片超市,表示我们需要拖动卡片,需要同步打开背景预览框(位置提示),关闭、保存的时候,取消提示背景,并关闭抽屉。

【截图提示我违规,后面再看效果吧】

  1. 拖拽实现原理

我们现在该将卡片拖出来了,使用 以下两个事件,即可获取当前拖拽在页面中的位置,然后,通过传参给layout,实时生成预览,dragend 结束时,将拖拽过程中生成的所有数据正式生成最终数据。

@drag="($event) => dragHandle($event, item)"
@dragend="($event) => dragendpHandle($event, item)"

我们的主拖拽区是有间距的,在获取到坐标后,需要减去相应的值,才能确定拖拽到那个区域,同时,这个事件一直在触发,考虑节流,同时,在相同位置上也触发,考虑条件【我们以被拖动元素的左上顶点作为位置判断依据】!

  1. 计算偏差

1536px 是可视区的宽度(document.documentElement.clientWidth),其他的都是居中,左右除以2就行了,高度就是 top 的高度 50,加上 margin 10px,因此,左上顶点,各距离60px。通过 vuex 传递数据:

  1. // 拖动开始
  2. dragHandle(e, item) {
  3. // 如果初始位置为0 不执行
  4. if (!e.x && !e.y) return;
  5. this.drawerCard = false;
  6. const Tx = e.clientX - 58;
  7. const Ty = e.clientY - 60;
  8. // 如果保持不动*(位置不变)
  9. if (Tx == atPiont[0] && Ty == atPiont[1]) return;
  10. this.$store.commit("setAtPiont", [Tx, Ty]);
  11. // drag 事件一直在执行,只有初始化的时候,不同的 kpid 才执行该语句[初次加载,null]
  12. if (!this.$store.state.dragItem ||
  13. this.$store.state.dragItem.kpid != item.kpid)
  14. this.$store.commit("setDragItem", item);
  15. },
  16. // 拖动结束
  17. dragendpHandle(e, item) {
  18. const Tx = e.clientX - 58;
  19. const Ty = e.clientY - 60;
  20. // 清空实时位置
  21. this.$store.commit("setAtPiont", [null, null]);
  22. this.$store.commit("setEndPiont", [Tx, Ty]);

【注意:drag 方法会重复调用很多次,我们应该用条件控制其频繁修改我们的变量,或使用节流实现页面控制】

  1. layout 通过实时数据渲染:

  1. layout.vue watch:
  2. "$store.state.atPiont": {
  3. handler(val) {
  4. // 监听实时位置
  5. console.log("实时位置", val);
  6. },
  7. deep: true,
  8. },
  9. "$store.state.endPiont": {
  10. handler(val) {
  11. // 监听最后位置
  12. console.log("最后位置", val);
  13. },
  14. deep: true,
  15. },

生成预览数据:

  1. <myGridItem :itemData="previewData" />
  2. "$store.state.atPiont": {
  3. handler(val) {
  4. if (!val[0] && !val[1]) return;
  5. // 监听实时位置,并生成预览对象
  6. const item = this.$store.state.dragItem;
  7. // console.log("实时位置", val, item);
  8.        this.previewData = cloneDeep(this.layout);
  9. this.previewData.push({
  10. // x y 要根据实时位置生成
  11. x: Math.ceil(val[0] / 100) - 1,
  12. y: Math.ceil(val[1] / 50) - 1,
  13. w: item.w,
  14. h: item.h,
  15. i: item.i,
  16. });
  17. },
  18. deep: true,
  19. },
  20. "$store.state.endPiont": {
  21. handler(val) {
  22. // 监听最后位置【转换为正式数据】
  23. this.layout = cloneDeep(this.previewData);
  24. // console.log("最后位置", val);
  25. },
  26. deep: true,
  27. },

上面的代码其实隐藏了一个问题,如下图,previewData 和 layout 各有一份一样的数据,导致不同的拖拽层出现两个一模一样的卡片。

  1. // 我们转换为正式数据后,预览数据不久不需要了嘛?
  2. // 监听最后位置【转换为正式数据】
  3. this.layout = cloneDeep(this.previewData);
  4. this.previewData = []; // 清空预览数据

  1. 删除元素

  1. closeItemHandle(item) {
  2. // 预览与正式数据公用一个 item 因此,需两者都清除数据
  3. this.layout.splice(
  4. this.layout.findIndex((i) => i.i == item.i),
  5. 1
  6. );
  7. this.previewData.splice(
  8. this.previewData.findIndex((i) => i.i == item.i),
  9. 1
  10. );
  11. },

八、总结

常见的事件以及交互我在这里就不细说了,大家可以看我上一篇文章,实现的总体效果欢迎看我B站视频:Vue-Grid-Layout

有问题欢迎留言谈论~

标签:
声明

1.本站遵循行业规范,任何转载的稿件都会明确标注作者和来源;2.本站的原创文章,请转载时务必注明文章作者和来源,不尊重原创的行为我们将追究责任;3.作者投稿可能会经我们编辑修改或补充。

在线投稿:投稿 站长QQ:1888636

后台-插件-广告管理-内容页尾部广告(手机)
关注我们

扫一扫关注我们,了解最新精彩内容

搜索