如何在 Vue 中实现一个可拖拽、可缩放的自由布局组件,并处理元素之间的碰撞检测和吸附对齐?

各位观众老爷,晚上好!今天给大家带来一场精彩的 Vue.js 自由布局组件实战秀,主题是:如何在 Vue 中实现一个可拖拽、可缩放的自由布局组件,并处理元素之间的碰撞检测和吸附对齐。准备好瓜子板凳,咱们开讲!

一、搭台唱戏:组件的基本结构

首先,咱得有个舞台,也就是 Vue 组件的基本结构。创建一个名为 FreeLayout.vue 的组件:

<template>
  <div class="free-layout" ref="layoutContainer">
    <div
      v-for="item in items"
      :key="item.id"
      class="layout-item"
      :style="{
        width: item.width + 'px',
        height: item.height + 'px',
        left: item.x + 'px',
        top: item.y + 'px',
        zIndex: item.zIndex
      }"
      @mousedown="startDrag(item, $event)"
      @touchstart="startDrag(item, $event)"
    >
      {{ item.content }}
      <div class="resize-handle resize-handle-se" @mousedown.stop="startResize(item, 'se', $event)" @touchstart.stop="startResize(item, 'se', $event)"></div>
    </div>
  </div>
</template>

<script>
export default {
  name: 'FreeLayout',
  props: {
    items: {
      type: Array,
      default: () => []
    },
    gridSize: {
      type: Number,
      default: 10 // 吸附网格大小
    }
  },
  data() {
    return {
      draggingItem: null,
      resizingItem: null,
      resizeDirection: null,
      startX: 0,
      startY: 0,
      startWidth: 0,
      startHeight: 0,
      offsetX: 0,
      offsetY: 0,
    };
  },
  watch: {
    items: {
      handler(newVal) {
        // 监听 items 变化,自动更新 zIndex,确保最后一个被点击的元素在最上层
        newVal.forEach((item, index) => {
          item.zIndex = index + 1;
        });
      },
      deep: true, //深度监听
      immediate: true
    }
  },
  methods: {
    startDrag(item, event) {
      this.draggingItem = item;
      this.offsetX = event.clientX - item.x;
      this.offsetY = event.clientY - item.y;
      this.startX = event.clientX;
      this.startY = event.clientY;
      item.zIndex = this.items.length + 1; // 将当前拖拽元素置顶
      this.items.forEach(i => {
        if (i !== item) {
          i.zIndex = 1; // 其他元素置底
        }
      });

      document.addEventListener('mousemove', this.doDrag);
      document.addEventListener('mouseup', this.endDrag);
      document.addEventListener('touchmove', this.doDrag);
      document.addEventListener('touchend', this.endDrag);
    },
    doDrag(event) {
      if (!this.draggingItem) return;

      let x = event.clientX - this.offsetX;
      let y = event.clientY - this.offsetY;

      // 吸附网格
      if (this.gridSize) {
        x = Math.round(x / this.gridSize) * this.gridSize;
        y = Math.round(y / this.gridSize) * this.gridSize;
      }

      // 碰撞检测和吸附
      const collisionInfo = this.checkCollisionAndSnap(this.draggingItem, x, y);
      x = collisionInfo.x;
      y = collisionInfo.y;

      this.draggingItem.x = x;
      this.draggingItem.y = y;
    },
    endDrag() {
      this.draggingItem = null;
      document.removeEventListener('mousemove', this.doDrag);
      document.removeEventListener('mouseup', this.endDrag);
      document.removeEventListener('touchmove', this.doDrag);
      document.removeEventListener('touchend', this.endDrag);
    },
    startResize(item, direction, event) {
      event.stopPropagation(); // 阻止冒泡,防止触发拖拽
      this.resizingItem = item;
      this.resizeDirection = direction;
      this.startX = event.clientX;
      this.startY = event.clientY;
      this.startWidth = item.width;
      this.startHeight = item.height;

      document.addEventListener('mousemove', this.doResize);
      document.addEventListener('mouseup', this.endResize);
      document.addEventListener('touchmove', this.doResize);
      document.addEventListener('touchend', this.endResize);
    },
    doResize(event) {
      if (!this.resizingItem) return;

      let width = this.startWidth;
      let height = this.startHeight;

      switch (this.resizeDirection) {
        case 'se':
          width = this.startWidth + (event.clientX - this.startX);
          height = this.startHeight + (event.clientY - this.startY);
          break;
        // 其他方向的缩放逻辑...
      }

      // 限制最小尺寸
      width = Math.max(50, width);
      height = Math.max(50, height);

      // 吸附网格
      if (this.gridSize) {
        width = Math.round(width / this.gridSize) * this.gridSize;
        height = Math.round(height / this.gridSize) * this.gridSize;
      }

      this.resizingItem.width = width;
      this.resizingItem.height = height;
    },
    endResize() {
      this.resizingItem = null;
      this.resizeDirection = null;
      document.removeEventListener('mousemove', this.doResize);
      document.removeEventListener('mouseup', this.endResize);
      document.removeEventListener('touchmove', this.doResize);
      document.removeEventListener('touchend', this.endResize);
    },
    checkCollisionAndSnap(item, x, y) {
      let newX = x;
      let newY = y;
      const containerRect = this.$refs.layoutContainer.getBoundingClientRect();
      const itemWidth = item.width;
      const itemHeight = item.height;

      // 边界检测
      newX = Math.max(0, Math.min(newX, containerRect.width - itemWidth));
      newY = Math.max(0, Math.min(newY, containerRect.height - itemHeight));

      // 碰撞检测和吸附
      for (const otherItem of this.items) {
        if (otherItem === item) continue;

        const dx = newX - otherItem.x;
        const dy = newY - otherItem.y;

        const combinedWidth = itemWidth + otherItem.width;
        const combinedHeight = itemHeight + otherItem.height;

        // 水平方向的碰撞检测
        if (Math.abs(dx) < combinedWidth / 2) {
          // 左侧吸附
          if (dx > 0 && dx < this.gridSize) {
            newX = otherItem.x + otherItem.width;
          }
          // 右侧吸附
          else if (dx < 0 && Math.abs(dx) < this.gridSize) {
            newX = otherItem.x - itemWidth;
          }
        }

        // 垂直方向的碰撞检测
        if (Math.abs(dy) < combinedHeight / 2) {
          // 顶部吸附
          if (dy > 0 && dy < this.gridSize) {
            newY = otherItem.y + otherItem.height;
          }
          // 底部吸附
          else if (dy < 0 && Math.abs(dy) < this.gridSize) {
            newY = otherItem.y - itemHeight;
          }
        }
      }
      return { x: newX, y: newY };
    }
  },
  mounted() {
    // 为了避免页面渲染时出现闪烁,这里初始化 zIndex
    this.items.forEach((item, index) => {
      item.zIndex = index + 1;
    });
  }
};
</script>

<style scoped>
.free-layout {
  position: relative;
  width: 800px;
  height: 600px;
  border: 1px solid #ccc;
  user-select: none;
}

.layout-item {
  position: absolute;
  background-color: #f0f0f0;
  border: 1px solid #999;
  cursor: grab;
  display: flex;
  justify-content: center;
  align-items: center;
}

.layout-item:active {
  cursor: grabbing;
}

.resize-handle {
  position: absolute;
  width: 10px;
  height: 10px;
  background-color: #3498db;
  cursor: se-resize;
  z-index: 10;
}

.resize-handle-se {
  bottom: 0;
  right: 0;
}
</style>

二、主要功能模块详解

  1. 数据结构:items 数组

    items 数组是核心,它存储了每个布局元素的位置、大小和内容等信息。每个元素(对象)至少包含以下属性:

    • id: 唯一标识符。
    • x: x 坐标。
    • y: y 坐标。
    • width: 宽度。
    • height: 高度。
    • content: 内容。
    • zIndex: z-index 值,用于控制元素的层叠顺序。
  2. 拖拽功能

    • startDrag(item, event): 开始拖拽。记录起始位置、鼠标偏移量,并绑定 mousemovemouseup 事件。同时将拖拽元素置顶。
    • doDrag(event): 拖拽过程中。根据鼠标位置计算元素的新位置,并更新 item.xitem.y。在这里,会调用 checkCollisionAndSnap 来进行碰撞检测和吸附。
    • endDrag(): 结束拖拽。移除 mousemovemouseup 事件监听器。
  3. 缩放功能

    • startResize(item, direction, event): 开始缩放。记录起始位置、元素初始尺寸,并绑定 mousemovemouseup 事件。
    • doResize(event): 缩放过程中。根据鼠标位置和缩放方向计算元素的新尺寸,并更新 item.widthitem.height
    • endResize(): 结束缩放。移除 mousemovemouseup 事件监听器。
  4. 碰撞检测和吸附对齐:checkCollisionAndSnap(item, x, y)

    这部分是重头戏,也是实现自由布局的关键。

    • 边界检测: 确保元素不会超出容器的边界。
    • 碰撞检测: 遍历所有其他元素,检查当前拖拽的元素是否与它们发生碰撞。
    • 吸附对齐: 如果发生碰撞,并且距离足够近(小于 gridSize),则将当前元素吸附到其他元素的边缘。

    碰撞检测和吸附的逻辑

    假设有两个元素 A 和 B,A 正在被拖拽:

    • 计算 A 和 B 在 x 和 y 轴上的距离 dxdy
    • 计算 A 和 B 的宽度和高度之和的一半,分别记为 combinedWidth / 2combinedHeight / 2
    • 如果 Math.abs(dx) < combinedWidth / 2,说明 A 和 B 在水平方向上可能发生碰撞。
      • 如果 dx > 0 并且 dx < gridSize,说明 A 在 B 的左侧,并且距离小于 gridSize,则将 A 吸附到 B 的右侧:newX = otherItem.x + otherItem.width;
      • 如果 dx < 0 并且 Math.abs(dx) < gridSize,说明 A 在 B 的右侧,并且距离小于 gridSize,则将 A 吸附到 B 的左侧:newX = otherItem.x - itemWidth;
    • 如果 Math.abs(dy) < combinedHeight / 2,说明 A 和 B 在垂直方向上可能发生碰撞。
      • 如果 dy > 0 并且 dy < gridSize,说明 A 在 B 的上方,并且距离小于 gridSize,则将 A 吸附到 B 的下方:newY = otherItem.y + otherItem.height;
      • 如果 dy < 0 并且 Math.abs(dy) < gridSize,说明 A 在 B 的下方,并且距离小于 gridSize,则将 A 吸附到 B 的上方:newY = otherItem.y - itemHeight;
  5. Z-Index 管理

    为了确保拖拽的元素始终在最上层,需要动态地管理 zIndex。在 startDrag 方法中,将当前拖拽元素的 zIndex 设置为最大值(例如,items.length + 1),并将其他元素的 zIndex 设置为较低的值。

三、使用姿势

<template>
  <div>
    <FreeLayout :items="layoutItems" :grid-size="20" />
  </div>
</template>

<script>
import FreeLayout from './FreeLayout.vue';

export default {
  components: {
    FreeLayout
  },
  data() {
    return {
      layoutItems: [
        { id: 1, x: 100, y: 100, width: 150, height: 100, content: 'Item 1', zIndex: 1 },
        { id: 2, x: 300, y: 200, width: 120, height: 80, content: 'Item 2', zIndex: 2 },
        { id: 3, x: 500, y: 150, width: 100, height: 120, content: 'Item 3', zIndex: 3 }
      ]
    };
  }
};
</script>

四、代码优化和扩展

  1. 性能优化:

    • 节流(Throttling)/防抖(Debouncing):doDragdoResize 方法中使用节流或防抖,可以减少计算的频率,提高性能。
    • 避免不必要的 DOM 操作: 尽量减少直接操作 DOM 的次数,例如,可以使用 Vue 的数据绑定来更新元素的位置和大小。
  2. 功能扩展:

    • 多方向缩放: 可以添加更多的缩放方向(例如,nwnesw),实现更灵活的缩放。
    • 元素锁定: 可以添加一个 locked 属性,用于禁止拖拽或缩放某些元素。
    • 撤销/重做: 可以记录用户的操作,实现撤销和重做功能。
    • 保存/加载布局: 可以将布局数据保存到本地存储或服务器,并加载回来。

五、常见问题及解决方案

问题 解决方案
拖拽时元素抖动 检查 doDrag 方法中的计算逻辑,确保计算的准确性。 使用 transform: translate() 代替直接修改 lefttop 属性,可以提高性能和流畅度。 * 尝试使用节流或防抖来减少计算的频率。
缩放时元素变形 确保在 doResize 方法中正确计算元素的新尺寸。 检查 CSS 样式,确保没有其他样式影响元素的尺寸。 * 如果需要保持元素的宽高比,可以根据宽度或高度的变化,自动调整另一个尺寸。
碰撞检测不准确 仔细检查 checkCollisionAndSnap 方法中的碰撞检测逻辑,确保计算的准确性。 可以尝试使用更精确的碰撞检测算法,例如,基于像素的碰撞检测。 * 调整 gridSize 的值,使其更适合你的应用场景。
Z-Index 管理混乱 确保在 startDrag 方法中正确设置拖拽元素的 zIndex 可以使用 Vue 的 watch 监听 items 数组的变化,自动更新 zIndex,确保最后一个被点击的元素在最上层。 * 避免手动修改 zIndex,尽量使用组件内部的逻辑来管理。
拖拽或缩放时触发了浏览器的默认行为 mousedownmousemove 事件处理函数中调用 event.preventDefault(),阻止浏览器的默认行为。 使用 CSS 样式 user-select: none 禁用元素的文本选择。
在移动端设备上拖拽/缩放体验不佳 使用 touchstarttouchmovetouchend 事件代替 mousedownmousemovemouseup 事件。 使用 event.touches[0].clientXevent.touches[0].clientY 获取触摸点的坐标。 禁用移动端的缩放手势,可以使用 <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> 使用 touch-action CSS 属性来控制元素如何响应触摸事件。例如,设置 touch-action: none 可以禁用元素的所有触摸事件。

六、总结

今天咱们一起动手撸了一个可拖拽、可缩放的自由布局组件,并且深入了解了碰撞检测和吸附对齐的实现原理。虽然代码还有优化的空间,功能也还有扩展的余地,但是核心逻辑已经搭建完成。希望这次实战秀能帮助大家更好地理解 Vue.js 组件开发,并能在实际项目中灵活运用。

记住,编程就像谈恋爱,多尝试、多踩坑,才能找到真爱!

感谢各位的观看,下期再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注