前言
嗨, 说起探探想必各位程序汪都不陌生 (毕竟妹子很多), 能在上面丝滑的翻牌子, 探探的的堆叠滑动组件起到了关键的作用, 下面就来看看如何用 vue 写一个探探的堆叠组件
一. 功能分析
简单使用下探探会发现, 堆叠滑动的功能很简单, 用一张图概括就是:
简单归纳下里面包含的基本功能点:
图片的堆叠
图片第一张的滑动
条件成功后的滑出, 条件失败后的回弹
滑出后下一张图片堆叠到顶部
体验优化
根据触摸点的不同, 滑动时首图有不同角度偏移
偏移面积判定是否成功滑出
二. 具体实现
有了归纳好的功能点, 我们实现组件的思路会更清晰
1. 堆叠效果
堆叠图片效果在网上有大量的实例, 实现的方法大同小异, 主要通过在父层设定 perspective 及 perspective-origin, 来实现子层的透视, 子层设定好 translate3d Z 轴数值即可模拟出堆叠效果, 具体代码如下
- // 图片堆叠 dom
- <!--opacity: 0 隐藏我们不想看到的 stack-item 层级 -->
- <!--z-index: -1 调整 stack-item 层级 "-->
- <ul class="stack">
- <li class="stack-item" style="transform: translate3d(0px, 0px, 0px);opacity: 1;z-index: 10;"><img src="1.png" alt="01"></li>
- <li class="stack-item" style="transform: translate3d(0px, 0px, -60px);opacity: 1;z-index: 1"><img src="2.png" alt="02"></li>
- <li class="stack-item" style="transform: translate3d(0px, 0px, -120px);opacity: 1;z-index: 1"><img src="3.png" alt="03"></li>
- <li class="stack-item" style="transform: translate3d(0px, 0px, -180px);opacity: 0;z-index: -1"><img src="4.png" alt="04"></li>
- <li class="stack-item" style="transform: translate3d(0px, 0px, -180px);opacity: 0;z-index: -1"><img src="5.png" alt="05"></li>
- </ul>
- <style>
- .stack {
- width: 100%;
- height: 100%;
- position: relative;
- perspective: 1000px; // 子元素视距
- perspective-origin: 50% 150%; // 子元素透视位置
- -webkit-perspective: 1000px;
- -webkit-perspective-origin: 50% 150%;
- margin: 0;
- padding: 0;
- }
- .stack-item{
- background: #fff;
- height: 100%;
- width: 100%;
- border-radius: 4px;
- text-align: center;
- overflow: hidden;
- }
- .stack-item img {
- width: 100%;
- display: block;
- pointer-events: none;
- }
- </style>
上面只是一组静态代码, 我们希望得到的是 vue 组件, 所以需要先建立一个组件模板 stack.vue, 在模板中我们可以使用 v-for, 遍历出 stack 节点, 使用: style 来修改各个 item 的 style, 代码如下
- <template>
- <ul class="stack">
- <li class="stack-item" v-for="(item, index) in pages" :style="[transform(index)]">
- <img :src="item.src">
- </li>
- </ul>
- </template>
- <script>
- export
- default {
- props:
- {
- // pages 数据包含基础的图片数据
- pages:
- {
- type:
- Array,
- default:
- []
- }
- },
- data() {
- return {
- // basicdata 数据包含组件基本数据
- basicdata: {
- currentPage: 0 // 默认首图的序列
- },
- // temporaryData 数据包含组件临时数据
- temporaryData: {
- opacity: 1,
- // 记录 opacity
- zIndex: 10,
- // 记录 zIndex
- visible: 3 // 记录默认显示堆叠数 visible
- }
- }
- },
- methods: {
- // 遍历样式
- transform(index) {
- if (index>= this.basicdata.currentPage) {
- let style = {}
- let visible = this.temporaryData.visible let perIndex = index - this.basicdata.currentPage
- // visible 可见数量前滑块的样式
- if (index <= this.basicdata.currentPage + visible - 1) {
- style['opacity'] = '1'style['transform'] = 'translate3D(0,0,' + -1 * perIndex * 60 + 'px' + ')'style['zIndex'] = visible - index + this.basicdata.currentPage style['transitionTimingFunction'] = 'ease'style['transitionDuration'] = 300 + 'ms'
- } else {
- style['zIndex'] = '-1'style['transform'] = 'translate3D(0,0,' + -1 * visible * 60 + 'px' + ')'
- }
- return style
- }
- }
- }
- }
- </script>
关键点
:style 可以绑定对象的同时, 也可以绑定数组和函数, 这在遍历的时候很有用
最基本的 dom 结构已经构建完毕, 下一步是让首张图片动起来
2. 图片滑动
图片滑动效果, 在很多场景中都有出现, 其原理无非是监听 touchs 事件, 得到位移, 再通过 translate3D 改变目标位移, 因此我们要实现的步骤如下
对 stack 进行 touchs 事件的绑定
监听并储存手势位置变化的数值
改变首图 css 属性中 translate3D 的 x,y 值
具体实现
在 vue 框架中, 不建议直接操作节点, 而是通过指令 v-on 对元素进行绑定, 因此我们将绑定都写在 v-for 遍历里, 通过 index 进行判断其是否是首图, 再使用: style 修改首页的样式, 具体代码如下:
- <template>
- <ul class="stack">
- <li class="stack-item" v-for="(item, index) in pages"
- :style="[transformIndex(index),transform(index)]"
- @touchstart.stop.capture="touchstart"
- @touchmove.stop.capture="touchmove"
- @touchend.stop.capture="touchend"
- @mousedown.stop.capture="touchstart"
- @mouseup.stop.capture="touchend"
- @mousemove.stop.capture="touchmove">
- <img :src="item.src">
- </li>
- </ul>
- </template>
- <script>
- export default {
- props: {
- // pages 数据包含基础的图片数据
- pages: {
- type: Array,
- default: []
- }
- },
- data () {
- return {
- // basicdata 数据包含组件基本数据
- basicdata: {
- start: {}, // 记录起始位置
- end: {}, // 记录终点位置
- currentPage: 0 // 默认首图的序列
- },
- // temporaryData 数据包含组件临时数据
- temporaryData: {
- poswidth: '', // 记录位移
- posheight: '', // 记录位移
- tracking: false // 是否在滑动, 防止多次操作, 影响体验
- }
- }
- },
- 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
- }
- // 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.tracking = true
- },
- touchmove (e) {
- // 记录滑动位置
- if (this.temporaryData.tracking && !this.temporaryData.animation) {
- if (e.type === 'touchmove') {
- this.basicdata.end.x = e.targetTouches[0].clientX
- this.basicdata.end.y = e.targetTouches[0].clientY
- } else {
- 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
- }
- },
- touchend (e) {
- this.temporaryData.tracking = false
- // 滑动结束, 触发判断
- },
- // 非首页样式切换
- transform (index) {
- if (index> this.basicdata.currentPage) {
- let style = {}
- let visible = 3
- let perIndex = index - this.basicdata.currentPage
- // visible 可见数量前滑块的样式
- if (index <= this.basicdata.currentPage + visible - 1) {
- style['opacity'] = '1'
- style['transform'] = 'translate3D(0,0,' + -1 * perIndex * 60 + 'px' + ')'
- style['zIndex'] = visible - index + this.basicdata.currentPage
- style['transitionTimingFunction'] = 'ease'
- style['transitionDuration'] = 300 + 'ms'
- } else {
- style['zIndex'] = '-1'
- style['transform'] = 'translate3D(0,0,' + -1 * visible * 60 + 'px' + ')'
- }
- return style
- }
- },
- // 首页样式切换
- transformIndex (index) {
- // 处理 3D 效果
- if (index === this.basicdata.currentPage) {
- let style = {}
- style['transform'] = 'translate3D(' + this.temporaryData.poswidth + 'px' + ',' + this.temporaryData.posheight + 'px' + ',0px)'
- style['opacity'] = 1
- style['zIndex'] = 10
- return style
- }
- }
- }
- }
- </script>
3. 条件成功后的滑出, 条件失败后的回弹
条件的触发判断是在 touchend/mouseup 后进行, 在这里我们先用简单的条件进行判定, 同时给予首图弹出及回弹的效果, 代码如下
- <template>
- <ul class="stack">
- <li class="stack-item" v-for="(item, index) in pages"
- :style="[transformIndex(index),transform(index)]"
- @touchmove.stop.capture="touchmove"
- @touchstart.stop.capture="touchstart"
- @touchend.stop.capture="touchend"
- @mousedown.stop.capture="touchstart"
- @mouseup.stop.capture="touchend"
- @mousemove.stop.capture="touchmove">
- <img :src="item.src">
- </li>
- </ul>
- </template>
- <script>
- export default {
- props: {
- // pages 数据包含基础的图片数据
- pages: {
- type: Array,
- default: []
- }
- },
- data () {
- return {
- // basicdata 数据包含组件基本数据
- basicdata: {
- start: {}, // 记录起始位置
- end: {}, // 记录终点位置
- currentPage: 0 // 默认首图的序列
- },
- // temporaryData 数据包含组件临时数据
- temporaryData: {
- poswidth: '', // 记录位移
- posheight: '', // 记录位移
- tracking: false, // 是否在滑动, 防止多次操作, 影响体验
- animation: false, // 首图是否启用动画效果, 默认为否
- opacity: 1 // 记录首图透明度
- }
- }
- },
- 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
- }
- // 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.tracking = true
- this.temporaryData.animation = false
- },
- touchmove (e) {
- // 记录滑动位置
- if (this.temporaryData.tracking && !this.temporaryData.animation) {
- if (e.type === 'touchmove') {
- this.basicdata.end.x = e.targetTouches[0].clientX
- this.basicdata.end.y = e.targetTouches[0].clientY
- } else {
- 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
- }
- },
- touchend (e) {
- this.temporaryData.tracking = false
- this.temporaryData.animation = true
- // 滑动结束, 触发判断
- // 简单判断滑动宽度超出 100 像素时触发滑出
- if (Math.abs(this.temporaryData.poswidth)>= 100) {
- // 最终位移简单设定为 x 轴 200 像素的偏移
- 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
- // 不满足条件则滑入
- } else {
- this.temporaryData.poswidth = 0
- this.temporaryData.posheight = 0
- }
- },
- // 非首页样式切换
- transform (index) {
- if (index> this.basicdata.currentPage) {
- let style = {}
- let visible = 3
- let perIndex = index - this.basicdata.currentPage
- // visible 可见数量前滑块的样式
- if (index <= this.basicdata.currentPage + visible - 1) {
- style['opacity'] = '1'
- style['transform'] = 'translate3D(0,0,' + -1 * perIndex * 60 + 'px' + ')'
- style['zIndex'] = visible - index + this.basicdata.currentPage
- style['transitionTimingFunction'] = 'ease'
- style['transitionDuration'] = 300 + 'ms'
- } else {
- style['zIndex'] = '-1'
- style['transform'] = 'translate3D(0,0,' + -1 * visible * 60 + 'px' + ')'
- }
- return style
- }
- },
- // 首页样式切换
- transformIndex (index) {
- // 处理 3D 效果
- if (index === this.basicdata.currentPage) {
- let style = {}
- style['transform'] = 'translate3D(' + this.temporaryData.poswidth + 'px' + ',' + this.temporaryData.posheight + 'px' + ',0px)'
- style['opacity'] = this.temporaryData.opacity
- style['zIndex'] = 10
- if (this.temporaryData.animation) {
- style['transitionTimingFunction'] = 'ease'
- style['transitionDuration'] = 300 + 'ms'
- }
- return style
- }
- }
- }
- }
- </script>
4. 滑出后下一张图片堆叠到顶部
重新堆叠是组件最后一个功能, 同时也是最重要和复杂的功能在我们的代码里, stack-item 的排序依赖绑定: style 的 transformIndex 和 transform 函数, 函数里判定的条件是 currentPage, 那是不是改变 currentPage, 让其 + 1, 即可完成重新堆叠呢?
答案没有那么简单, 因为我们滑出是动画效果, 会进行 300ms 的时间, 而 currentPage 变化引起的重排, 会立即变化, 打断动画的进行因此我们需要先修改 transform 函数的排序条件, 后改变 currentPage
#### 具体实现
修改 transform 函数排序条件
让 currentPage+1
添加 onTransitionEnd 事件, 在滑出结束后, 重新放置 stack 列表中
代码如下:
- <template>
- <ul class="stack">
- <li class="stack-item" v-for="(item, index) in pages"
- :style="[transformIndex(index),transform(index)]"
- @touchmove.stop.capture="touchmove"
- @touchstart.stop.capture="touchstart"
- @touchend.stop.capture="touchend"
- @mousedown.stop.capture="touchstart"
- @mouseup.stop.capture="touchend"
- @mousemove.stop.capture="touchmove"
- @webkit-transition-end="onTransitionEnd"
- @transitionend="onTransitionEnd">
- <img :src="item.src">
- </li>
- </ul>
- </template>
- <script>
- export default {
- props: {
- // pages 数据包含基础的图片数据
- pages: {
- type: Array,
- default: []
- }
- },
- data () {
- return {
- // basicdata 数据包含组件基本数据
- basicdata: {
- start: {}, // 记录起始位置
- end: {}, // 记录终点位置
- currentPage: 0 // 默认首图的序列
- },
- // temporaryData 数据包含组件临时数据
- temporaryData: {
- poswidth: '', // 记录位移
- posheight: '', // 记录位移
- lastPosWidth: '', // 记录上次最终位移
- lastPosHeight: '', // 记录上次最终位移
- tracking: false, // 是否在滑动, 防止多次操作, 影响体验
- animation: false, // 首图是否启用动画效果, 默认为否
- opacity: 1, // 记录首图透明度
- swipe: false // onTransition 判定条件
- }
- }
- },
- 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
- }
- // 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.tracking = true
- this.temporaryData.animation = false
- },
- touchmove (e) {
- // 记录滑动位置
- if (this.temporaryData.tracking && !this.temporaryData.animation) {
- if (e.type === 'touchmove') {
- this.basicdata.end.x = e.targetTouches[0].clientX
- this.basicdata.end.y = e.targetTouches[0].clientY
- } else {
- 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
- }
- },
- touchend (e) {
- this.temporaryData.tracking = false
- this.temporaryData.animation = true
- // 滑动结束, 触发判断
- // 简单判断滑动宽度超出 100 像素时触发滑出
- if (Math.abs(this.temporaryData.poswidth)>= 100) {
- // 最终位移简单设定为 x 轴 200 像素的偏移
- 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.temporaryData.lastPosWidth = this.temporaryData.poswidth
- this.temporaryData.lastPosHeight = this.temporaryData.posheight
- // currentPage+1 引发排序变化
- this.basicdata.currentPage += 1
- // currentPage 切换, 整体 dom 进行变化, 把第一层滑动置零
- this.$nextTick(() => {
- this.temporaryData.poswidth = 0
- this.temporaryData.posheight = 0
- this.temporaryData.opacity = 1
- })
- // 不满足条件则滑入
- } else {
- this.temporaryData.poswidth = 0
- this.temporaryData.posheight = 0
- this.temporaryData.swipe = false
- }
- },
- onTransitionEnd (index) {
- // dom 发生变化后, 正在执行的动画滑动序列已经变为上一层
- if (this.temporaryData.swipe && index === this.basicdata.currentPage - 1) {
- this.temporaryData.animation = true
- this.temporaryData.lastPosWidth = 0
- this.temporaryData.lastPosHeight = 0
- this.temporaryData.swipe = false
- }
- },
- // 非首页样式切换
- transform (index) {
- if (index> this.basicdata.currentPage) {
- let style = {}
- let visible = 3
- let perIndex = index - this.basicdata.currentPage
- // visible 可见数量前滑块的样式
- if (index <= this.basicdata.currentPage + visible - 1) {
- style['opacity'] = '1'
- style['transform'] = 'translate3D(0,0,' + -1 * perIndex * 60 + 'px' + ')'
- style['zIndex'] = visible - index + this.basicdata.currentPage
- style['transitionTimingFunction'] = 'ease'
- style['transitionDuration'] = 300 + 'ms'
- } else {
- style['zIndex'] = '-1'
- style['transform'] = 'translate3D(0,0,' + -1 * visible * 60 + 'px' + ')'
- }
- return style
- // 已滑动模块释放后
- } else if (index === this.basicdata.currentPage - 1) {
- let style = {}
- // 继续执行动画
- style['transform'] = 'translate3D(' + this.temporaryData.lastPosWidth + 'px' + ',' + this.temporaryData.lastPosHeight + 'px' + ',0px)'
- style['opacity'] = '0'
- style['zIndex'] = '-1'
- style['transitionTimingFunction'] = 'ease'
- style['transitionDuration'] = 300 + 'ms'
- return style
- }
- },
- // 首页样式切换
- transformIndex (index) {
- // 处理 3D 效果
- if (index === this.basicdata.currentPage) {
- let style = {}
- style['transform'] = 'translate3D(' + this.temporaryData.poswidth + 'px' + ',' + this.temporaryData.posheight + 'px' + ',0px)'
- style['opacity'] = this.temporaryData.opacity
- style['zIndex'] = 10
- if (this.temporaryData.animation) {
- style['transitionTimingFunction'] = 'ease'
- style['transitionDuration'] = 300 + 'ms'
- }
- return style
- }
- }
- }
- }
- </script>
ok~ 完成了上面的四步, 堆叠组件的基本功能就已经实现, 快来看看效果吧
堆叠滑动效果已经出来了, 但是探探在体验上, 还增加了触碰角度偏移, 以及判定滑出面积比例
角度偏移的原理, 是在用户每次进行 touch 时, 记录用户触碰位置, 计算出最大的偏移角度, 在滑动出现位移时, 线性增加角度以至最大的偏移角度
使用在 stack 中具体要做的是:
touchmove 中计算出所需角度和方向
touchend 及 onTransitionEnd 中将角度至零
判定滑出面积比例, 主要通过偏移量计算出偏移面积, 从而得到面积比例, 完成判断
完整的代码和 demo 可以在 github 上查看源码, 这里就不贴出来了
谢谢大家看完这篇文章, 喜欢可以在 github 上给个 , 最后祝大家在探探上都能找到前女友
来源: http://www.jqhtml.com/13235.html