各位观众老爷,晚上好!今天给大家带来一场精彩的 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>
二、主要功能模块详解
-
数据结构:
items
数组items
数组是核心,它存储了每个布局元素的位置、大小和内容等信息。每个元素(对象)至少包含以下属性:id
: 唯一标识符。x
: x 坐标。y
: y 坐标。width
: 宽度。height
: 高度。content
: 内容。zIndex
: z-index 值,用于控制元素的层叠顺序。
-
拖拽功能
startDrag(item, event)
: 开始拖拽。记录起始位置、鼠标偏移量,并绑定mousemove
和mouseup
事件。同时将拖拽元素置顶。doDrag(event)
: 拖拽过程中。根据鼠标位置计算元素的新位置,并更新item.x
和item.y
。在这里,会调用checkCollisionAndSnap
来进行碰撞检测和吸附。endDrag()
: 结束拖拽。移除mousemove
和mouseup
事件监听器。
-
缩放功能
startResize(item, direction, event)
: 开始缩放。记录起始位置、元素初始尺寸,并绑定mousemove
和mouseup
事件。doResize(event)
: 缩放过程中。根据鼠标位置和缩放方向计算元素的新尺寸,并更新item.width
和item.height
。endResize()
: 结束缩放。移除mousemove
和mouseup
事件监听器。
-
碰撞检测和吸附对齐:
checkCollisionAndSnap(item, x, y)
这部分是重头戏,也是实现自由布局的关键。
- 边界检测: 确保元素不会超出容器的边界。
- 碰撞检测: 遍历所有其他元素,检查当前拖拽的元素是否与它们发生碰撞。
- 吸附对齐: 如果发生碰撞,并且距离足够近(小于
gridSize
),则将当前元素吸附到其他元素的边缘。
碰撞检测和吸附的逻辑:
假设有两个元素 A 和 B,A 正在被拖拽:
- 计算 A 和 B 在 x 和 y 轴上的距离
dx
和dy
。 - 计算 A 和 B 的宽度和高度之和的一半,分别记为
combinedWidth / 2
和combinedHeight / 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;
- 如果
-
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>
四、代码优化和扩展
-
性能优化:
- 节流(Throttling)/防抖(Debouncing): 在
doDrag
和doResize
方法中使用节流或防抖,可以减少计算的频率,提高性能。 - 避免不必要的 DOM 操作: 尽量减少直接操作 DOM 的次数,例如,可以使用 Vue 的数据绑定来更新元素的位置和大小。
- 节流(Throttling)/防抖(Debouncing): 在
-
功能扩展:
- 多方向缩放: 可以添加更多的缩放方向(例如,
nw
、ne
、sw
),实现更灵活的缩放。 - 元素锁定: 可以添加一个
locked
属性,用于禁止拖拽或缩放某些元素。 - 撤销/重做: 可以记录用户的操作,实现撤销和重做功能。
- 保存/加载布局: 可以将布局数据保存到本地存储或服务器,并加载回来。
- 多方向缩放: 可以添加更多的缩放方向(例如,
五、常见问题及解决方案
问题 | 解决方案 |
---|---|
拖拽时元素抖动 | 检查 doDrag 方法中的计算逻辑,确保计算的准确性。 使用 transform: translate() 代替直接修改 left 和 top 属性,可以提高性能和流畅度。 * 尝试使用节流或防抖来减少计算的频率。 |
缩放时元素变形 | 确保在 doResize 方法中正确计算元素的新尺寸。 检查 CSS 样式,确保没有其他样式影响元素的尺寸。 * 如果需要保持元素的宽高比,可以根据宽度或高度的变化,自动调整另一个尺寸。 |
碰撞检测不准确 | 仔细检查 checkCollisionAndSnap 方法中的碰撞检测逻辑,确保计算的准确性。 可以尝试使用更精确的碰撞检测算法,例如,基于像素的碰撞检测。 * 调整 gridSize 的值,使其更适合你的应用场景。 |
Z-Index 管理混乱 | 确保在 startDrag 方法中正确设置拖拽元素的 zIndex 。 可以使用 Vue 的 watch 监听 items 数组的变化,自动更新 zIndex ,确保最后一个被点击的元素在最上层。 * 避免手动修改 zIndex ,尽量使用组件内部的逻辑来管理。 |
拖拽或缩放时触发了浏览器的默认行为 | 在 mousedown 和 mousemove 事件处理函数中调用 event.preventDefault() ,阻止浏览器的默认行为。 使用 CSS 样式 user-select: none 禁用元素的文本选择。 |
在移动端设备上拖拽/缩放体验不佳 | 使用 touchstart 、touchmove 和 touchend 事件代替 mousedown 、mousemove 和 mouseup 事件。 使用 event.touches[0].clientX 和 event.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 组件开发,并能在实际项目中灵活运用。
记住,编程就像谈恋爱,多尝试、多踩坑,才能找到真爱!
感谢各位的观看,下期再见!