Vue.js|Nuxt 模仿探探叠加滑动|vue 仿 Tinder 卡片效果 - V2EX
V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
xiaoyan2017
V2EX    推广

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

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

    前言

    这段时间一直在捣鼓 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     Solana     2485 人在线   最高记录 6679       Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 26ms UTC 15:31 PVG 23:31 LAX 08:31 JFK 11:31
    Do have faith in what you're doing.
    ubao snddm index pchome yahoo rakuten mypaper meadowduck bidyahoo youbao zxmzxm asda bnvcg cvbfg dfscv mmhjk xxddc yybgb zznbn ccubao uaitu acv GXCV ET GDG YH FG BCVB FJFH CBRE CBC GDG ET54 WRWR RWER WREW WRWER RWER SDG EW SF DSFSF fbbs ubao fhd dfg ewr dg df ewwr ewwr et ruyut utut dfg fgd gdfgt etg dfgt dfgd ert4 gd fgg wr 235 wer3 we vsdf sdf gdf ert xcv sdf rwer hfd dfg cvb rwf afb dfh jgh bmn lgh rty gfds cxv xcv xcs vdas fdf fgd cv sdf tert sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf shasha9178 shasha9178 shasha9178 shasha9178 shasha9178 liflif2 liflif2 liflif2 liflif2 liflif2 liblib3 liblib3 liblib3 liblib3 liblib3 zhazha444 zhazha444 zhazha444 zhazha444 zhazha444 dende5 dende denden denden2 denden21 fenfen9 fenf619 fen619 fenfe9 fe619 sdf sdf sdf sdf sdf zhazh90 zhazh0 zhaa50 zha90 zh590 zho zhoz zhozh zhozho zhozho2 lislis lls95 lili95 lils5 liss9 sdf0ty987 sdft876 sdft9876 sdf09876 sd0t9876 sdf0ty98 sdf0976 sdf0ty986 sdf0ty96 sdf0t76 sdf0876 df0ty98 sf0t876 sd0ty76 sdy76 sdf76 sdf0t76 sdf0ty9 sdf0ty98 sdf0ty987 sdf0ty98 sdf6676 sdf876 sd876 sd876 sdf6 sdf6 sdf9876 sdf0t sdf06 sdf0ty9776 sdf0ty9776 sdf0ty76 sdf8876 sdf0t sd6 sdf06 s688876 sd688 sdf86