探探/Tinder是一個很火的陌生人社交App,趁着國慶假期閒暇時間倒騰了個Nuxt.js項目,項目中有個模塊模仿探探滑動切換界面效果。支持左右拖拽滑動like和no like及滑動回彈效果。html
一覽效果vue
emmmm,是否是效果還不錯,哈哈~web
好了,下面就簡單的講解下具體的實現過程,你們若是感興趣的話也能夠去試一試。微信
頁面模塊佈局分爲 頂部headerBar、翻牌子區域、底部tabBar 三個部分。app
在頁面剛加載的時候,爲了不卡片區域空白,加了一張背景圖 (古風bg)。dom
佈局模板templateasync
<!-- //翻一翻模板 --> <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>
至於項目中頂部headerBar/底部tabBar組件,這裏不介紹了,感興趣的能夠去參看這篇分享文章。ide
http://www.javashuo.com/article/p-rxwieczb-nu.html函數
點擊右上角高級篩選,會在側邊彈出篩選框。裏面的範圍滑塊、switch開關、Rate評分等組件則是使用Vant組件庫。佈局
VPopup彈框組件實現的側邊欄彈出層功能。
http://www.javashuo.com/article/p-tcssdxnq-nu.html
<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>
爲了頁面代碼整潔,卡片滑動區單獨封裝了一個flipcard.vue組件。只需傳入pages數據就行。
<flipcard ref="stack" :pages="stackList" @click="handleStackClicked"></flipcard>
上、下、左、右四個方向拖拽會出現不一樣的斜切視角,支持拖拽回彈動畫。
pages傳入JSON數據格式
module.exports = [ { avatar: '/assets/img/avatar02.jpg', name: '放蕩不羈愛自由', sex: 'female', age: 23, starsign: '天秤座', distance: '藝術/健身', photos: [...], sign: '交個朋友,非誠勿擾' }, ... ]
flipcard.vue模板
<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>
組件使用了touch和mouse事件來支持移動端和PC端滑動。
/** * @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>
另外,點擊卡片會跳轉到卡片詳情信息頁。
在卡片詳情頁,接收傳過來的卡片ID,獲取該用戶詳細信息便可。
<script> const stackJson = require('./mock-stack.js') export default { // ... async asyncData({app, params, query, store}) { let cid = query.cid let stackArr = stackJson[cid] return { cid: cid, stackitem: stackArr } }, } </script>
ok,基於Vue/Nuxt實現卡片滑動就分享到這裏。但願你們能喜歡哈!😏
最後附上一個最近實例項目