这是一个拿过来就可以用的卡片滑动组件
一、效果展示
探探滑动卡片
二、实现梳理
- 封装组件:需要传递数据,参数类型为数组,用于展示卡片
- 样式设置:采用定位,堆叠在一起
- 用户操作:点击、左滑、右滑
- 滑动逻辑:按下、拖动、松开
- 需判断滑出容器的距离
- 需判断左滑、右滑
- 切换下一张卡片
三、代码实现
javascript
<template>
<ul
ref="stack"
class="tinder-stack"
@mouseleave="onDragEnd"
>
<li
v-for="(item, index) in pages"
:key="cardKey(item, index)"
class="tinder-card"
:style="cardStyle(index)"
@touchstart.stop.capture="onDragStart"
@touchmove.stop.capture="onDragMove"
@touchend.stop.capture="onDragEnd"
@touchcancel.stop.capture="onDragEnd"
@mousedown.stop.capture.prevent="onDragStart"
@mousemove.stop.capture.prevent="onDragMove"
@mouseup.stop.capture.prevent="onDragEnd"
>
<slot name="card" :item="item" :index="index">
<img class="tinder-card-image" :src="item.avatar" alt="" />
<div class="tinder-card-info">
<h3 class="tinder-card-title">{{ item.name || '未命名' }}</h3>
<p class="tinder-card-subtitle">{{ item.distance || '' }}</p>
</div>
</slot>
</li>
</ul>
</template>
<script>
export default {
name: 'tinderCardStack',
props: {
pages: {
type: Array,
default: () => []
},
visible: {
type: Number,
default: 3
},
swipeThreshold: {
type: Number,
default: 0.25
}
},
data () {
return {
currentIndex: 0,
dragging: false,
moving: false,
startX: 0,
startY: 0,
x: 0,
y: 0,
animation: false
}
},
computed: {
offsetWidthRatio () {
const width = this.stackWidth
if (!width) {
return 0
}
return Math.min(Math.abs(this.x) / width, 1)
},
offsetRatio () {
const width = this.stackWidth
const height = this.stackHeight
if (!width || !height) {
return 0
}
const offsetWidth = Math.max(0, width - Math.abs(this.x))
const offsetHeight = Math.max(0, height - Math.abs(this.y))
const ratio = 1 - (offsetWidth * offsetHeight) / (width * height)
return Math.min(Math.max(ratio, 0), 1)
},
rotation () {
return this.offsetWidthRatio * 15 * (this.x >= 0 ? 1 : -1)
},
stackWidth () {
return this.$refs.stack ? this.$refs.stack.offsetWidth : 0
},
stackHeight () {
return this.$refs.stack ? this.$refs.stack.offsetHeight : 0
}
},
methods: {
cardKey (item, index) {
return item && item.id !== undefined ? item.id : index
},
getPoint (e) {
if (e.touches && e.touches[0]) {
return { x: e.touches[0].clientX, y: e.touches[0].clientY }
}
if (e.changedTouches && e.changedTouches[0]) {
return { x: e.changedTouches[0].clientX, y: e.changedTouches[0].clientY }
}
return { x: e.clientX, y: e.clientY }
},
relativeIndex (index) {
const length = this.pages.length
if (!length) {
return -1
}
const diff = index - this.currentIndex
return diff >= 0 ? diff : diff + length
},
cardStyle (index) {
const rel = this.relativeIndex(index)
const style = {}
if (rel < 0 || rel >= this.visible) {
style.opacity = '0'
style.zIndex = '-1'
style.transform = `translate3d(0, 0, ${-this.visible * 60}px)`
return style
}
if (rel === 0) {
style.zIndex = this.visible + 2
style.opacity = '1'
style.transform = `translate3d(${this.x}px, ${this.y}px, 0) rotate(${this.rotation}deg)`
if (this.animation) {
style.transition = 'transform 0.28s ease, opacity 0.28s ease'
}
return style
}
style.zIndex = this.visible - rel
style.opacity = `${1 - rel * 0.08 + this.offsetRatio * 0.08}`
style.transform = `translate3d(0, 0, ${-60 * (rel - this.offsetRatio)}px)`
style.transition = this.dragging ? 'none' : 'transform 0.28s ease, opacity 0.28s ease'
return style
},
onDragStart (e) {
if (!this.pages.length || this.animation) {
return
}
const p = this.getPoint(e)
this.dragging = true
this.moving = false
this.startX = p.x
this.startY = p.y
this.x = 0
this.y = 0
},
onDragMove (e) {
if (!this.dragging || this.animation) {
return
}
if (e.cancelable) {
e.preventDefault()
}
const p = this.getPoint(e)
this.moving = true
this.x = p.x - this.startX
this.y = p.y - this.startY
},
onDragEnd () {
if (!this.dragging) {
return
}
this.dragging = false
this.animation = true
const width = this.stackWidth || 320
const swipeOutRatio = Math.abs(this.x) / width
const threshold = Math.min(Math.max(this.swipeThreshold, 0), 1)
const shouldSwipe = swipeOutRatio >= threshold
const active = this.currentIndex
const currentItem = this.pages[active]
if (shouldSwipe) {
const direction = this.x >= 0 ? 'right' : 'left'
this.x = this.x >= 0 ? width * 1.2 : -width * 1.2
this.y = this.y * 1.1
window.setTimeout(() => {
this.currentIndex = (this.currentIndex + 1) % this.pages.length
this.x = 0
this.y = 0
this.animation = false
this.$emit('card-swiped', {
index: active,
direction,
item: currentItem
})
}, 280)
} else {
const isClick = !this.moving || (Math.abs(this.x) < 6 && Math.abs(this.y) < 6)
this.x = 0
this.y = 0
window.setTimeout(() => {
this.animation = false
if (isClick) {
this.$emit('click', active)
}
}, 280)
}
},
prev () {
if (!this.pages.length || this.animation) {
return
}
this.animation = true
this.x = -(this.stackWidth || 320)
this.y = 0
window.setTimeout(() => {
this.currentIndex = (this.currentIndex - 1 + this.pages.length) % this.pages.length
this.x = 0
this.y = 0
this.animation = false
}, 280)
},
next () {
if (!this.pages.length || this.animation) {
return
}
this.animation = true
this.x = this.stackWidth || 320
this.y = 0
const oldIndex = this.currentIndex
window.setTimeout(() => {
this.currentIndex = (this.currentIndex + 1) % this.pages.length
this.x = 0
this.y = 0
this.animation = false
this.$emit('card-swiped', {
index: oldIndex,
direction: 'right',
item: this.pages[oldIndex]
})
}, 280)
}
}
}
</script>
<style scoped>
.tinder-stack {
position: relative;
width: 100%;
height: 500px;
margin: 0;
padding: 0;
list-style: none;
user-select: none;
}
.tinder-card {
position: absolute;
inset: 0;
border-radius: 12px;
overflow: hidden;
background: #fff;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18);
will-change: transform, opacity;
}
.tinder-card-image {
width: 100%;
height: 72%;
display: block;
object-fit: cover;
}
.tinder-card-info {
padding: 16px;
color: #222;
}
.tinder-card-title {
margin: 0 0 8px;
font-size: 20px;
font-weight: 700;
}
.tinder-card-subtitle {
margin: 0;
font-size: 14px;
color: #666;
}
</style>
四、使用组件
javascript
<template>
<div class="scroll-container" ref="scrollContainer">
<div class="card_container">
<tinder-card-stack
:pages="stackList"
:visible="3"
:swipe-threshold="0.8"
@card-swiped="onCardSwiped"
@click="onCardClick"
/>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import pic1 from '@/assets/img/1.png'
import pic2 from '@/assets/img/2.png'
import pic3 from '@/assets/img/3.png'
import pic4 from '@/assets/img/4.png'
import pic5 from '@/assets/img/5.png'
import pic6 from '@/assets/img/6.png'
import pic7 from '@/assets/img/7.png'
import pic8 from '@/assets/img/8.png'
import pic9 from '@/assets/img/9.png'
import pic10 from '@/assets/img/10.png'
import pic11 from '@/assets/img/11.png'
import pic12 from '@/assets/img/12.png'
import pic13 from '@/assets/img/13.png'
import pic14 from '@/assets/img/14.png'
import pic15 from '@/assets/img/15.png'
import pic16 from '@/assets/img/16.png'
import pic17 from '@/assets/img/17.png'
import pic18 from '@/assets/img/18.png'
import pic19 from '@/assets/img/19.png'
import pic20 from '@/assets/img/20.png'
const stackList = ref([
{
id: 1,
name: '小美',
age: 24,
sex: 'female',
starsign: '天秤座',
distance: '2.5公里',
avatar: pic1
},
{
id: 2,
name: '小明',
age: 28,
sex: 'male',
starsign: '射手座',
distance: '3.2公里',
avatar: pic2
},
{
id: 3,
name: '小丽',
age: 22,
sex: 'female',
starsign: '双鱼座',
distance: '1.8公里',
avatar: pic3
},
{
id: 4,
name: '小华',
age: 26,
sex: 'male',
starsign: '白羊座',
distance: '4.1公里',
avatar: pic4
},
{
id: 5,
name: '小芳',
age: 25,
sex: 'female',
starsign: '巨蟹座',
distance: '0.8公里',
avatar: pic5
},
{
id: 6,
name: '安然',
age: 23,
sex: 'female',
starsign: '水瓶座',
distance: '1.2公里',
avatar: pic6
},
{
id: 7,
name: '子墨',
age: 29,
sex: 'male',
starsign: '狮子座',
distance: '5.6公里',
avatar: pic7
},
{
id: 8,
name: '雨桐',
age: 21,
sex: 'female',
starsign: '双子座',
distance: '2.1公里',
avatar: pic8
},
{
id: 9,
name: '浩宇',
age: 27,
sex: 'male',
starsign: '天蝎座',
distance: '6.3公里',
avatar: pic9
},
{
id: 10,
name: '若溪',
age: 24,
sex: 'female',
starsign: '处女座',
distance: '3.8公里',
avatar: pic10
},
{
id: 11,
name: '梓辰',
age: 26,
sex: 'male',
starsign: '摩羯座',
distance: '2.9公里',
avatar: pic11
},
{
id: 12,
name: '可欣',
age: 22,
sex: 'female',
starsign: '白羊座',
distance: '1.4公里',
avatar: pic12
},
{
id: 13,
name: '星野',
age: 25,
sex: 'male',
starsign: '天秤座',
distance: '4.7公里',
avatar: pic13
},
{
id: 14,
name: '梦琪',
age: 23,
sex: 'female',
starsign: '金牛座',
distance: '0.9公里',
avatar: pic14
},
{
id: 15,
name: '景行',
age: 30,
sex: 'male',
starsign: '双鱼座',
distance: '7.1公里',
avatar: pic15
},
{
id: 16,
name: '诗雅',
age: 26,
sex: 'female',
starsign: '射手座',
distance: '3.0公里',
avatar: pic16
},
{
id: 17,
name: '泽言',
age: 27,
sex: 'male',
starsign: '巨蟹座',
distance: '2.2公里',
avatar: pic17
},
{
id: 18,
name: '念初',
age: 24,
sex: 'female',
starsign: '狮子座',
distance: '5.0公里',
avatar: pic18
},
{
id: 19,
name: '远舟',
age: 28,
sex: 'male',
starsign: '处女座',
distance: '4.4公里',
avatar: pic19
},
{
id: 20,
name: '晴岚',
age: 25,
sex: 'female',
starsign: '摩羯座',
distance: '1.6公里',
avatar: pic20
}
])
function onCardSwiped(payload) {
console.log(payload)
}
function onCardClick(index) {
console.log(index)
}
</script>
<style scoped>