需求分析:
- 主要有两点,滚动,以及可视范围的判断
- 滚动即提供一个组件,接收两个参数from与to,组件实现从数字from变为数字to,变化过程为上下滚动
- 可视范围的判断即,当组件位于可视范围时,才开始滚动
- 其余实现要求包括,当组件渲染后,数字to的变化,会触发下一次滚动,用vue实现
- 效果如下图
实现方案:
- 首先拆解需求,999可以视为3个9组成的数字,所以一串数字的滚动,其实相当于一组单个数字滚动的组合,故首先实现单个数字滚动的效果,再组装成组件就能实现需求的效果
- 单个数字的滚动,有点类似于机械时钟的翻页,其实现为在现有展示数字的背面,存在别的数字,在适当时机,机械会控制翻转到新的数字,旧的数字被掩盖在新的数字下方
- 所以单个数字滚动,也可以用类似的方法,即准备从0到9十个数字,纵向排列,但可视范围只有一个数字的大小,称其为窗口,这十个数字都根据窗口定位,在数字变化的时候,改变其定位,并添加动画效果,就可以实现滚动的效果
样式
- 根据上面的方案,我们可以写出下面的样式布局
/* html */
<div class="_single-digi-scroller">
<div class="placeholder" ref="myPlaceHolder">0</div>
<div class="display-panel" ref="myDigiPanel">
<div class="num">0</div>
<div class="num">1</div>
<div class="num">2</div>
<div class="num">3</div>
<div class="num">4</div>
<div class="num">5</div>
<div class="num">6</div>
<div class="num">7</div>
<div class="num">8</div>
<div class="num">9</div>
</div>
</div>
/* style */
._single-digi-scroller {
display: inline-block;
position: relative;
overflow: hidden;
.placeholder {
opacity: 0;
}
.display-panel {
position: absolute;
}
}
- 这里留意
display: inline-block;
是为了使多个该组件组合时可以并排展示而不换行,overflow: hidden;
则是保证可视范围,即上文提及的窗口,只有一个数字的大小 - 另外值得注意的是,由于滚动区域
display-panel
是绝对定位的,这会导致其父元素高度坍塌而使得高度为0,所以html中添加了一个透明的placeholder
,其作用是用来撑开组件高度的,这样我们就无需给组件另外设置高度,而让其与父元素的文本高度一致 - 那么现在基本的单数字滚动组件结构就写好了,接下来给其添加滚动逻辑
滚动逻辑
- 根据上面的样式,我们只需要改变
display-panel
的top属性,就可以改变其相对于窗口的位置,再加上transition
的css样式,就会有动画效果,实现滚动 - 首先我们需要知道数字从多少滚到多少,所以增加两个props,from与to,然后根据from与to计算top值,具体代码如下:
props: {
from: {
type: [Number, String],
default: 0
},
to: {
type: [Number, String],
default: 0
},
height: {
type: [Number, String],
default: 0
},
speed: {
type: [Number, String],
default: 2
}
},
data: () => ({
toPos: false,
fromPos: false,
transitionStyle: {}
}),
watch: {
changeInfo: {
immediate: true,
handler(val) {
if (val) {
this.fromPos = { top: `-${val.from * this.height}px` };
setTimeout(() => {
// 不用nexttick是因为其间隔太小,浏览器未渲染就改变了pos,导致动画没生效
this.toPos = { top: `-${val.to * this.height}px` };
this.transitionStyle = { transition: `${this.speed}s` };
}, 200);
}
}
}
},
computed: {
numStyle() {
return {
height: `${this.height}px`,
lineHeight: `${this.height}px`
};
},
panelStyle() {
if (this.toPos) return { ...this.toPos, ...this.transitionStyle };
if (this.fromPos) return { ...this.fromPos, ...this.transitionStyle };
return {};
},
changeInfo() {
if ((this.from || this.from === 0) && (this.to || this.to === 0)) {
return { to: this.to, from: this.from };
}
return false;
}
}
- 这里可以看到我们新增了一个
changeInfo
的computed属性,这是因为我们并不知道from和to哪个先接收到,所以为了保证计算top属性时,from和to都已经得到了,添加的这个属性 - 然后
changeInfo
数值变化的时候,会触发watch的逻辑,首先根据from计算top属性,然后通过setTimeout
回调计算to对应的top值,并增加动画样式 - top值的计算很简单,默认一开始窗口展示的是0,这时top也是0,然后如果是别的数字,只需要用该数字乘以height然后取反即可,height即窗口高度,也即每个数字的高度,如3和0,相差3个数字,向上移三个数字窗口的高度就会显示3,也即
top = -3 * height
- 上述计算值最终汇总到
panelStyle
这个computed属性中,如果有to对应的值则先取to的,没有则取from的,由于第一次to的计算会晚于from,所以第一次必然先取的from对应的值,后续则都取的to对应的值,最后把panelStyle
赋给display-panel
,则滚动逻辑就完成了 - 注意这里用到的
this.height
,这个值可以通过计算placeholder
的clientHeight直接获得,但由于我们后续还要将多个单数字滚动组装在一起,如果每个单数字滚动组件都计算一边clientHeight,必然会造成性能的浪费,而组装后的单数字高度必然是相等的,所以可以统一在组装好的组件中计算,然后作为props传给各个单数字滚动组件 - 同时为了保证
display-panel
中每个数字的高度都是一致的,我们计算了numStyle这个属性,并将其赋给display-panel
里的每个数字
<div class="display-panel" :style="panelStyle" ref="myDigiPanel">
<div class="num" :style="numStyle">0</div>
<div class="num" :style="numStyle">1</div>
...
</div>
组装
/* html */
<div class="_digi-scroller"
:style="{ height: `${height}px`, width: `${ changeInfo ? width * changeInfo.to.length : 0}px` }"
ref="myDigiScroller"
>
<div class="placeholder" ref="myPlaceHolder">0</div>
<div :style="{ left: `-${width}px` }" class="digi-zone" v-if="changeInfo">
<single-digi-scroller
class="single-digi"
v-for="(digi, index) in changeInfo.to"
:key="changeInfo.to.length - index"
:from="changeInfo.from[index]"
:to="digi"
:height="height"
/>
</div>
</div>
/* style */
._digi-scroller {
display: inline-flex;
align-items: center;
white-space: nowrap;
.placeholder {
display: inline-block;
opacity: 0;
}
.digi-zone {
position: relative;
display: inline-block;
}
}
-
single-digi-scroller
就是前面的单数字滚动组件,与前面类似,placeholder
也是用来撑起高度的,样式基本与单数字滚动组件类似,不同之处为display为inline-flex
,主要是为了行内垂直对齐,white-space: nowrap;
则保证不换行 - 另外可以看到
digi-zone
上有一个left的属性,这是因为digi-zone
是相对布局(这样才能撑起组件的宽度),而如前所说其左边有一个透明的占位符,故左移一个数字的位置将其覆盖,使digi-zone
位于整个组件的最开始位置 - 同时注意到这里给
single-digi-scroller
的key值为其index的倒序,这是由于vue会根据key值进行元素复用,而当数值位数增加或减少时,变化的位数为最高位,即假设从99变为100时,位数加一,新增的single-digi-scroller
排在最开头,而如果key值等于index,则被复用的元素为第一和第二位,视觉上就会呈现出从990变化为100的效果(如下图所示),而不是099变为100,所以为了保证复用元素为后两位,这里key值要取倒序
- 接下来一个个看每个属性的计算
- height与width可以通过直接计算
placeholder
的高度与宽度值得到,其为data中的属性,在组件首次渲染完的时候计算
mounted() {
const { clientHeight, clientWidth } = this.$refs.myPlaceHolder;
this.height = clientHeight;
this.width = clientWidth;
},
- width是单个数字的宽度,用于计算整个组件的长度,单数字滚动组件的个数由
changeInfo
决定,changeInfo由接收的参数from与to计算得来,具体代码如下
props: {
from: {
type: [Number, String],
default: 0
},
to: {
type: [Number, String],
default: 0
}
},
computed: {
changeInfo() {
if ((this.from || this.from === 0) && (this.to || this.to === 0)) {
// from 和 to 都接收到了之后
const len = Math.max(String(this.to).length, String(this.from).length);
// eslint-disable-next-line prefer-spread
const from = `${Array.apply(null, Array(len)).map(() => '0').join('')}${this.from}`.slice(-len);
// eslint-disable-next-line prefer-spread
const to = `${Array.apply(null, Array(len)).map(() => '0').join('')}${this.to}`.slice(-len);
return { from, to };
}
return false;
}
},
- 这里设置changeInfo的原因首先同前面一样,保证from和to都已经接收到,其次则是由于from和to位数不一定相同,所以需要计算最长的位数,然后较短的一方在其前面补零,保证changeInfo中from和to的位数是相同的
-
Array.apply(null, Array(len))
是用来生成一组长度为len,元素都为undefined的数组 - 这样处理完之后,我们将from和to切分成了单个数字,由于from和to处理完后位数相同,分别将对应位置的数字传入单数字滚动组件中,然后由单数字组件里的逻辑就可以开启滚动效果
- 到这里,数值的滚动效果就实现了,其由多个单数字滚动组件组装而成
可视范围判断
- 这里可以使用性能较好的IntersectionObserver进行实现,对于不支持该方法的系统,采用监听滚动的方法兜底
- 首先判断系统是否支持
IntersectionObserver
checkObserverSupport() {
return 'IntersectionObserver' in window
&& 'IntersectionObserverEntry' in window
&& 'intersectionRatio' in window.IntersectionObserverEntry.prototype;
}
- 新增一个
inView
的data属性,用来标记当前组件是否已抵达可视区域,以及listener
用来记录IntersectionObserver的监听实例,在页面销毁时进行清除,故目前data如下:
data: () => ({
height: 0,
width: 0,
inView: false,
listener: undefined,
scrollTimer: undefined // 滚动监听节流用
})
- 在mounted中添加
checkIntoView
方法,对于支持IntersectionObserver
方法的系统,通过实例化该方法来判断可视范围,当判断元素到达可视范围时,清除监听,标记inview
为true,然后开始计算changeInfo
,执行滚动
checkIntoView() {
if (this.checkObserverSupport()) {
// eslint-disable-next-line no-unused-expressions
this.listener && this.listener.disconnect();
this.listener = new IntersectionObserver(
entries => {
const { intersectionRatio } = entries[0];
if (intersectionRatio > 0) {
this.listener.disconnect();
console.log('intersection observer: digi scroller into view');
this.listener = undefined;
this.inView = true;
}
}
);
this.listener.observe(this.$refs.myDigiScroller);
} else if (this.checkRectBounding()) {
this.inView = true;
} else {
if (!this.scrollContainer) {
this.scrollListenContainer = window;
} else if (typeof this.scrollContainer === 'string') {
this.scrollListenContainer = document.querySelector(this.scrollContainer);
} else {
this.scrollListenContainer = this.scrollContainer;
}
this.scrollListenContainer.removeEventListener('scroll', this.checkIntoViewPollyfill);
this.scrollListenContainer.addEventListener('scroll', this.checkIntoViewPollyfill);
}
}
/* computed */
changeInfo() {
if ((this.from || this.from === 0)
&& (this.to || this.to === 0)
&& this.inView // 只有this.inView为true时才会开始计算
) {
...
}
}
- 对于不支持
IntersectionObserver
的系统,这里首先计算了checkRectBounding
,该方法用于元素位于页面的相对位置,从而判断其是否在可视范围内,代码如下,对于已经在可视范围里的,直接执行滚动即可,而还不在可视范围的则进行滚动监听
checkRectBounding() {
if (!this.$refs.myDigiScroller) return false;
const viewPortHeight = window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight;
const rect = this.$refs.myDigiScroller.getBoundingClientRect() || {};
const { top } = rect;
return +top <= viewPortHeight + 100; // 由于存在节流,这里判定范围扩大一点
}
- 回到
checkIntoView
,这里this.scrollContainer
是一个props属性,用户可以指定监听的容器,其初始值为空,默认监听window的滚动 -
checkIntoViewPollyfill
代码如下,为了避免重复监听,注册监听事件前,进行了remove操作,监听方法本质也是调用checkRectBounding,判断是否在可视范围,在可视范围的话,同上,对监听进行清除操作
checkIntoViewPollyfill() {
if (this.scrollTimer) return;
const isInView = this.checkRectBounding();
this.scrollTimer = setTimeout(() => { this.scrollTimer = undefined; }, 100); // 节流
if (isInView) {
console.log('scroll listener: digi scroller into view');
this.scrollListenContainer.removeEventListener('scroll', this.checkIntoViewPollyfill);
// eslint-disable-next-line no-unused-expressions
this.scrollTimer && clearTimeout(this.scrollTimer);
this.scrollTimer = undefined;
this.inView = true;
}
}
- 最后,在页面销毁时,清除所有监听以及计时器,避免对别的页面产生影响
beforeDestroy() {
if (this.listener) {
this.listener.disconnect();
this.listener = undefined;
}
if (this.scrollListenContainer) {
this.scrollListenContainer.removeEventListener('scroll', this.checkIntoViewPollyfill);
}
// eslint-disable-next-line no-unused-expressions
this.scrollTimer && clearTimeout(this.scrollTimer);
this.scrollTimer = undefined;
}
结语
- 这样,一个具有滚动监听能力的数值滚动组件就实现了,只有当组件位于可视范围内的时候,才执行滚动的动画,比一渲染便执行滚动的组件,用户体验会更好一些,其可以放置在页面的任何位置。
- 同时它也可以放置在文本段落里,因为其高度以及字体大小都由其父元素决定,外观上与纯文本是一致的。