首先演示一下最终效果:
流畅的拖动和交换位置效果,并实时更新数据
支持组件的样式和内容自定义
这是这次系列文章的第二篇,我自己封装了一个用vue实现的拖拽排列卡片组件,并且发布到npm,详细地记录下来了整体制作过程。总共有三篇文章,介绍组件的制作思路和遇到的问题,以及在发布到npm上并下载使用的过程中,发生了什么问题并如何解决。
- 第一篇为组件封装后的使用文档及介绍
- 第二篇为组件的实现思路以及细节
- 第三篇为将组件打包并上传至npm,如何实现按需加载和下载后使用的问题
先确定初步要实现功能的大致需求:
- 鼠标点击卡片可进行移动,鼠标滚动时也根据跟着滚动
- 移动卡片在另一张卡片上方附近区域时,要进行位置交换。交换位置时,两张卡片中间的卡片要自动前移/后移
- 松开鼠标时卡片回到原位置/新位置
- 将属性、事件暴露出去给父组件调用,并制作插槽
建议看到一半不知道我在写什么的的小伙伴,直接去源码仓库看一下我的那个源码。只想快速了解一下的就只看下面问题的整体思路就可以了!
Q1:如何实现卡片移动?
整体思路:
- 所有卡片统一采用absolute布局,根据位置号码和列数等参数计算出top和left进行显示。
- 单独添加一个卡片,我们称它为拖拽卡片。z-index比其他普通卡片大,用于移动卡片。
- 点击卡片的时候,隐藏当前位置的普通卡片,把当前位置卡片的数据传入拖拽卡片,显示拖拽卡片。
- 点击后,全局监听鼠标的移动事件,鼠标移动多少距离,拖拽卡片就移动多少距离。同时需要监听窗口的滚动事件进行相同的操作。
- 当鼠标松开时,清除所有监听,把拖拽卡片恢复至根据位置号码计算出的位置。隐藏拖拽卡片,恢复普通卡片的显示。
具体实现:
首先我们先要将卡片结构制作出来,读取数据循环生成卡片。
<!-- 外层的div是用于制定卡片的范围包括外面的margin -->
<div
class="cardBorderBox"
v-for="item of listData"
:key="item.id"
:id="item.id"
>
<!-- 里面的div是用于显示卡片本身的内容 -->
<div class="cardInsideBox" >
<div class="topWrapBox">
<!-- 这里是标题栏,用于添加点击事件 -->
</div>
<div class="emptyContent">
<!-- 这里是内容部分 -->
</div>
</div>
</div>
<script>
export default {
//name记得一定要定义
name: "cardDragger",
data(){
return {
listData: [
{
positionNum: 1, // 位置号码,卡片的位置根据这个计算生成
name: "演示卡片1", // 卡片标题
id: "card1", // 用于辨识的卡片ID
},
]
}
},
}
</script>
复制代码
卡片还需要对位置和样式进行调整,需要的其他参数有:
data(){
return {
colNum:2, //一行有多少列
cardOutsideWidth:590, //单个卡片的外范围宽度
cardOutsideHeight:380, //单个卡片的外范围高度
cardInsideWidth:default:560, //单个卡片的内容宽度
cardInsideHeight:default:320, //单个卡片的内容高度
}
}
复制代码
卡片的布局采用absolute定位,便于制作过渡动画。卡片的top和left,就根据自身的位置号码计算生成。width和height使用设定的卡片外围宽高。
<template>
<div
class="cardBorderBox"
v-for="item of listData"
:key="item.id"
:id="item.id"
:style="{
top:computeTop(item.positionNum)+'px',
left:computeLeft(item.positionNum)+'px',
width:cardOutsideWidth+'px',
height:cardOutsideHeight+'px'
}"
>
<!-- 省略部分代码 -->
<div>
</template>
<script>
//整体就是按列数的限定,从左往右一行一行地排列数据
computeLeft(num) {
//left为(位置号码-1)%列数*卡片外围宽度
return (num-1) % this.colNum * this.cardOutsideWidth;
},
computeTop(num) {
//top为(位置号码/列数)向上取整,减去1,再乘以卡片外围高度
return (Math.ceil(num / this.colNum) - 1) * this.cardOutsideHeight;
}
</script>
<!-- 省略部分样式代码 -->
复制代码
因为我们移动卡片的时候,无法确保移动的那张卡片永远显示在最前面以及不具有任何事件和动画,所以我们需要再新增一张单独的卡片,我们称它为拖拽卡片,和上面遍历listData生成的普通卡片结构和样式是一样的。但拖拽卡片只用于在移动卡片的时候显示单个卡片数据。
<!-- 拖拽卡片,用moveBox这个class名字来查找 -->
<div
class="cardBorderBox moveBox"
v-if="selectMenuData"
>
<div class="cardInsideBox" >
<div class="topWrapBox">
<!-- 这里是标题栏 -->
</div>
<div class="emptyContent">
<!-- 这里是内容部分 -->
</div>
</div>
</div>
<!-- 省略部分代码 -->
data(){
return {
selectMenuData:null
//在移动卡片时设置selectMenuData为选中卡片数据,则可以显示拖拽卡片
}
}
复制代码
在我们卡片数据新增或删减,或组件首次加载完成时,都需要给普通卡片的listData数据添加selectState属性,默认为false。这样子我们的普通卡片和拖拽卡片,都有了用于识别显示/隐藏的状态值。
//判断卡片的selectState是否存在,不存在则添加false
methods:{
addSelectState( ){
this.listData.forEach(item=>{
if(item.selectState === undefined){
this.$set(item,'selectState',false)
}
})
}
},
watch:{
//这里不用进行深度监听,只需监听数组长度是否发生变化
listData(){
this.addSelectState()
}
},
beforeMount() {
this.addSelectState()
},
复制代码
接下来为了确保我们的卡片定位都是根据同一个位置进行绝对定位。我们需要在所有内容的最外面再包裹一层div,再添加上position:relative,根据listData的数量设定div的宽高。
<!--
首先,absolute是根据第一个父元素不为static 定位的元素进行定位
其次,确定宽高是因为将卡片移动的的时候,宽高会根据内容自适应,这里不需要宽高自适应。
宽度为:列数*卡片外围宽度
高度为:最后一个卡片的top+卡片外围的高度
-->
<div
:style="{
position:'relative',
height:computeTop(listData.length)+cardOutsideHeight+'px',
width:cardOutsideWidth*colNum+'px'}"
>
<!-- computeTop()方法是上面计算卡片top的方法 -->
<!-- 普通卡片代码 -->
<!-- 拖拽卡片代码 -->
</div>
复制代码
然后我们就给普通卡片的标题栏添加点击事件,当鼠标点击标题栏的时候,显示拖拽卡片,隐藏点击的普通卡片
<div
class="cardBorderBox"
v-for="item of listData"
:key="item.id"
:id="item.id"
>
<div class="cardInsideBox" >
<div @mousedown="touchStart($event,item.id)" class="topWrapBox">
<!-- 标题栏添加点击事件 -->
</div>
<div class="emptyContent">
<!-- 这里是内容部分 -->
</div>
</div>
</div>
methods: {
//event为鼠标的点击事件,selectId是当前数据的id
touchStart(event, selectId) {
const that = this;
//选中的卡片的dom
const selectDom = document.getElementById(selectId);
//获取屏幕滚动条位置
let originTop = document.body.scrollTop === 0 ?
document.documentElement.scrollTop : document.body.scrollTop;
let scrolTop = originTop;
//记录卡片的top和left
let moveTop
let moveLeft
//记录起始选中位置
let OriginObjPosition = {
left: 0,
top: 0,
originNum: -1
};
//起始鼠标信息
let OriginMousePosition = {
x: 0,
y: 0
};
//记录交换位置的号码
let OldPositon = null;
let NewPositon = null;
//1.保存点击的起始鼠标位置
OriginMousePosition.x = event.screenX;
OriginMousePosition.y = event.screenY;
//2.将选中的普通卡片数据传入拖拽卡片,显示拖拽卡片
this.selectMenuData = this.listData.find(item => {
return item.id === selectId;
});
//3.隐藏选中的普通卡片卡片
this.$set(this.selectMenuData, "selectState", true);
//4.保存现在卡片的top和left
moveLeft = OriginObjPosition.left = parseInt(
//这里获取到的left是带单位的字符串,要转换成纯数字
selectDom.style.left.slice(0, selectDom.style.left.length - 2)
);
moveTop = OriginObjPosition.top = parseInt(
selectDom.style.top.slice(0, selectDom.style.top.length - 2)
);
//在普通卡片隐藏,拖拽卡片显示之后,再调整拖拽卡片的位置和添加其他鼠标事件
//Vue.$nextTick()在下次 DOM 更新循环结束之后执行延迟回调
this.$nextTick(() => {
let moveBoxDom = document.querySelector(".d_moveBox")
if(moveBoxDom){
moveBoxDom.style.left = OriginObjPosition.left + "px";
moveBoxDom.style.top = OriginObjPosition.top + "px";
//都是全局监听
document.addEventListener("mousemove", mouseMoveListener);
document.addEventListener("mouseup", mouseUpListener);
document.addEventListener("scroll", mouseScroll);
}
});
//省略部分代码
}
}
复制代码
拖拽卡片显示出来了,鼠标移动、松开、滚轮事件也添加了。剩下的就是完善每个事件的内容了。首先是鼠标移动事件,我们需要监听鼠标的当前位置和原先位置进行对比,再调整拖拽卡片的top和left,就可完成点击卡片并移动卡片的效果。
methods: {
//所有其他函数都添加在touchStart方法里,共同使用点击事件的数据
touchStart(event, selectId) {
//省略部分代码
function mouseMoveListener(event) {
//在原来的top和left基础上,加上鼠标的偏移量
moveTop = OriginObjPosition.top + ( event.screenY - OriginMousePosition.y );
moveLeft = OriginObjPosition.left + ( event.screenX - OriginMousePosition.x );
document.querySelector(".d_moveBox").style.left = moveLeft + "px";
document.querySelector(".d_moveBox").style.top = moveTop + (scrolTop - originTop) + "px"; //这里要加上滚动的高度
}
}
}
复制代码
鼠标滚轮事件也差不多,监听滚动的具体,对拖拽卡片的位置进行改变。
function mouseScroll(event) {
scrolTop = document.body.scrollTop === 0
? document.documentElement.scrollTop
: document.body.scrollTop;
document.querySelector(".d_moveBox").style.top = moveTop + scrolTop - originTop + "px";
}
复制代码
Q2:如何检测并交换卡片?
整体思路:
- 移动卡片时,调用计算当前卡片位置属于哪个位置号码的函数,若与现有号码重复且不是自身号码的话则交换位置。
- 交换时对比位置号码是由小换到大,还是由大换到小,分别对两种情况的中间的号码分别前移一位/后移一位。
具体实现:
在上面的鼠标移动事件中,我们调用检测函数,检测当前移动位置是否有卡片在下方,但需要对检测函数进行节流,否则检测频率太高影响性能。卡片移动至另一张卡片的某一方向距离超过百分之50的距离时,则进行位置交换。(这里检测的是以卡片外围宽高进行计算的)
methods: {
touchStart(event, selectId) {
//用于保存检测位置的定时器
let DectetTimer = null;
//省略部分代码...
function mouseMoveListener(event) {
//省略部分代码...
//在鼠标移动的监听中添加如下代码
if (!DectetTimer) {
DectetTimer = setTimeout(()=>{
//节流调用检测函数,传入当前位置信息
cardDetect(moveTop + (scrolTop - originTop),moveLeft)
//调用结束清空定时器
DectetTimer = null;
}, 200);
}
}
function cardDetect(moveItemTop, moveItemLeft){
//计算当前移动卡片位于卡片的哪一行哪一列
let newWidthNum = Math.round((moveItemLeft/ that.cardOutsideWidth))+1
let newHeightNum = Math.round((moveItemTop/ that.cardOutsideHeight))
//如果移动卡片至范围外则不会有任何操作,直接返回
if(newHeightNum>(Math.ceil(that.listData.length / that.colNum) - 1)||
newHeightNum<0||
newWidthNum<=0||
newWidthNum>that.colNum){
return false
}
//将计算的行列转换为位置号码
const newPositionNum = (newWidthNum) + newHeightNum * that.colNum
if(newPositionNum!==that.selectMenuData.positionNum){
//寻找当前位置号码有没有卡片数据
let newItem = that.listData.find(item=>{
return item.positionNum === newPositionNum
})
//有卡片数据的话就进行交换
if( newItem ){
swicthPosition(newItem, that.selectMenuData);
}
}
}
}
}
复制代码
当检测得到的位置号码,与现有的其他普通卡片位置号码重复时,则判定为需要交换位置。交换的情况分为位置号码从小移动到大,和从大移动到小两种情况。
//省略部分代码
function swicthPosition(newItem, originItem) {
//原来的位置号码和需要交换的位置号码
OldPositon = originItem.positionNum;
NewPositon = newItem.positionNum;
//位置号码从小移动到大
if (NewPositon > OldPositon) {
//需要往前移动的数据都存在这里
let changeArray = [];
//从小移动到大,那小的号码就会空出来,其余卡片应往前移动一位
//找出两个号码中间对应的卡片数据
for (let i = OldPositon + 1; i <= NewPositon; i++) {
let pushData = that.listData.find(item => {
return item.positionNum === i;
});
changeArray.push(pushData);
}
for (let item of changeArray) {
//vue的$set使更改数据的同时实时刷新样式
that.$set(item, "positionNum", item.positionNum - 1);
}
that.$set(originItem, "positionNum", NewPositon);
}
//位置号码从大移动到小
if (NewPositon < OldPositon) {
let changeArray = [];
//从大移动到小,那大的号码就会空出来,其余卡片应往后移动一位
//找出两个号码中间对应的卡片数据
for (let i = OldPositon - 1; i >= NewPositon; i--) {
let pushData = that.listData.find(item => {
return item.positionNum === i;
});
changeArray.push(pushData);
}
for (let item of changeArray) {
that.$set(item, "positionNum", item.positionNum + 1);
}
that.$set(originItem, "positionNum", NewPositon);
}
}
复制代码
Q3:鼠标松开之后回到原位?
整体思路:
- 鼠标松开时,先清空位置检测中的定时器,再进行最后一次位置检测。
- 然后将拖拽卡片恢复至位置号码对应的位置,到原位时显示原普通卡片,隐藏拖拽卡片。
具体实现:
function mouseUpListener() {
/*首先清除位置检测的定时器,
因为位置检测的定时器,会在鼠标松开事件结束后执行,
会导致拖拽卡片都已经回到原位置并隐藏了,还会发生位置交换导致报错。
应该调整为,先清楚定时器,直接检测,再添加卡片返回原处的动画*/
clearTimeout(DectetTimer)
DectetTimer = null
//对鼠标松开位置直接进行最后一次位置检测
cardDetect(moveTop + (scrolTop - originTop),moveLeft)
//设置拖动卡片当前位置号码计算生成的宽高,并添加transition进行过渡
document.querySelector(".d_moveBox").style.transition = "all 0.3s";
document.querySelector(".d_moveBox").style.top = that.computeTop(that.selectMenuData.positionNum) + "px";
document.querySelector(".d_moveBox").style.left = that.computeLeft(that.selectMenuData.positionNum) + "px";
that.mousedownTimer = setTimeout(() => {
/*mousedownTimer是一个全局定时器,默认为空。详情可看仓库源码。
若鼠标松开,卡片过渡动画开始时后则激活定时器,
时间到了的话就先显示原来的组件,再隐藏拖拽组件,然后清空定时器内容。
保证在过渡动画执行期间,不能点击其他卡片。
mousedownTimer在点击事件开始时进行判断,若不为空则直接返回跳出点击事件
*/
that.$set(that.selectMenuData, "selectState", false);
that.selectMenuData = null;
clearTimeout(that.mousedownTimer);
that.mousedownTimer = null;
}, 300);
//移除所有监听
document.removeEventListener("mousemove", mouseMoveListener);
document.removeEventListener("mouseup", mouseUpListener);
document.removeEventListener("scroll", mouseScroll);
}
复制代码
Q4:如何制作组件插槽和属性、事件的自定义?
整体思路:
- 属性:将data的数据都放在props进行定义并设定默认值
- 事件:只要在组件中的某些函数中调用$emit,在使用时进行监听
- 插槽:使用vue在2.6.0更新的具名插槽进行制作
具体实现:
原来在data中的需要让用户自定义使用的属性,都改为放在props中,并赋予默认值
//组件中:
props:{
data:{
type:Array,
//设定默认值,返回空数组
default: function () {
return []
}
},
colNum:{
type:Number,
default:2
},
cardOutsideWidth:{
type:Number,
default:590
},
cardOutsideHeight:{
type:Number,
default:380
},
cardInsideWidth:{
type:Number,
default:560
},
cardInsideHeight:{
type:Number,
default:320
}
},
//使用时:
<cardDragger
:data="componentData"
:colNum="3"
:cardOutsideWidth="360"
:cardInsideWidth="320"
:cardOutsideHeight="250"
:cardInsideHeight="210"
>
复制代码
事件封装也很简单,只需在需要的地方调用自定义事件,例如,我在鼠标松开的事件中调用了:
//组件中$emit事件名+要传递的数据
function mouseUpListener() {
that.$emit('finishDrag',OldPositon,NewPositon,that.selectMenuData)
}
//使用时
<cardDragger
:data="componentData"
@finishDrag="finishDrag"
>
export default {
methods: {
finishDrag(OldPositon,NewPositon,originItem){
console.log(OldPositon,NewPositon,originItem)
}
}
}
复制代码
插槽制作的话,先要确定你有什么内容是需要制作至插槽的。我这里的话是要将标题栏的内容和卡片内容添加插槽,使用的是vue的具名插槽。把你原有的需要用插槽替换的内容放入slot里面,当做默认内容就可以了。
<div
class="d_cardBorderBox"
v-for="item of listData"
:key="item.id"
:id="item.id"
>
<div
class="d_cardInsideBox"
v-if="item.selectState===false"
>
<!--保留标题栏添加事件内容的div里添加slot,保留点击事件-->
<div @mousedown="touchStart($event,item.id)" class="d_topWrapBox">
<!--原来这里应该是标题栏的内容,将slot添加至slot的默认值即可-->
<slot name="header" v-bind:item="item">
<div class="d_topMenuBox" >
<div class="d_menuTitle" >{{item.name}}</div>
</div>
</slot>
</div>
<slot name="content" v-bind:item="item" >
<div class="d_emptyContent">
卡片暂无内容
</div>
</slot>
</div>
</div>
复制代码
还使用了作用域插槽,让插槽内容能够访问子组件中才有的数据。并且我还做了一些判断,若data数据里的componentData是存在的话就使用vue的component优先显示。这里就不再赘述啦。
Q5:制作遇到哪些问题?
1.为什么不用drag和drop?
不采用h5的drag和drop是因为鼠标样式会变成禁止符号和拖拽时会变成透明。不符合我对拖拽样式的需求。
2.拖拽卡片何时添加transition?
在显示卡片和移动卡片的时候,是不能添加transition的,否则拖起来会有延迟。只有在鼠标松开后,使卡片返回原处的时候再添加transition进行过渡。又因为拖拽卡片是用v-if显示的,在下次显示拖拽卡片的时候transition已经被销毁了。
3.动画还没结束时快速点击另一张卡片报错了怎么办?
添加了一个全局定时器,若鼠标松开,卡片过渡动画开始时后则激活定时器,结束后清空定时器内容。点击卡片的事件先判断定时器内容是否为空再往下执行。
当前稳定版本:0.3.5,更新于10月23日早上11点,拖拽卡片部分去掉了,采用原生卡片,此文章有待修改

相关文章
- 超级好用的 Java 数据可视化库:Tablesaw
- [Web 前端] 030 ajax 是什么
- Selenium 弹窗按钮操作
- Python3 日期和时间
- 你未必知道的CSS知识点(第二季)
- 是让人耳目一新的 Jetpack MVVM 精讲啊!
- Flutter 跨端网络抓包 (以Android 为例)
- 手把手入门Fish-Redux开发flutter(下)
上一篇: Docker Compose 网络设置
下一篇: 两分钟带你学会ssh免密登录