Vue组件 - 平滑滚动面板

概述

这个组件的效果类似于 fullPage.js,通过鼠标滚轮或者上下滑动手势进行换页,每一页均是全屏的,在换页过程中带有过渡效果。

实现

1
2
3
4
5
6
7
8
9
10
<div class="scroll-pane" v-scroll="handleScroll" v-touch:up="nextPage" v-touch:down="prevPage">
<div class="scroll-pane__wrapper" :style="translateY">
<div class="scroll-pane__item" v-for="(page, k) in pages" :key="k">
<template v-if="typeof page === 'string'">{{ page }}</template>
<template v-else>
<component :is="page"></component>
</template>
</div>
</div>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
import scroll from '@/directives/scroll'
import { instantDebounce } from '@/utils.js'

export default {
directives: {
scroll
},
data () {
return {
index: 0
}
},
computed: {
translateY () {
return `transform: translateY(-${this.index}00%)`
}
},
methods: {
nextPage () {
const count = this.count || (this.pages && this.pages.length) || 0
if (!count) {
return
}
if (this.index < count - 1) {
this.index++
} else {
this.$emit('onTop')
}
},
prevPage () {
const count = this.count || (this.pages && this.pages.length) || 0
if (!count) {
return
}
if (this.index > 0) {
this.index--
} else {
this.$emit('onBottom')
}
}
},
props: {
count: {
type: Number
},
pages: {
type: Array
}
},
created () {
this.handleScroll = instantDebounce((e) => {
if (e === 'down') {
this.nextPage()
} else if (e === 'up') {
this.prevPage()
}
}, 1000)
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.scroll-pane {
height: 100%;
overflow: hidden;

&__wrapper {
display: flex;
height: 100%;
flex-direction: column;
transition: transform 0.3s ease-in-out;
}

&__item {
flex-shrink: 0;
height: 100%;
}
}

解析

结构

HTML结构主要由三个部分组成,scroll-pane 是外框架,他是全屏的,并且不显示范围外的内容

scroll-pane__wrapper 是页面容器,他和 scroll-pane 一样是全屏的,并且本身是一个 flex 容器,因为要实现的是垂直方向的换页,所以flex的方向是column。

scroll-pane__item 是页面元素,也需要要全屏显示。

关键的来了,由于wrapper没有设置overflow: hidden,所以wrapper之中的内容是可以超过wrapper本身高度的,默认情况下flex布局的项目会自己进行缩放,然后向顶部看齐,所以需要flex-shrink: 0限制项目的大小,让项目在位置不够时也不缩小

wrapper 虽然是全屏的,但是其中的内容(item)早就已经超出他的范围了,这个时候根元素的overflow: hidden就能隐藏超出范围的部分,相当于一张长纸条,用一个框把其中一块展示出来,框外的不显示。

既然item是平铺在 wrapper 中的,那么显示哪一个 item 的内容就可以使用transform进行调整了,translateY负责将wrapper的内容进行Y方向的偏移,移出一个屏幕(100%),就能看到下一个页面的内容。

事件

对于上下切换的操作,在PC端可以监听mousewheel事件,但是滚轮的操作是很频繁的,这样会导致页面在短时间内被多次切换,所以这里需要使用去抖函数,对短期内的频繁操作做限制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export const instantDebounce = (fn, t) => {
const delay = t || 500
let timer
return function () {
const args = arguments
if (timer) {
clearTimeout(timer)
}
if (!timer) {
fn.apply(this, args)
}
timer = setTimeout(() => {
timer = null
}, delay)
}
}

这个函数主要是通过闭包实现的,timer 储存了先前得到的 timeout id。
在第一次使用时 timer 为空,先执行callback,然后给 timer 一个 id,第二次执行的时候由于闭包的特性,保留了 timer 的状态,这个时候 timer 不为空,这时候有两种情况:

  1. 第二次操作在 delay 范围内,那么很明显,第二次属于“频繁操作”,所以会执行clearTimeout将先前的timer操作取消。
  2. 第二次操作在 delay 范围外,这个操作是“合法”的,那么在第二次操作执行前,第一次的setTimeout函数已经执行了,此时timer为空,回到了前文“第一次使用”的情况。

简单来说就是如果前后操作的时间间隔小于 delay,那么只会重复创建 setTimeout 任务并且清除任务,大于 delay 时才会触发callback。

对于移动端,需要监听touchstart等事件,根据起始点和结束点的位置,判断用户的手势,这里使用了 vue-directive-touch 实现,这个操作不需要去抖,因为手势很难在短时间内多次触发。

可用性

为了使组件能容纳更多类型的“内容”,不能简单使用slot实现,因为页面的数量是不确定的,不能提供不定量的slot元素。

将内容全放在default slot中,那么$slots.default的值为列表,无法将每一个列表元素单独使用scroll-page__item包裹(slot带来的元素会堆在一起)。

所以我选择了props作为页面元素的容器,在pages中能存放现有组件,并且能将组件通过component元素渲染出来。