V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
xiaoyan2017
V2EX  ›  推广

Vue.js|Nuxt 模仿探探叠加滑动|vue 仿 Tinder 卡片效果

  •  
  •   xiaoyan2017 · 2020-10-13 17:06:01 +08:00 · 1909 次点击
    这是一个创建于 1501 天前的主题,其中的信息可能已经有所发展或是发生改变。

    前言

    这段时间一直在捣鼓 Nuxt.js 项目,有个需求是实现类似探探卡片左右滑动切换功能。要求能实现左右手指拖动切换、点击按钮进行切换、拖拽回弹等功能。

    基于 Vue|Nuxt.js 卡片式翻牌效果

    如上图:最终展示效果

    okay,下面就来简单的讲解下实现过程。

    布局

    整体布局分为 顶部 headerbar 、卡片堆叠区域、底部 tabbar 三个部分。

    <!-- //卡片页面模板 -->
    <template>
        <div>
            <!-- >>顶部 -->
            <header-bar :back="false" bgcolor="linear-gradient(to right, #00e0a1, #00a1ff)" fixed>
                <div slot="title"><i class="iconfont icon-like c-red"></i> <em class="ff-gg">遇见 TA</em></div>
                <div slot="right" class="ml-30" @click="showFilter = true"><i class="iconfont icon-filter"></i></div>
            </header-bar>
    
            <!-- >>主页面 -->
            <div class="nuxt__scrollview scrolling flex1" ref="scrollview" style="background: linear-gradient(to right, #00e0a1, #00a1ff);">
                <div class="nt__flipcard">
                    <div class="nt__stack-wrapper">
                        <flipcard ref="stack" :pages="stackList" @click="handleStackClicked"></flipcard>
                    </div>
                    <div class="nt__stack-control flexbox">
                        <button class="btn-ctrl prev" @click="handleStackPrev"><i class="iconfont icon-unlike "></i></button>
                        <button class="btn-ctrl next" @click="handleStackNext"><i class="iconfont icon-like "></i></button>
                    </div>
                </div>
            </div>
    
            <!-- >>底部 tabbar -->
            <tab-bar bgcolor="linear-gradient(to right, #00e0a1, #00a1ff)" color="#fff" />
        </div>
    </template>
    
    

    侧边筛选框

    点击右上角筛选按钮,在侧边会出现弹窗。里面的范围滑块、switch 开关、Rate 评分等组件则是使用 Vant 组件库。

    <template>
        <!-- ... -->
        
        <!-- @@侧边栏弹框模板 -->
        <v-popup v-model="showFilter" position="left" xclose xposition="left" title="高级筛选与设置">
            <div class="flipcard-filter">
                <div class="item nuxt-cell">
                    <label class="lbl">范围</label>
                    <div class="flex1">
                        <van-slider v-model="distanceRange" bar-height="2px" button-size="12px" active-color="#00e0a1" min="1" @input="handleDistanceRange" />
                    </div>
                    <em class="val">{{distanceVal}}</em>
                </div>
                <div class="item nuxt-cell">
                    <label class="lbl flex1">自动增加范围</label>
                    <em class="val"><van-switch v-model="autoExpand" size="20px" active-color="#00e0a1" /></em>
                </div>
                <div class="item nuxt-cell">
                    <label class="lbl flex1">性别</label>
                    <em class="val">女生</em>
                </div>
                <div class="item nuxt-cell">
                    <label class="lbl">好评度</label>
                    <div class="flex1"><van-rate v-model="starVal" color="#00e0a1" icon="like" void-icon="like-o" @change="handleStar" /></div>
                    <em class="val">{{starVal}}星</em>
                </div>
                <div class="item nuxt-cell">
                    <label class="lbl flex1">优先在线用户</label>
                    <em class="val"><van-switch v-model="firstOnline" size="20px" active-color="#00e0a1" /></em>
                </div>
                <div class="item nuxt-cell">
                    <label class="lbl flex1">优先新用户</label>
                    <em class="val"><van-switch v-model="firstNewUser" size="20px" active-color="#00e0a1" /></em>
                </div>
                <div class="item nuxt-cell mt-20">
                    <div class="mt-30 nuxt__btn nuxt__btn-primary--gradient" style="height:38px;"><i class="iconfont icon-filter"></i> 更新</div>
                </div>
            </div>
        </v-popup>
    </template>
     
    <script>
        export default {
            // 用于配置应用默认的 meta 标签
            head() {
                return {
                    title: `${this.title} - 翻一翻`,
                    meta: [
                        {name:'keywords',hid: 'keywords',content:`${this.title} | 翻一翻 | 翻动卡片`},
                        {name:'description',hid:'description',content:`${this.title} | 仿探探卡片翻动`}
                    ]
                }
            },
            middleware: 'auth',
            data () {
                return {
                    title: 'Nuxt',
                    showFilter: false,
                    distanceRange: 1,
                    distanceVal: '<1km',
                    autoExpand: true,
                    starVal: 5,
                    firstOnline: false,
                    firstNewUser: true,
                    
                    // ...
                }
            },
            methods: {
                /* @@左侧筛选函数 */
                // 范围选择
                handleDistanceRange(val) {
                    if(val == 1) {
                        this.distanceVal = '<1km';
                    } else if (val == 100) {
                        this.distanceVal = "100km+"
                    }else {
                        this.distanceVal = val+'km';
                    }
                },
                // 好评度
                handleStar(val) {
                    this.starVal = val;
                },
                
                // ...
            },
        }
    </script>
    

    Nuxt 仿 Tinder 堆叠卡片

    其中卡片堆叠区单独封装了一个 flipcard.vue 组件,只需传入 pages 数据就可以。

    <flipcard ref="stack" :pages="stackList"></flipcard>

    在卡片的四角拖拽卡片,会出现不同程度的斜切视角。

    pages 支持传入的参数

    module.exports = [
        {
            avatar: '/assets/img/avatar02.jpg',
            name: '放荡不羁爱自由',
            sex: 'female',
            age: 23,
            starsign: '天秤座',
            distance: '艺术 /健身',
            photos: [...],
            sign: '交个朋友,非诚勿扰'
        },
        
        ...
    ]
    

    堆叠卡片模板

    <template>
        <ul class="stack">
            <li class="stack-item" v-for="(item, index) in pages" :key="index" :style="[transformIndex(index),transform(index)]"
                @touchmove.stop.capture="touchmove"
                @touchstart.stop.capture="touchstart"
                @touchend.stop.capture="touchend($event, index)"
                @touchcancel.stop.capture="touchend($event, index)"
                @mousedown.stop.capture.prevent="touchstart"
                @mouseup.stop.capture.prevent="touchend($event, index)"
                @mousemove.stop.capture.prevent="touchmove"
                @mouseout.stop.capture.prevent="touchend($event, index)"
                @webkit-transition-end="onTransitionEnd(index)"
                @transitionend="onTransitionEnd(index)"
            >
                <img :src="item.avatar" />
                <div class="stack-info">
                    <h2 class="name">{{item.name}}</h2>
                    <p class="tags">
                        <span class="sex" :class="item.sex"><i class="iconfont" :class="'icon-'+item.sex"></i> {{item.age}}</span>
                        <span class="xz">{{item.starsign}}</span>
                    </p>
                    <p class="distance">{{item.distance}}</p>
                </div>
            </li>
        </ul>
    </template>
    
    /**
     * @Desc     Vue 仿探探|Tinder 卡片滑动 FlipCard
     * @Time     andy by 2020-10-06
     * @About    Q:282310962  wx:xy190310
     */
    <script>
        export default {
            props: {
                pages: {
                    type: Array,
                    default: {}
                }
            },
            data () {
                return {
                    basicdata: {
                        start: {},
                        end: {}
                    },
                    temporaryData: {
                        isStackClick: true,
                        offsetY: '',
                        poswidth: 0,
                        posheight: 0,
                        lastPosWidth: '',
                        lastPosHeight: '',
                        lastZindex: '',
                        rotate: 0,
                        lastRotate: 0,
                        visible: 3,
                        tracking: false,
                        animation: false,
                        currentPage: 0,
                        opacity: 1,
                        lastOpacity: 0,
                        swipe: false,
                        zIndex: 10
                    }
                }
            },
            computed: {
                // 划出面积比例
                offsetRatio () {
                    let width = this.$el.offsetWidth
                    let height = this.$el.offsetHeight
                    let offsetWidth = width - Math.abs(this.temporaryData.poswidth)
                    let offsetHeight = height - Math.abs(this.temporaryData.posheight)
                    let ratio = 1 - (offsetWidth * offsetHeight) / (width * height) || 0
                    return ratio > 1 ? 1 : ratio
                },
                // 划出宽度比例
                offsetWidthRatio () {
                    let width = this.$el.offsetWidth
                    let offsetWidth = width - Math.abs(this.temporaryData.poswidth)
                    let ratio = 1 - offsetWidth / width || 0
                    return ratio
                }
            },
            methods: {
                touchstart (e) {
                    if (this.temporaryData.tracking) {
                        return
                    }
                    // 是否为 touch
                    if (e.type === 'touchstart') {
                        if (e.touches.length > 1) {
                            this.temporaryData.tracking = false
                            return
                        } else {
                            // 记录起始位置
                            this.basicdata.start.t = new Date().getTime()
                            this.basicdata.start.x = e.targetTouches[0].clientX
                            this.basicdata.start.y = e.targetTouches[0].clientY
                            this.basicdata.end.x = e.targetTouches[0].clientX
                            this.basicdata.end.y = e.targetTouches[0].clientY
                            // offsetY 在 touch 事件中没有,只能自己计算
                            this.temporaryData.offsetY = e.targetTouches[0].pageY - this.$el.offsetParent.offsetTop
                        }
                    // pc 操作
                    } else {
                        this.basicdata.start.t = new Date().getTime()
                        this.basicdata.start.x = e.clientX
                        this.basicdata.start.y = e.clientY
                        this.basicdata.end.x = e.clientX
                        this.basicdata.end.y = e.clientY
                        this.temporaryData.offsetY = e.offsetY
                    }
                    this.temporaryData.isStackClick = true
                    this.temporaryData.tracking = true
                    this.temporaryData.animation = false
                },
                touchmove (e) {
                    this.temporaryData.isStackClick = false
                    // 记录滑动位置
                    if (this.temporaryData.tracking && !this.temporaryData.animation) {
                        if (e.type === 'touchmove') {
                            e.preventDefault()
                            this.basicdata.end.x = e.targetTouches[0].clientX
                            this.basicdata.end.y = e.targetTouches[0].clientY
                        } else {
                            e.preventDefault()
                            this.basicdata.end.x = e.clientX
                            this.basicdata.end.y = e.clientY
                        }
                        // 计算滑动值
                        this.temporaryData.poswidth = this.basicdata.end.x - this.basicdata.start.x
                        this.temporaryData.posheight = this.basicdata.end.y - this.basicdata.start.y
                        let rotateDirection = this.rotateDirection()
                        let angleRatio = this.angleRatio()
                        this.temporaryData.rotate = rotateDirection * this.offsetWidthRatio * 15 * angleRatio
                    }
                },
                touchend (e, index) {
                    if(this.temporaryData.isStackClick) {
                        this.$emit('click', index)
                        this.temporaryData.isStackClick = false
                    }
                    this.temporaryData.isStackClick = true
                    this.temporaryData.tracking = false
                    this.temporaryData.animation = true
                    // 滑动结束,触发判断
                    // 判断划出面积是否大于 0.4
                    if (this.offsetRatio >= 0.4) {
                        // 计算划出后最终位置
                        let ratio = Math.abs(this.temporaryData.posheight / this.temporaryData.poswidth)
                        this.temporaryData.poswidth = this.temporaryData.poswidth >= 0 ? this.temporaryData.poswidth + 200 : this.temporaryData.poswidth - 200
                        this.temporaryData.posheight = this.temporaryData.posheight >= 0 ? Math.abs(this.temporaryData.poswidth * ratio) : -Math.abs(this.temporaryData.poswidth * ratio)
                        this.temporaryData.opacity = 0
                        this.temporaryData.swipe = true
                        this.nextTick()
                        // 不满足条件则滑入
                    } else {
                        this.temporaryData.poswidth = 0
                        this.temporaryData.posheight = 0
                        this.temporaryData.swipe = false
                        this.temporaryData.rotate = 0
                    }
                },
                nextTick () {
                    // 记录最终滑动距离
                    this.temporaryData.lastPosWidth = this.temporaryData.poswidth
                    this.temporaryData.lastPosHeight = this.temporaryData.posheight
                    this.temporaryData.lastRotate = this.temporaryData.rotate
                    this.temporaryData.lastZindex = 20
                    // 循环 currentPage
                    this.temporaryData.currentPage = this.temporaryData.currentPage === this.pages.length - 1 ? 0 : this.temporaryData.currentPage + 1
                    // currentPage 切换,整体 dom 进行变化,把第一层滑动置最低
                    this.$nextTick(() => {
                        this.temporaryData.poswidth = 0
                        this.temporaryData.posheight = 0
                        this.temporaryData.opacity = 1
                        this.temporaryData.rotate = 0
                    })
                },
                onTransitionEnd (index) {
                    let lastPage = this.temporaryData.currentPage === 0 ? this.pages.length - 1 : this.temporaryData.currentPage - 1
                    // dom 发生变化正在执行的动画滑动序列已经变为上一层
                    if (this.temporaryData.swipe && index === lastPage) {
                        this.temporaryData.animation = true
                        this.temporaryData.lastPosWidth = 0
                        this.temporaryData.lastPosHeight = 0
                        this.temporaryData.lastOpacity = 0
                        this.temporaryData.lastRotate = 0
                        this.temporaryData.swipe = false
                        this.temporaryData.lastZindex = -1
                    }
                },
                prev () {
                    this.temporaryData.tracking = false
                    this.temporaryData.animation = true
                    // 计算划出后最终位置
                    let width = this.$el.offsetWidth
                    this.temporaryData.poswidth = -width
                    this.temporaryData.posheight = 0
                    this.temporaryData.opacity = 0
                    this.temporaryData.rotate = '-3'
                    this.temporaryData.swipe = true
                    this.nextTick()
                },
                next () {
                    this.temporaryData.tracking = false
                    this.temporaryData.animation = true
                    // 计算划出后最终位置
                    let width = this.$el.offsetWidth
                    this.temporaryData.poswidth = width
                    this.temporaryData.posheight = 0
                    this.temporaryData.opacity = 0
                    this.temporaryData.rotate = '3'
                    this.temporaryData.swipe = true
                    this.nextTick()
                },
                rotateDirection () {
                    if (this.temporaryData.poswidth <= 0) {
                        return -1
                    } else {
                        return 1
                    }
                },
                angleRatio () {
                    let height = this.$el.offsetHeight
                    let offsetY = this.temporaryData.offsetY
                    let ratio = -1 * (2 * offsetY / height - 1)
                    return ratio || 0
                },
                inStack (index, currentPage) {
                    let stack = []
                    let visible = this.temporaryData.visible
                    let length = this.pages.length
                    for (let i = 0; i < visible; i++) {
                        if (currentPage + i < length) {
                            stack.push(currentPage + i)
                        } else {
                            stack.push(currentPage + i - length)
                        }
                    }
                    return stack.indexOf(index) >= 0
                },
                // 非首页样式切换
                transform (index) {
                    let currentPage = this.temporaryData.currentPage
                    let length = this.pages.length
                    let lastPage = currentPage === 0 ? this.pages.length - 1 : currentPage - 1
                    let style = {}
                    let visible = this.temporaryData.visible
                    if (index === this.temporaryData.currentPage) {
                        return
                    }
                    if (this.inStack(index, currentPage)) {
                        let perIndex = index - currentPage > 0 ? index - currentPage : index - currentPage + length
                        style['opacity'] = '1'
                        style['transform'] = 'translate3D(0,0,' + -1 * 60 * (perIndex - this.offsetRatio) + 'px' + ')'
                        style['zIndex'] = visible - perIndex
                        if (!this.temporaryData.tracking) {
                            style['transitionTimingFunction'] = 'ease'
                            style['transitionDuration'] = 300 + 'ms'
                        }
                    } else if (index === lastPage) {
                        style['transform'] = 'translate3D(' + this.temporaryData.lastPosWidth + 'px' + ',' + this.temporaryData.lastPosHeight + 'px' + ',0px) ' + 'rotate(' + this.temporaryData.lastRotate + 'deg)'
                        style['opacity'] = this.temporaryData.lastOpacity
                        style['zIndex'] = this.temporaryData.lastZindex
                        style['transitionTimingFunction'] = 'ease'
                        style['transitionDuration'] = 300 + 'ms'
                    } else {
                        style['zIndex'] = '-1'
                        style['transform'] = 'translate3D(0,0,' + -1 * visible * 60 + 'px' + ')'
                    }
                    return style
                },
                // 首页样式切换
                transformIndex (index) {
                    if (index === this.temporaryData.currentPage) {
                        let style = {}
                        style['transform'] = 'translate3D(' + this.temporaryData.poswidth + 'px' + ',' + this.temporaryData.posheight + 'px' + ',0px) ' + 'rotate(' + this.temporaryData.rotate + 'deg)'
                        style['opacity'] = this.temporaryData.opacity
                        style['zIndex'] = 10
                        if (this.temporaryData.animation) {
                            style['transitionTimingFunction'] = 'ease'
                            style['transitionDuration'] = (this.temporaryData.animation ? 300 : 0) + 'ms'
                        }
                        return style
                    }
                },
            }
        }
    </script>
    

    点击卡片会直接跳转到详细页面。

    ok,基于 Vue.js|Nuxt.js 实现卡片拖拽切换效果就分享到这里。希望能喜欢~~

    作者:xiaoyan2017
    链接: https://segmentfault.com/a/1190000037446858
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

    3 条回复    2020-10-13 20:33:51 +08:00
    Exia
        1
    Exia  
       2020-10-13 18:03:54 +08:00
    感谢分享,已经收藏,看着流畅性不错~
    xiaoyan2017
        2
    xiaoyan2017  
    OP
       2020-10-13 18:42:52 +08:00
    @Exia 感谢支持!滑动挺流畅的。
    think2011
        3
    think2011  
       2020-10-13 20:33:51 +08:00
    那么多 this.xxx = xx, 就不能写成一个变量再赋值吗? 😰
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   3665 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 34ms · UTC 10:17 · PVG 18:17 · LAX 02:17 · JFK 05:17
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.