vuejs实现spa页面组件滑动特效
写在前面的一些废话
其实通过vuejs非常容易实现spa中路由的过渡效果,网上也有不少教程。但是考虑一下以下需求
- 即将离开的组件和将要进入的组合同时出现在页面中
- 用手指拖动页面可以切换路由,而不仅仅是点击链接
- 结合以上两点,拖动过程中同时显示两个组件,手指离开屏幕后执行切换路由或者返回
好像也不是很简单
最近几个月天在仿DiDi应用写一个web app,写到“顺风车”组件的时候发现在其组件下的两个子组件:乘客组件以及车主组件,他们的切换方式正是满足上面3点要求。好几番尝试后终于写出了满足要求的代码,来和小伙伴分享~
我是正文
首先简单的过渡效果我就不多做介绍了。通过vue
提供的transition
组件,我们可以很容易的实现一个普通的动画效果。具体的可以参考vuejs官方文档。
Step 0
我们先大致了解一下html结构
<div class="content-wrapper">
<!-- router-view指向组件driver和passenger -->
<router-view class="content"></router-view>
</div>
Step 1
来实现第一个需求。
这个需求不难,分别给2个组件定义不同transition
动画即可。
对于左边的passenger组件,他是从左边进入/离开的,相应的代码为transform: translateX(-100%)
,同理对于右边的driver组件,相应代码为transform: translateX(100%)
。
特别注意的是,需要对router-view
添加一行css代码(写在.content
中)position: absolute
来使driver组件元素和passenger组件元素脱离文档流,否则达不到2个组件同时出现在页面中的效果。
效果如下:
上一个touch事件完成后,已滑动距离。实际在这个设计里,因为我们手指离开后,页面不会停留在中间,不是滑过去切换路由,就是滑回去恢复原样。所以currentDistance
并没有什么卵用,但是如果要即停即走,这个变量不可少。
Step 2
对于移动端,肯定少不了手指滑动效果。在左右滑动页面的时候,理应也能切换到对应的路由。滴滴app就是这样。如何滑动?实现思路就是以3个touch事件:touchstart
,touchmove
,touchend
是核心,配合transform: translate
来实现。额外要求:松手后判断滑动距离,达到一定距即进行路由的切换,否则页面“滑”回去。
如何让元素跟着你的指尖走?我们以driver
组件为例,先了解一下组件中各项数据
data() {
return {
touch: {}, // 保存着起始位置x1和变化的位置x2
touchStartTime: 0, // touch开始
touchEndTime: 0, // touch结束时间
currentDistance: 0 // 上一个touch事件完成后,已滑动距离。实际在这个设计里,因为我们手指离开后, 页面不会停留在中间,不是滑过去切换路由,就是滑回去恢复原样。所以这个变量并没有什么卵用,但是如果要*即停即走*,这个变量不可少。
totalDiff: 0 // 总滑动距离
}
},
首先,我们在监听元素的touchstart事件,在用户touch页面的时候记录下位置信息,因为我们是左右滑动,所以只关心x轴方向。回调函数如下
function touchStart(ev) {
let touch = ev.changedTouches[0]
this.touch.x1 = touch.pageX // 本文中所有this指向vue组件实例
// 这里是driver组件或是passenger
}
然后,也是重点,监听touchmove事件。
function touchMove(ev) {
let touch = ev.changedTouches[0]
this.touch.x2 = touch.pageX
let diff = this.touch.x2 - this.touch.x1 // 差值,表示手指移动的距离
this.totalDiff = diff + this.currentDistance // 总差值,表示手指移动的距离,正表示右滑,负左滑
if (this.totalDiff < 0) { // driver组件是右滑,所以totalDiff不能小于0
this.totalDiff = 0
} else if (this.totalDiff > this.maxMoveDistance) { // 这里maxMoveDistance为屏幕宽度
this.totalDiff = this.maxMoveDistance
}
let el = ev.currentTarget
translate(el, this.totalDiff, 0) // 对组件进行滑动
translate(this.leftEl, this.totalDiff, 0) // leftEl后面再做解释
}
关于translate函数,具体实现如下:
/**
* 简单的移动函数
* @param {HTML Object} el 目标节点
* @param {number} x 水平方向的移动
* @param {number} y 垂直方向的移动
* @param {Object} options 可选参数
* @param {Boolean} options.useTransfrom 是否通过transfrom来移动元素
* @param {Boolean} options.transitionTimingFunction transition的timingFunction
* @param {String} options.transitionDuration transition时间
*/
function translate(el, x, y, options) {
const defaultOptions = {
useTransfrom: true,
transitionTimingFunction: 'cubic-bezier(0.165, 0.84, 0.44, 1)',
transitionDuration: '0s'
}
for (let option in options) {
defaultOptions[option] = options[option]
}
if (defaultOptions.useTransfrom) {
el.style.transform = `translate3d(${x}px,${y}px,0)`
el.style.transitionProperty = 'transform'
el.style.transitionTimingFunction = defaultOptions.transitionTimingFunction
el.style.transitionDuration = defaultOptions.transitionDuration
} else {
el.style.left = x
el.style.top = y
}
}
接下来就是touchend
事件
function touchEnd(ev) {
let touch = ev.changedTouches[0]
this.touch.x2 = touch.pageX
let diff = this.touch.x2 - this.touch.x1
this.touchEndTime = Date.now()
this.totalDiff = diff + this.currentDistance
this.currentDistance = this.totalDiff
let el = ev.currentTarget
let touchTime = this.touchEndTime - this.touchStartTime
// 当滑动距离超过一半或者快速滑动一段距离时,就进行完整的滑动,否则回弹
// 快速滑动的数据是自己尝试的,体验可能不是很好^ ^
if (this.totalDiff > this.maxMoveDistance / 2 || (touchTime < 150 && this.totalDiff > this.maxMoveDistance / 10)) {
translate(el, this.maxMoveDistance, 0, {
transitionTimingFunction: 'linear',
transitionDuration: '.1s'
})
translate(this.leftEl, this.maxMoveDistance, 0, {
transitionTimingFunction: 'linear',
transitionDuration: '.1s'
})
this.$emit('dragedSlide') // 通知父组件进行路由切换
} else {
this.totalDiff = this.currentDistance = 0
translate(el, this.totalDiff, 0)
translate(this.leftEl, this.totalDiff, 0)
}
}
效果图:
Step 3
在拖动时,左右都是“白边”,难看。怎么处理?也不难。
我们都写过或者了解过轮播图,其中一种写法就是在第一张图(元素)的左边插入最后一张图(元素),最后一张图的右边插入第一张。在拖动当前元素时,同时拖动其左/右的元素(也就是Step 2中代码里的leftEl
,当然还有righEl),来达到我们要的效果。所以现在我们的html结构是这样的:
<div class="content-wrapper">
<passenger class="out_of_screen out_of_screen-left"/>
<!-- router-view指向组件driver和passenger -->
<router-view class="content"/>
<driver class="out_of_screen out_of_screen-right"/>
</div>
到这里好像就大功告成了?看一下效果:
等等,是你撸多了吗?怎么有两个动画?
其实是因为路由切换后会自动触发组件本身的transition,再加上自己写的translate(this.leftEl, ...)
,就有2个啦。
知道原因就很好处理了,去掉其中一个动画即可。我的选择是去掉组件本身的过渡效果。
具体做法就是给组件动态绑定transition
的name
属性,来选择性的使组件开启/关闭过渡效果。同时对左右插入的元素监听其transitionend
事件,配合上父组件的dragedSlide
事件,来实现动态过渡,上代码
<div class="content-wrapper">
<passenger class="out_of_screen out_of_screen-left" @transitionend.native="updateRouter($event, 'passenger')/>
<!-- router-view指向组件driver和passenger -->
<router-view class="content"
@dragedSlide="confirmDragSlide"
:transitionName="transitionName"
/>
<driver class="out_of_screen out_of_screen-right" @transitionend.native="updateRouter($event, 'passenger')/>
</div>
父组件js部分
export default {
beforeRouteUpdate(to, from, next) {
this.transitionName = this.isDragedSlide ? '' : 'slide'
next()
},
data() {
return {
transitionName: 'slide',
isDragedSlide: false
}
},
methods: {
// ...
// 是否通过手指拖动触发滑屏
confirmDragSlide() {
this.isDragedSlide = true
},
updateRouter(ev, routeName) {
if (this.isDragedSlide) {
let el = ev.target
this.$router.push(routeName)
el.style.transform = ''
el.style.transitionDuration = '0s'
this.isDragedSlide = false
}
}
}
}
最终效果
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。