vue.js|nuxt仿探探界面|vue卡片堆疊切換效果

Vue|Nuxt仿製探探卡片式滑動效果

Tinder/探探的卡片設計都非常棒,如何在vue項目中實現類似探探卡片左右滑動切換效果? 最近由於項目需求,就自己倒騰了個組件,基本實現了探探滑動切換功能。

如上圖,是不是感覺效果棒棒噠!

整體佈局

頁面佈局分爲 頂部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開關、Star評分等組件則是使用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>

vue仿探探卡片

爲了代碼整潔,卡片滑動區單獨封裝了一個組件flipcard,只需傳入pages數據就可以。

pages數據格式

module.exports = [
    {
        avatar: '/assets/img/avatar02.jpg',
        name: '放蕩不羈愛自由',
        sex: 'female',
        age: 23,
        starsign: '天秤座',
        distance: '藝術/健身',
        photos: [...],
        sign: '交個朋友,非誠勿擾'
    },
    
    ...
]

卡片模板template

<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>

拖拽卡片四周會出現不同角度的傾斜效果。組件支持touch和mouse事件,在移動端和PC端均可滑動。

點擊卡片後會跳轉到詳情頁。

好了,基於Nuxt項目仿製探探卡片堆疊效果就分享到這裏。希望對大家有些幫助哈~~✍💪

最後附上一個UniApp實例項目

unipp+vue仿抖音短視頻|uni-app直播實例

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章