在移动设备上,滚动一个视图不会立即停止滚动,往往需要再滑动一小段距离然后再停止,模拟出惯性的效果。滑动的时候速度越快,那么就滚动的越远。一般组件都会帮开发者写好这些基本功能,不需要开发者操心。但有的时候我们需要使用类似的逻辑,比如我需要在手指滑动后,通过一些列序列帧变化来显示动画,那么这时候就可能需要开发者自己来写这个惯性滑动的逻辑了。不管怎样,我们用Rx来实现一遍这个惯性滑动,也是一种不错的体验。
涉及操作符
- scan
- switchMapTo
- switchMap
- mapTo
- takeUntil
- takeWhile
- filter
基本事件流
我们需要三个基本的事件流,分别是鼠标(手指)按下、移动、抬起。不同环境可能创建的方式不同,但性质是相同的,下面是伪代码
let mdOb = fromEvent(...,MOUSE_DOWN)
let mmOb = fromEvent(...,MOUSE_MOVE)
let muOb = fromEvent(...,MOUSE_UP)
这些事件流触发的规律是,由一个MOUSE_DOWN事件,一连串的MOUSE_MOVE事件,和一个MOUSE_UP事件组成
MOUSE_DOWN MOUSE_MOVE MOUSE_MOVE...... MOUSE_UP
(*)-------------(o)--------------(o)......-------------|>
接下来我们就要从这3个Observable来组合出合适的逻辑,来实现惯性滑动效果。
手势移动的偏移量和实时速度
我们需要取得手指或者鼠标按下后移动的距离来确定每时每刻的速度,因为我们需要在手指或鼠标抬起的瞬间利用这个速度进行惯性移动
let speedOb = mdOb.pipe(switchMapTo(mmOb.pipe(takeUntil(muOb), scan((aac, v) => {
let { stageY, nativeEvent: { timeStamp } } = v
if (aac.nativeEvent) aac = { stageY: aac.stageY, timeStamp: aac.nativeEvent.timeStamp }
aac.delta = stageY - aac.stageY
aac.lastTs = aac.timeStamp
aac.stageY = stageY
aac.timeStamp = timeStamp
return aac
}))));
其中
mdOb.pipe(switchMapTo(mmOb.pipe(takeUntil(muOb),......
这一段逻辑是非常常用的固定的搭配,表示我们需要获取手指按下到手指抬起之间的所有移动事件。
所以本段逻辑只有一个关键操作符scan
。使用这个操作符的目的是,为了取得上次计算的结果,因为我们需要比较前一个事件和这个事件的手指或鼠标的Y坐标变化。
下面我们来逐句分析其逻辑
let { stageY, nativeEvent: { timeStamp } } = v
这句话是js的解构赋值,我们获取了移动事件数据中的手指Y坐标,和此时的时间戳,当然在不同场合下,可能数据对象不同,我们可以自己获取一个时间戳也是没有问题的比如:
let { stageY } = v
let timeStamp = new Date()
第二行
if (aac.nativeEvent) aac = { stageY: aac.stageY, timeStamp: aac.nativeEvent.timeStamp }
判断aac.nativeEvent
的目的是,判断我们是否已经接收过移动事件了,如果已经接收过了,我们就用之前数据创建一个新的aac对象,为什么要创建一个新的对象呢,因为原来的对象会被复用,出现脏数据。
第三行,根据前一次的y坐标(aac.stageY
)和当前的y坐标stageY
计算出差值,就是本次移动的距离。
aac.delta = stageY - aac.stageY
第四行,我们把上一次的时间戳存放起来,这个是给后面的逻辑使用的。
aac.lastTs = aac.timeStamp
第五、六两行,是把本次的y坐标和时间戳存起来,作为下一次计算时使用的数据
aac.stageY = stageY
aac.timeStamp = timeStamp
scan
操作符会在每次都传入aac(累加结果),v(当前事件对象)两个参数,我们利用aac来存放上一次的数据。
此外scan
操作符和reduce
十分相似,只是后者的结果会在事件流结束的时候传出,而scan会每次把结果输出。
计算惯性偏移,阻尼运动
我们有了speedOb这个事件流,就可以用来模拟手指抬起的时候惯性移动效果了。
let inertiaOb = rxjs.combineLatest(muOb, speedOb).pipe(switchMap(([, { delta, lastTs, timeStamp }]) => rxjs.interval(20).pipe(mapTo({ delta: delta * 10 / (timeStamp - lastTs) }), takeWhile(_ => {
_.delta *= 0.9
return _.delta > 0.1 || _.delta < -0.1
}))));
我们来分析上面的逻辑
rxjs.combineLatest(muOb, speedOb)
上面这句话可以让我们得到当鼠标或手指抬起的时候,speedOb事件流里面最新的数据,我们用这个数据作为用户滑动的速度,然后做一个逐渐减速的过程。
switchMap就是上述行为发生的时候,我们开始监听switchMap传入的函数所返回出来的那个事件流。
这个事件流是
rxjs.interval(20).pipe(mapTo({ delta: delta * 10 / (timeStamp - lastTs) }), takeWhile(_ => {
此时会每个20毫秒产生一个事件,这个事件被转换成了一个对象,其中delta: delta * 10 / (timeStamp - lastTs),这是一个距离除以时间的公式,得到的是速度即v=s/t
这个对象中的delta从一个距离转变成了速度值。
_.delta *= 0.9
return _.delta > 0.1 || _.delta < -0.1
这里的速度将逐渐减少,如果速度值低于某个范围,则终止事件流(takeWhile的行为),但由于我们终止只是switchMap内部的事件流,并不会终止外层的事件流,所以只要用户继续按下手指滑动,逻辑又会再次启动。
执行滑动操作
本例是改变序列帧的索引,也可以用其他逻辑代替
return rxjs.merge(speedOb, inertiaOb).pipe(filter(_ => _.delta != 0), scan((aac, v) => {
aac += (v.delta / speed >> 0);
if (aac < 0) aac = 0;
else if (aac > totalFrames) aac = totalFrames
return aac
}, initFrame))
此时我们用到了之前创建的两个事件流,并且merge
了他们。因为当用户按住屏幕移动的时候,内容也要跟着改变,放开手指或鼠标的时候会接着改变一小段时间,所以两个事件流的事件合并来处理。我们过滤了不需要改变内容的事件,就是当速度为0的时候。
此时再次出现scan
操作符。
这里很多逻辑是和具体业务有关,这里仅供参考,aac存放是此时的序列帧的索引,速度越快那么索引向后累加的就越快,动画就越快的播放,反之则播放的慢。其中speed和initFrame是传入的常数,用来调整姿势。
这个事件流将流出你需要的数据,最后进行subscribe即可