最近探探的卡片飞出效果比较火热,趁着手头空闲,我自己也手撸了一次这个功能,最后总结的时候发现该功能的核心代码不超过40行,先来看看最终效果。
布局
先将整体UI框架搭建起来
<template>
<div class="lp-card-throw-body">
<!-- 为了使卡片内容居中 -->
<div class="lp-card-throw-box" :style="{
width: cardWidth + 'px',
height: cardHeight + 'px'
}">
<!-- 拖拽卡片 -->
<div class="card" :style="{
'z-index': 10 - index,
width: cardWidth - index * offset + 'px',
height: cardHeight - index * offset + 'px',
top: index * offset * 1.5 + 'px',
left: index * (offset / 2) + 'px',
background: '#fff'
}" v-for="(item, index) in list" :key="index"></div>
</div>
</div>
</template>
<script>
export default {
props: {
// 卡片宽度
cardWidth: {
type: Number,
default: 250
},
// 卡片高度
cardHeight: {
type: Number,
default: 250
},
// 卡片间的间隔,数值越大,卡片之间间隔越大
offset: {
type: Number,
default: 25
}
},
data () {
return {
list: [1, 2, 3, 4, 5, 6, 7]
};
}
};
</script>
<style lang="scss" scoped>
.lp-card-throw-body {
position: relative;
width: 100%;
height: 100%;
.lp-card-throw-box {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
.card {
position: absolute;
width: 100%;
height: 100%;
box-shadow: 0px 4px 20px rgba(0,0,0,.3);
}
}
}
</style>
拖拽并回弹
实现拖拽效果,并在手指松开时回弹到原点
<template>
<div class="lp-card-throw-body">
<!-- 为了使卡片内容居中 -->
<div class="lp-card-throw-box" :style="...">
<!-- 拖拽卡片 -->
<div class="card"
:style="..."
:class="{ animation: animation }"
@touchstart.stop.prevent='touchStart'
@touchmove='touchMove'
@touchend='touchEnd'
v-for="(item, index) in list" :key="index"></div>
</div>
</div>
</template>
<script>
export default {
props: {
...
},
data () {
return {
list: [1, 2, 3, 4, 5, 6, 7],
// 顶部盒子相对于lp-card-throw-box的偏移量,可以是负值
left: 0,
top: 0,
// 触摸开始的坐标
startPoint: {
x: 0,
y: 0
},
// 添加动画效果,让效果看起来更加丝滑
animation: false
};
},
methods: {
// 触摸开始
touchStart (event) {
const touch = event.touches[0];
// 记录开始触摸时的坐标,方便计算盒子的偏移量
this.startPoint.x = touch.clientX;
this.startPoint.y = touch.clientY;
},
// 触摸移动
touchMove (event) {
const touch = event.touches[0];
// 顶部盒子移动的距离 = 当前坐标 - 触摸开始时的坐标
// 即顶部盒子相对于`lp-card-throw-box`的偏移量
this.left = touch.clientX - this.startPoint.x;
this.top = touch.clientY - this.startPoint.y;
},
// 触摸结束
touchEnd (event) {
// 将顶部的盒子恢复原位
this.top = 0;
this.left = 0;
// 添加动画效果,让效果看起来更加丝滑
this.animation = true;
setTimeout(() => {
this.animation = false;
}, 400);
}
}
};
</script>
<style lang="scss" scoped>
.lp-card-throw-body {
...
.lp-card-throw-box {
...
.card {
...
}
.animation {
transition: height .4s ease-out, width .4s ease-out, left .4s ease-out, top .4s ease-out;
}
}
}
</style>
到这里,基础功能已经实现了,还剩下根据条件判断将卡片飞出了,如果拖拽距离太短,则让其回到原点,否则将卡片飞出去,我们先看看现在的效果。
卡片飞出
判断飞出条件:根据勾股定理,a^2 + b^2 = c^2,其中,a代表了this.left,b代表了this.top,c指弹性指数,超过这个指数则飞出,否则还原,所以飞出的临界条件就是Math.pow(this.left, 2) + Math.pow(this.top, 2) < Math.pow(throwRatio, 2)
,throwRatio可以由父组件传入。
另外就是顶部卡片飞出的距离,我们可以也给他一个系数,这个系数越大,飞出的也就越远。
export default {
props: {
// 触发飞出的系数
throwRatio: {
type: Number,
default: 150
},
// 飞出的距离系数
throwDistance: {
type: Number,
default: 500
}
...
},
...
// 触摸结束
touchEnd (event) {
// 如果没有超过一定距离,则还原,否则飞出
// 公式利用了数学中的勾股定理
if (Math.pow(this.left, 2) + Math.pow(this.top, 2) < Math.pow(this.throwRatio, 2)) {
// 将顶部的盒子恢复原位
this.top = 0;
this.left = 0;
} else {
// 将盒子飞出
// 也可以用changedTouches.clientX
this.left = this.left * this.throwDistance;
this.top = this.top * this.throwDistance;
this.list.splice(0, 1);
}
// 添加动画效果,让效果看起来更加丝滑
this.animation = true;
setTimeout(() => {
this.left = 0;
this.top = 0;
this.animation = false;
}, 60);
}
}
至此,这个功能也就完成了90%,来看看效果吧!
优化
基本的完成的差不多了,但是想要将其制作成一个组件,还是有一些地方需要优化的
- 比如重复性代码太多,多处出现了
this.left = 0;this.top = 0;
的代码 - 子组件不能直接对
this.list
进行删除,否则会报错,所以我们需要触发父级的函数
开撸:首先我们对touchEnd
函数中的代码进行优化。
// 触摸结束
touchEnd (event) {
this.animation = true;
// 如果没有超过一定距离,则还原,否则飞出
// 公式利用了数学中的勾股定理
if (Math.pow(this.left, 2) + Math.pow(this.top, 2) >= Math.pow(this.throwRatio, 2)) {
// 将盒子飞出
// 也可以用changedTouches.clientX
this.left = this.left * this.throwDistance;
this.top = this.top * this.throwDistance;
this.onThrowDone();
}
// 添加动画效果,让效果看起来更加丝滑
setTimeout(() => {
this.resetCard();
}, 60);
},
// 使盒子回到原处
resetCard () {
this.left = 0;
this.top = 0;
this.animation = false;
},
// 卡片成功飞出后触发父组件的方法,让父组件自己删除list数组
onThrowDone () {
this.$emit('onThrowDone');
}
优化后我们解决了哪些问题:
- 首先,我将判断条件中的
小于
换成了大于或等于
,因为我们从原来的代码中可以看到,无论是否经过判断,最终都会经过this.left = 0; this.top = 0; this.animation = false;
,只要换一个判断条件,我可以减少多余的重复性代码,何乐不为呢? - 第二,将重复性代码抽离出来,用
resetCard
函数进行封装,这样后期我们可以多次调用的时候我们自需要调用这个函数就行了 - 第三,当卡片成功飞出后,触发父组件的方法,让父组件自己删除数组的元素,这样父组件的调用就可以这样写
<template>
<div class="lp-card-throw-demo">
<lp-card-throw @onThrowDone='onThrowDone' :list='list'></lp-card-throw>
</div>
</template>
<script>
import { lpCardThrow } from '@/index.js';
export default {
components: {
lpCardThrow
},
data () {
return {
list: [1, 2, 3, 4, 5, 6, 7]
};
},
methods: {
// 卡片成功飞出后触发
onThrowDone () {
// 每次删除顶部的元素
this.list.splice(0, 1);
}
}
};
</script>
至此,探探卡片飞出效果才算是真的完成了,到此为止真正的逻辑代码还不到40行,我们还可以对其进行补充,比如触摸按下时的事件,移动时的事件,取消的事件等等,下面是本次案例的完整代码。
完整代码
<!-- 卡片组件 -->
<template>
<div class="lp-card-throw-body">
<!-- 为了使卡片内容居中 -->
<div class="lp-card-throw-box" :style="{
width: cardWidth + 'px',
height: cardHeight + 'px'
}">
<!-- 拖拽卡片 -->
<div class="card" :style="{
'z-index': 10 - index,
width: cardWidth - index * offset + 'px',
height: cardHeight - index * offset + 'px',
top: (index === 0 ? top : index * offset * 1.5) + 'px',
left: (index === 0 ? left : index * (offset / 2)) + 'px',
background: '#fff',
opacity: index < cardLevels ? 1 : 0
}"
:class="{ animation: animation }"
@touchstart.stop.prevent='touchStart'
@touchmove='touchMove'
@touchend='touchEnd'
v-for="(item, index) in list" :key="index">{{item}}</div>
<div v-show="list.length === 0">没有卡片了</div>
</div>
</div>
</template>
<script>
export default {
props: {
// 卡片宽度
cardWidth: {
type: Number,
default: 250
},
// 卡片高度
cardHeight: {
type: Number,
default: 250
},
// 卡片间的间隔,数值越大,卡片之间间隔越大
offset: {
type: Number,
default: 25
},
// 触发飞出的系数
throwRatio: {
type: Number,
default: 150
},
// 飞出的距离
throwDistance: {
type: Number,
default: 500
},
// 传入的数据
list: {
type: Array,
default: () => {
return [];
}
},
// 卡片显示的层数
cardLevels: {
type: Number,
default: 3
}
},
data () {
return {
// 顶部盒子相对于lp-card-throw-box的偏移量,可以是负值
left: 0,
top: 0,
// 触摸开始的坐标
startPoint: {
x: 0,
y: 0
},
// 是否加载动画
animation: false
};
},
methods: {
// 触摸开始
touchStart (event) {
const touch = event.touches[0];
// 记录开始触摸时的坐标,方便计算盒子的偏移量
this.startPoint.x = touch.clientX;
this.startPoint.y = touch.clientY;
},
// 触摸移动
touchMove (event) {
const touch = event.touches[0];
// 顶部盒子移动的距离 = 当前坐标 - 触摸开始时的坐标
// 即顶部盒子相对于`lp-card-throw-box`的偏移量
this.left = touch.clientX - this.startPoint.x;
this.top = touch.clientY - this.startPoint.y;
},
// 触摸结束
touchEnd (event) {
this.animation = true;
// 如果没有超过一定距离,则还原,否则飞出
// 公式利用了数学中的勾股定理
if (Math.pow(this.left, 2) + Math.pow(this.top, 2) >= Math.pow(this.throwRatio, 2)) {
// 将盒子飞出
// 也可以用changedTouches.clientX
this.left = this.left * this.throwDistance;
this.top = this.top * this.throwDistance;
this.onThrowDone();
}
// 添加动画效果,让效果看起来更加丝滑
setTimeout(() => {
this.resetCard();
}, 60);
},
// 使盒子回到原处
resetCard () {
this.left = 0;
this.top = 0;
this.animation = false;
},
onThrowDone () {
this.$emit('onThrowDone');
}
}
};
</script>
<style lang="scss" scoped>
.lp-card-throw-body {
position: relative;
width: 100%;
height: 100%;
.lp-card-throw-box {
position: absolute;
top: 50%;
left: 50%;
text-align: center;
line-height: 250px;
transform: translate(-50%, -50%);
.card {
position: absolute;
width: 100%;
height: 100%;
box-shadow: 0px 4px 20px rgba(0,0,0,.3);
}
.animation {
transition: opacity .4s ease-out, left .4s ease-out, top .4s ease-out;
}
}
}
</style>
<!-- 父组件调用代码 -->
<template>
<div class="lp-card-throw-demo">
<lp-card-throw @onThrowDone='onThrowDone' :list='list'></lp-card-throw>
</div>
</template>
<script>
import { lpCardThrow } from '@/index.js';
export default {
components: {
lpCardThrow
},
data () {
return {
list: [1, 2, 3, 4, 5, 6, 7]
};
},
methods: {
onThrowDone () {
this.list.splice(0, 1);
}
}
};
</script>
<style lang="scss" scoped>
.lp-card-throw-demo {
width: 100vw;
height: 100vh;
}
</style>
更多相关文档,请见:
线上地址 【前端橘子君】
GitHub仓库【前端橘子君】