首先演示一下最终效果:html
流畅的拖动和交换位置效果,并实时更新数据 vue
支持组件的样式和内容自定义 git
这是此次系列文章的第二篇,我本身封装了一个用vue实现的拖拽排列卡片组件,而且发布到npm,详细地记录下来了总体制做过程。总共有三篇文章,介绍组件的制做思路和遇到的问题,以及在发布到npm上并下载使用的过程当中,发生了什么问题并如何解决。github
先肯定初步要实现功能的大体需求:npm
建议看到一半不知道我在写什么的的小伙伴,直接去源码仓库看一下个人那个源码。只想快速了解一下的就只看下面问题的总体思路就能够了!api
首先咱们先要将卡片结构制做出来,读取数据循环生成卡片。数组
<!-- 外层的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>
复制代码
卡片还须要对位置和样式进行调整,须要的其余参数有:bash
data(){
return {
colNum:2, //一行有多少列
cardOutsideWidth:590, //单个卡片的外范围宽度
cardOutsideHeight:380, //单个卡片的外范围高度
cardInsideWidth:default:560, //单个卡片的内容宽度
cardInsideHeight:default:320, //单个卡片的内容高度
mousedownTimer: null //用于记录卡片当前是否在过渡状态中的定时器
}
}
复制代码
卡片的布局采用absolute定位,便于制做过渡动画。width和height使用设定的卡片外围宽高。dom
<template>
<div
class="cardBorderBox"
v-for="item of listData"
:key="item.id"
:id="item.id"
:style="{ 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>
<!-- 省略部分样式代码 -->
复制代码
在首次加载和监听到卡片数量产生变更时,须要从新根据自身的位置号码计算生成卡片的top和left。保证异步数据的加载也能读取到。异步
//判断卡片的selectState是否存在,不存在则添加false
methods:{
addCardStyle(){
this.$nextTick(()=>{
this.listData.forEach(item=>{
document.querySelector('#'+item.id).style.top = this.computeTop(item.positionNum)+'px'
document.querySelector('#'+item.id).style.left = this.computeLeft(item.positionNum)+'px'
})
})
}
},
watch:{
listData:{
handler:function(){
this.addCardStyle()
},
immediate: true
}
}
复制代码
接下来咱们须要在全部内容的最外面再包裹一层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) {
//其余卡片正在动画中的时候不能够再次点击,不然动画和数据会出错。
if (this.mousedownTimer) {
return false;
}
const that = this;
//选中的卡片的dom和数据
let selectDom = document.getElementById(selectId);
let selectMenuData = this.data.find(item => {
return item.id === 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.给选中卡片一个transition:none的class,去除默认过渡
selectDom.classList.add('d_moveBox')
//3.保存如今卡片的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)
);
//4.添加其余鼠标事件
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";
}
复制代码
在上面的鼠标移动事件中,咱们调用检测函数,检测当前移动位置是否有卡片在下方,但须要对检测函数进行节流,不然检测频率过高影响性能。卡片移动至另外一张卡片的某一方向距离超过百分之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!==selectMenuData.positionNum){
//寻找当前位置号码有没有卡片数据
let newItem = that.listData.find(item=>{
return item.positionNum === newPositionNum
})
//有卡片数据的话就进行交换
if( newItem ){
swicthPosition(newItem, selectMenuData);
}
}
}
}
}
复制代码
当检测获得的位置号码,与现有的其余普通卡片位置号码重复时,则断定为须要交换位置。交换的状况分为位置号码从小移动到大,和从大移动到小两种状况。
//省略部分代码
function swicthPosition(newItem, originItem) {
OldPositon = originItem.positionNum;
NewPositon = newItem.positionNum;
that.$emit('swicthPosition',OldPositon,NewPositon,originItem)
//位置号码从小移动到大
if (NewPositon > OldPositon) {
let changeArray = [];
//从小移动到大,那小的号码就会空出来,其他卡片应往前移动一位
//找出两个号码中间对应的卡片数据
for (let i = OldPositon + 1; i <= NewPositon; i++) {
let pushData = that.data.find(item => {
return item.positionNum === i;
});
changeArray.push(pushData);
}
for (let item of changeArray) {
//vue的$set实时更改数据
that.$set(item, "positionNum", item.positionNum - 1);
//原生js调整卡片动画
document.querySelector('#'+item.id).style.top = that.computeTop(item.positionNum)+'px'
document.querySelector('#'+item.id).style.left = that.computeLeft(item.positionNum)+'px'
}
//正在拖动的卡片就不须要动画了
that.$set(originItem, "positionNum", NewPositon);
}
//位置号码从大移动到小
if (NewPositon < OldPositon) {
let changeArray = [];
//从大移动到小,那大的号码就会空出来,其他卡片应日后移动一位
//找出两个号码中间对应的卡片数据
for (let i = OldPositon - 1; i >= NewPositon; i--) {
let pushData = that.data.find(item => {
return item.positionNum === i;
});
changeArray.push(pushData);
}
for (let item of changeArray) {
that.$set(item, "positionNum", item.positionNum + 1);
document.querySelector('#'+item.id).style.top = that.computeTop(item.positionNum)+'px'
document.querySelector('#'+item.id).style.left = that.computeLeft(item.positionNum)+'px'
}
that.$set(originItem, "positionNum", NewPositon);
}
}
复制代码
function mouseUpListener() {
/*首先清除位置检测的定时器,
由于位置检测的定时器,会在鼠标松开事件结束后执行,
会致使拖拽卡片都已经回到原位置并隐藏了,还会发生位置交换致使报错。
应该调整为,先清楚定时器,直接检测,再添加卡片返回原处的动画*/
clearTimeout(DectetTimer)
DectetTimer = null
//对鼠标松开位置直接进行最后一次位置检测
cardDetect(moveTop + (scrolTop - originTop),moveLeft)
//设置卡片当前位置号码计算生成的宽高,并添加transition进行过渡
document.querySelector(".d_moveBox").classList.add('d_transition');
document.querySelector(".d_moveBox").style.top = that.computeTop(selectMenuData.positionNum) + "px";
document.querySelector(".d_moveBox").style.left = that.computeLeft(selectMenuData.positionNum) + "px";
that.$emit('finishDrag',OldPositon,NewPositon,selectMenuData)
that.mousedownTimer = setTimeout(() => {
/*mousedownTimer是一个全局定时器,默认为空。详情可看仓库源码。
若鼠标松开,卡片过渡动画开始时后则激活定时器,
时间到了的话就清空定时器内容。
保证在过渡动画执行期间,不能点击其余卡片。
mousedownTimer在点击事件开始时进行判断,若不为空则直接返回跳出点击事件
*/
document.querySelector(".d_moveBox").classList.remove('d_transition')
document.querySelector(".d_moveBox").classList.remove('d_moveBox')
clearTimeout(that.mousedownTimer);
that.mousedownTimer = null;
}, 300);
//移除全部监听
document.removeEventListener("mousemove", mouseMoveListener);
document.removeEventListener("mouseup", mouseUpListener);
document.removeEventListener("scroll", mouseScroll);
}
复制代码
原来在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优先显示。这里就再也不赘述啦。
不采用h5的drag和drop是由于鼠标样式会变成禁止符号和拖拽时会变成透明。不符合我对拖拽样式的需求。
在显示卡片和移动卡片的时候,是不能添加transition的,不然拖起来会有延迟。只有在鼠标松开后,使卡片返回原处的时候再添加transition进行过渡。又由于拖拽卡片是用v-if显示的,在下次显示拖拽卡片的时候transition已经被销毁了。
添加了一个全局定时器,若鼠标松开,卡片过渡动画开始时后则激活定时器,结束后清空定时器内容。点击卡片的事件先判判定时器内容是否为空再往下执行。
重写了位置检测,重写了拖动,去除了好多无用的代码。对异步数据没法加载进行修复。目前以为已经比一开始好了不少!请放心使用!
😃以上就是我制做这个组件的全过程啦,应该还有不少能够优化的地方,欢迎你们指正。以为有点意思的话记得点个赞呀~