【vue实现模仿探探卡片滑动切换效果】

这是一个拿过来就可以用的卡片滑动组件

一、效果展示

探探滑动卡片

二、实现梳理

  1. 封装组件:需要传递数据,参数类型为数组,用于展示卡片
  2. 样式设置:采用定位,堆叠在一起
  3. 用户操作:点击、左滑、右滑
  4. 滑动逻辑:按下、拖动、松开
  5. 需判断滑出容器的距离
  6. 需判断左滑、右滑
  7. 切换下一张卡片

三、代码实现

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>
相关推荐
无我Code3 小时前
全套开源:一款云端服务+本地设备计算的文生图应用
前端·人工智能·后端
用户69371750013844 小时前
实测可用|小米 MiMo 百万亿 Token 免费领,开发者速冲
前端·后端·ai编程
前端小万4 小时前
令人头痛的前端环境
前端·前端工程化
明月_清风4 小时前
Nginx 模块机制深度解析:从核心原理到生产实践
前端·nginx
APIshop4 小时前
1688 跨境寻源通详情接口深度解析:从接入到实战
前端·网络·chrome
爱上好庆祝4 小时前
学习js的第四天
前端·css·学习·html·css3·js
d111111111d4 小时前
UAER问题+修复小bug
前端·javascript·笔记·stm32·单片机·嵌入式硬件·学习
kyriewen115 小时前
Next.js:让你的React应用从“裸奔”到“穿衣服”
开发语言·前端·javascript·react.js·设计模式·ecmascript