BetterScroll源码阅读顺便学习TypeScript(下)
滚动逻辑
以上仍只是绑定了事件,还没到滚动那一步,接下来看ScrollerActions
,构造函数里调用了bindActionsHandler
方法,这个方法里监听了刚才actionsHandler
里绑定的那些事件:
private bindActionsHandler() {
// [mouse|touch]触摸开始事件
this.actionsHandler.hooks.on(
this.actionsHandler.hooks.eventTypes.start,
(e: TouchEvent) => {
if (!this.enabled) return true
return this.handleStart(e)
}
)
// [mouse|touch]触摸中事件
this.actionsHandler.hooks.on(
this.actionsHandler.hooks.eventTypes.move,
({ deltaX, deltaY, e}) => {
if (!this.enabled) return true
return this.handleMove(deltaX, deltaY, e)
}
)
// [mouse|touch]触摸结束事件
this.actionsHandler.hooks.on(
this.actionsHandler.hooks.eventTypes.end,
(e: TouchEvent) => {
if (!this.enabled) return true
return this.handleEnd(e)
}
)
}
接下来是上面三个事件对应的处理函数:
private handleStart(e: TouchEvent) {
// 获取触摸开始的时间戳
const timestamp = getNow()
this.moved = false
this.startTime = timestamp
// directionLockAction主要是用来做方向锁定的,比如判断某次滑动时应该进行水平滚动还是垂直滚动等,reset方法是复位锁定的方向变量
this.directionLockAction.reset()
// start方法同样也是做一些初始化或复位工作,包括滑动的距离、滑动方向
this.scrollBehaviorX.start()
this.scrollBehaviorY.start()
// 强制结束上次滚动
this.animater.doStop()
// 复位滚动开始的位置
this.scrollBehaviorX.resetStartPos()
this.scrollBehaviorY.resetStartPos()
}
这个方法主要是做一系列的复位工作,毕竟是开启一次新的滚动。
private handleMove(deltaX: number, deltaY: number, e: TouchEvent) {
// deltaX和deltaY记录的是move事件每次触发时和上一次的差值,getAbsDist方法是用来记录当前和触摸开始的绝对距离
const absDistX = this.scrollBehaviorX.getAbsDist(deltaX)
const absDistY = this.scrollBehaviorY.getAbsDist(deltaY)
const timestamp = getNow()
// 要么滑动距离大于阈值,要么在上次滑动结束后又立即滑动,否则不认为要进行滚动
/**/
private checkMomentum(absDistX: number, absDistY: number, timestamp: number) {
return (
timestamp - this.endTime > this.options.momentumLimitTime &&
absDistY < this.options.momentumLimitDistance &&
absDistX < this.options.momentumLimitDistance
)
}
/**/
if (this.checkMomentum(absDistX, absDistY, timestamp)) {
return true
}
// 这里用来根据eventPassthrough配置项来判断是否要进行锁定,保留原生滚动
// 如果本次检测到你是进行水平滚动,那么水平方向上会进行锁定,如果你这个配置设置的也是horizontal,这个方法会返回true,就相当于这次不进行模拟滚动而直接使用原生滚动,如果你传的是vertical,就会调用e.preventDefault()来阻止原生滚动
if (this.directionLockAction.checkMovingDirection(absDistX, absDistY, e)) {
this.actionsHandler.setInitiated()
return true
}
// 这个方法会把锁定的那个方向的另外一个方向的delta值设为0,即另外那个方向不进行滚动
const delta = this.directionLockAction.adjustDelta(deltaX, deltaY)
// move方法做了两件事,1是设置本次滑动的方向值,把右->左、下->上作为正向1,反之作为负向-1;2是调用阻尼方法,这个阻尼是啥意思呢,就是没到边界的话滑动的时候你能感觉到页面是跟你的手指同步滑动的,阻尼之后你就会感觉到有阻力,页面滑动变慢跟不上你的手指了:
/**/
performDampingAlgorithm(delta: number, dampingFactor: number) {
// 滑动开始的位置加上本次滑动偏移量即当前滑动到的位置
let newPos = this.currentPos + delta
// 已经滑动到了边界
if (newPos > this.minScrollPos || newPos < this.maxScrollPos) {
if (
(newPos > this.minScrollPos && this.options.bounces[0]) ||
(newPos < this.maxScrollPos && this.options.bounces[1])
) {
// 阻尼原理很简单,将本次滑动的距离乘一个小于1的小数就可以了
newPos = this.currentPos + delta * dampingFactor
} else {
// 如果配置关闭了阻尼效果,那么本次滑动就到头了,滑不动了
newPos =
newPos > this.minScrollPos ? this.minScrollPos : this.maxScrollPos
}
}
return newPos
}
/**/
const newX = this.scrollBehaviorX.move(delta.deltaX)
const newY = this.scrollBehaviorY.move(delta.deltaY)
// 无论是使用css3 transition还是requestAnimationFrame做动画,实际上改变的都是css的transform属性的值,这里的translate最终调用的是上述this.translater实例的translate方法
/**/
//point:{x:10,y:10}
translate(point: TranslaterPoint) {
let transformStyle = [] as string[]
Object.keys(point).forEach((key) => {
if (!translaterMetaData[key]) {
return
}
// translateX/translateY
const transformFnName = translaterMetaData[key][0]
if (transformFnName) {
// px
const transformFnArgUnit = translaterMetaData[key][1]
// x,y的值
const transformFnArg = point[key]
transformStyle.push(
`${transformFnName}(${transformFnArg}${transformFnArgUnit})`
)
}
})
this.hooks.trigger(
this.hooks.eventTypes.beforeTranslate,
transformStyle,
point
)
// 赋值
this.style[style.transform as any] = transformStyle.join(' ')
this.hooks.trigger(this.hooks.eventTypes.translate, point)
}
/**/
// 可以看到直接调用这个方法是没有设置transition的值或是使用requestAnimationFrame来改变位移,所以是没有动画的,到这里content元素就已经会跟着你的触摸进行滚动了
this.animater.translate({
x: newX,
y: newY
})
// 这个方法主要是用来重置startTime的值以及根据probeType配置来判断如何派发scroll事件
/**/
private dispatchScroll(timestamp: number) {
// 每momentumLimitTime时间派发一次事件
if (timestamp - this.startTime > this.options.momentumLimitTime) {
// 刷新起始时间和位置,这个用来判断是否要进行momentum动画
this.startTime = timestamp
// updateStartPos会将元素当前滚动到的新位置作为起始位置startPos
this.scrollBehaviorX.updateStartPos()
this.scrollBehaviorY.updateStartPos()
if (this.options.probeType === Probe.Throttle) {
this.hooks.trigger(this.hooks.eventTypes.scroll, this.getCurrentPos())
}
}
// 实时派发事件
if (this.options.probeType > Probe.Throttle) {
this.hooks.trigger(this.hooks.eventTypes.scroll, this.getCurrentPos())
}
}
/**/
this.dispatchScroll(timestamp)
}
到这个函数内容就会跟着我们的触摸开始滚动了,其实这样就可以结束了,但是呢,还有两件事要做,一是一般如果我们滑动一个东西,滑动较快的时候,即使手松开了物体也还会继续滚动一会,不会你一松开它也立马停下来,所以要判断是否是快速滑动以及如何进行这个松开后的动量动画;二是如果开启了回弹动画,这里需要判断是否要回弹。
动量动画及回弹动画
先来看触摸结束的处理函数:
private handleEnd(e: TouchEvent) {
if (this.hooks.trigger(this.hooks.eventTypes.beforeEnd, e)) {
return
}
// 调用scrollBehaviorX和scrollBehaviorY的同名方法来获取当前currentPos的值
const currentPos = this.getCurrentPos()
// 更新本次的滚动方向
this.scrollBehaviorX.updateDirection()
this.scrollBehaviorY.updateDirection()
if (this.hooks.trigger(this.hooks.eventTypes.end, e, currentPos)) {
return true
}
// 更新元素位置到结束触摸点的位置
this.animater.translate(currentPos)
// 计算最后一次区间耗时
this.endTime = getNow()
const duration = this.endTime - this.startTime
this.hooks.trigger(this.hooks.eventTypes.scrollEnd, currentPos, duration)
}
这个函数就派发了几个事件,具体做了什么还要找到订阅了这几个事件的地方,那么就要回到Scroller.ts
,
Scroller
类构造函数最后的init
方法里会执行一系列事件的订阅,找到end
事件的地方:
actions.hooks.on(
actions.hooks.eventTypes.end,
(e: TouchEvent, pos: TranslaterPoint) => {
this.hooks.trigger(this.hooks.eventTypes.touchEnd, pos)
if (this.hooks.trigger(this.hooks.eventTypes.end, pos)) {
return true
}
// 判断是否是点击操作
if (!actions.moved) {
this.hooks.trigger(this.hooks.eventTypes.scrollCancel)
if (this.checkClick(e)) {
return true
}
}
// 这里这里,这个就是用来判断是否越界及进行调整的方法
if (this.resetPosition(this.options.bounceTime, ease.bounce)) {
this.animater.setForceStopped(false)
return true
}
}
)
看resetPosition
方法:
resetPosition(time = 0, easing = ease.bounce) {
// checkInBoundary方法用来返回边界值及是否刚好在边界,具体逻辑看下面
const {
position: x,
inBoundary: xInBoundary,
} = this.scrollBehaviorX.checkInBoundary()
const {
position: y,
inBoundary: yInBoundary,
} = this.scrollBehaviorY.checkInBoundary()
// 如果都刚好在边界那么说明不需要回弹
if (xInBoundary && yInBoundary) {
return false
}
// 超过边界了那么就滚回去~(诶,你怎么骂人呢),scrollTo方法详见下面
this.scrollTo(x, y, time, easing)
return true
}
/*scrollBehavior的相关方法*/
checkInBoundary() {
const position = this.adjustPosition(this.currentPos)
// 如果边界值和本次位置一样那么说明刚好在边界
const inBoundary = position === this.getCurrentPos()
return {
position,
inBoundary,
}
}
// 越界调整位置
adjustPosition(pos: number) {
let roundPos = Math.round(pos)
if (
!this.hasScroll &&
!this.hooks.trigger(this.hooks.eventTypes.ignoreHasScroll)
) {// 满足条件返回最小滚动距离
roundPos = this.minScrollPos
} else if (roundPos > this.minScrollPos) {// 越过最小滚动距离了则需要回弹到最小距离
roundPos = this.minScrollPos
} else if (roundPos < this.maxScrollPos) {// 超过最大滚动距离了则需要回弹到最大距离
roundPos = this.maxScrollPos
}
return roundPos
}
/**/
上述的最后就是调用scrollTo
方法进行滚动,那么接下来就来看动画相关的逻辑。
scrollTo(
x: number,
y: number,
time = 0,
easing = ease.bounce,
extraTransform = {
start: {},
end: {},
}
) {
// 根据是使用transition还是requestAnimationFrame来判断是使用css cubic-bezier还是函数
/*
bounce: {
style: 'cubic-bezier(0.165, 0.84, 0.44, 1)',
fn: function(t: number) {
return 1 - --t * t * t * t
}
}
*/
const easingFn = this.options.useTransition ? easing.style : easing.fn
const currentPos = this.getCurrentPos()
// 动画开始位置
const startPoint = {
x: currentPos.x,
y: currentPos.y,
...extraTransform.start,
}
// 动画结束位置
const endPoint = {
x,
y,
...extraTransform.end,
}
this.hooks.trigger(this.hooks.eventTypes.scrollTo, endPoint)
// 起点终点相同当然就不需要动画了
if (isSamePoint(startPoint, endPoint)) return
// 调用动画方法
this.animater.move(startPoint, endPoint, time, easingFn)
}
这个方法的最后终于调用了动画的方法,因为支持两种动画方法,所以我们先来简单思考一下这两种的原理分别是什么。
动画
使用css3的transition来做动画是很简单的,只要设置好过渡属性transition
的值,接下来改变transform
的值自己就会应用动画,transition
是个简写属性,包含四个属性,一般来说我们主要设置它的transition-property
(指定你要应用动画的css属性名称,如transform,不设置则默认应用到所有可以应用的属性)、transition-duration
(过渡时间,必须要设置,不然为0没有过渡)、transition-timing-function
(动画曲线)。
使用requestAnimationFrame
的话就需要自己来设置计算每次的位置了,配合一些常用的动画曲线函数这个也是很简单的,比如上述的函数,更多函数可访问http://robertpenner.com/easing/:
function(t: number) {
return 1 - --t * t * t * t
}
你只要把动画已经进行了的时长和过渡时间的比例传入,返回的值你再和本次动画的距离相乘,即可得到此刻的位移。
接下来看具体的实现,需要先说明的是这两个类都继承了一个基类,因为它们存在很多的共同操作。
1.css3方式
move(
startPoint: TranslaterPoint,
endPoint: TranslaterPoint,
time: number,
easingFn: string | EaseFn
) {
// 设置一个pending变量,用来判断当前是否正在动画中
this.setPending(time > 0)
// 设置transition-timing-function属性
this.transitionTimingFunction(easingFn as string)
// 设置transition-property的值为transform
this.transitionProperty()
// 设置transition-duration属性
this.transitionTime(time)
// 调用上述提到过的this.translater的translate方法来设置元素的transform值
this.translate(endPoint)
// 如果时间不存在,那么在一个事件周期里里改变属性值不会触发transitionend事件,所以这里通过触发回流强制更新
if (!time) {
this._reflow = this.content.offsetHeight
this.hooks.trigger(this.hooks.eventTypes.move, endPoint)
this.hooks.trigger(this.hooks.eventTypes.end, endPoint)
}
}
2.requestAnimationFrame
方式
move(
startPoint: TranslaterPoint,
endPoint: TranslaterPoint,
time: number,
easingFn: EaseFn | string
) {
// time为0直接调用translate方法设置位置就可以了
if (!time) {
this.translate(endPoint)
this.hooks.trigger(this.hooks.eventTypes.move, endPoint)
this.hooks.trigger(this.hooks.eventTypes.end, endPoint)
return
}
// 不为0再进行动画
this.animate(startPoint, endPoint, time, easingFn as EaseFn)
}
private animate(
startPoint: TranslaterPoint,
endPoint: TranslaterPoint,
duration: number,
easingFn: EaseFn
) {
let startTime = getNow()
const destTime = startTime + duration
// 动画方法,会被requestAnimationFrame递归调用
const step = () => {
let now = getNow()
// 当前时间大于本次动画结束的时间表示动画结束了
if (now >= destTime) {
// 可能距目标值有一点小误差,手动设置一下提高准确度
this.translate(endPoint)
this.hooks.trigger(this.hooks.eventTypes.move, endPoint)
this.hooks.trigger(this.hooks.eventTypes.end, endPoint)
return
}
// 时间耗时比例
now = (now - startTime) / duration
// 调用缓动函数
let easing = easingFn(now)
const newPoint = {} as TranslaterPoint
Object.keys(endPoint).forEach((key) => {
const startValue = startPoint[key]
const endValue = endPoint[key]
// 得到本次动画的目标位置
newPoint[key] = (endValue - startValue) * easing + startValue
})
// 执行滚动
this.translate(newPoint)
if (this.pending) {
this.timer = requestAnimationFrame(step)
}
}
// 设置标志位
this.setPending(true)
// 基本操作,开始新的定时器或requestAnimationFrame时先做一次清除操作
cancelAnimationFrame(this.timer)
// 开始动画
step()
}
上面的代码里都只有设置pending
为true
,而没有重置为false
的地方,聪明的你一定能想到肯定是通过事件订阅在其他地方进行重置了,是的,让我们回到Scroller.ts
,Scroller
类里面绑定了content
元素的transitionend
事件和订阅了end
事件:
// 这是transitionend的处理函数
private transitionEnd(e: TouchEvent) {
if (e.target !== this.content || !this.animater.pending) {
return
}
const animater = this.animater as Transition
// 删除transition-duration的属性值
animater.transitionTime()
// 这里也调用了resetPosition来进行边界回弹,之前是在触摸结束后的end事件调用了,因为直接调用translate方法时是不会触发transitionend事件的,以及触摸结束后可能会有回弹动画,所以这里也需要调用
if (!this.resetPosition(this.options.bounceTime, ease.bounce)) {
this.animater.setPending(false)
}
}
this.animater.hooks.on(
this.animater.hooks.eventTypes.end,
(pos: TranslaterPoint) => {
// 同上,边界回弹
if (!this.resetPosition(this.options.bounceTime)) {
this.animater.setPending(false)
this.hooks.trigger(this.hooks.eventTypes.scrollEnd, pos)
}
}
)
当然,上述边界回弹的函数里最后动画完成后又会触发这两个事件,就又走到了resetPosition
的判断逻辑,但是因为它们已经回弹完成在边界上了,所以会直接返回false。
回弹逻辑看完了,但是动量动画还是没看到,别急,上面说了一般是当你松开手指的时候才判断是否要进行动量运动,所以回到上面的handleEnd
方法,发现最后触发了一个scrollEnd
事件,在Scroller
里找到订阅该事件的处理函数:
actions.hooks.on(
actions.hooks.eventTypes.scrollEnd,
(pos: TranslaterPoint, duration: number) => {
// 这个duration=this.endTime - this.startTime,但是startTime在一次触摸中每超过momentumLimitTime都会进行重置的,所以不是从手指触摸到手指离开的总时间
// 最后这段时间片段滚动的距离
const deltaX = Math.abs(pos.x - this.scrollBehaviorX.startPos)
const deltaY = Math.abs(pos.y - this.scrollBehaviorY.startPos)
// 判断是否是轻拂动作,应该是为插件服务的,这里不管
/**/
private checkFlick(duration: number, deltaX: number, deltaY: number) {
const flickMinMovingDistance = 1 // distinguish flick from click
if (
this.hooks.events.flick.length > 1 &&
duration < this.options.flickLimitTime &&
deltaX < this.options.flickLimitDistance &&
deltaY < this.options.flickLimitDistance &&
(deltaY > flickMinMovingDistance || deltaX > flickMinMovingDistance)
) {
return true
}
}
/**/
if (this.checkFlick(duration, deltaX, deltaY)) {
this.animater.setForceStopped(false)
this.hooks.trigger(this.hooks.eventTypes.flick)
return
}
// 判断是否进行momentum动画
if (this.momentum(pos, duration)) {
this.animater.setForceStopped(false)
return
}
}
)
private momentum(pos: TranslaterPoint, duration: number) {
const meta = {
time: 0,
easing: ease.swiper,
newX: pos.x,
newY: pos.y,
}
// 判断是否满足动量条件,满足则计算动量数据,也就是最后要滚动到的位置,这个方法代码较多,就不放出来了,反正做的事情时根据配置来判断是否满足动量条件,满足再根据配置判断是否在某个方向上允许回弹,最后再动用另一个方法momentum来计算动量数据,这个方法见下面
const momentumX = this.scrollBehaviorX.end(duration)
const momentumY = this.scrollBehaviorY.end(duration)
// 做一下判断
meta.newX = isUndef(momentumX.destination)
? meta.newX
: (momentumX.destination as number)
meta.newY = isUndef(momentumY.destination)
? meta.newY
: (momentumY.destination as number)
meta.time = Math.max(
momentumX.duration as number,
momentumY.duration as number
)
// 位置变了,那么意味着要进行动量动画
if (meta.newX !== pos.x || meta.newY !== pos.y) {
this.scrollTo(meta.newX, meta.newY, meta.time, meta.easing)
return true
}
}
// 计算动量数据
private momentum(
current: number,
start: number,
time: number,
lowerMargin: number,
upperMargin: number,
wrapperSize: number,
options = this.options
) {
// 最后滑动的时间片段
const distance = current - start
// 最后滑动的速度
const speed = Math.abs(distance) / time
const { deceleration, swipeBounceTime, swipeTime } = options
const momentumData = {
// 目标位置计算方式:手指松开后元素最后的位置+额外距离
// deceleration代表减速度,默认值是0.0015,假如distance = 15px,time = 300ms,那么speed = 0.05px/ms,则speed / deceleration = 33,即从当前距离继续滑动33px,你速度越快或deceleration设置的越小,滑动的越远
destination: current + (speed / deceleration) * (distance < 0 ? -1 : 1),
duration: swipeTime,
rate: 15,
}
// 超过最大滑动距离
if (momentumData.destination < lowerMargin) {
// 如果用户配置允许该方向回弹,那么再次计算动量距离,为什么??否则最多只能滚动到最大距离
momentumData.destination = wrapperSize
? Math.max(
lowerMargin - wrapperSize / 4,
lowerMargin - (wrapperSize / momentumData.rate) * speed
)
: lowerMargin
momentumData.duration = swipeBounceTime
} else if (momentumData.destination > upperMargin) {// 超过最小滚动距离,同上
momentumData.destination = wrapperSize
? Math.min(
upperMargin + wrapperSize / 4,
upperMargin + (wrapperSize / momentumData.rate) * speed
)
: upperMargin
momentumData.duration = swipeBounceTime
}
momentumData.destination = Math.round(momentumData.destination)
return momentumData
}
动量逻辑其实也很简单,就是根据最后时刻的耗时和距离来进行一下判断,再根据一定算法来计算动量数据也就是最终要滚动到的位置,然后滚过去。
到这里,核心的滚动逻辑已经全部结束了,最后来看一下如何强制结束transition
滚动,因为requestAnimationFrame
结束很简单,调用一下cancelAnimationFrame
就可以了。
doStop(): boolean {
const pending = this.pending
if (pending) {
// 复位标志位
this.setPending(false)
// 获取content元素当前的translateX和translateY的值
const { x, y } = this.translater.getComputedPosition()
// 将transition-duration的值设为0
this.transitionTime()
// 设置到当前位置
this.translate({ x, y })
}
return pending
}
首先获取到元素此刻的位置,然后删除过渡时间,最后再修改目标值为此刻的位置,因为不修改,即使你把过渡时间改回0了过渡动画仍然会继续,此时你强制修改一下位置,它立马就会结束。
例行总结
因为是第一次认真的阅读一份源码,所以可能会有很多问题,通篇就像在给这个源码加注释,而且因为是凭空阅读并没有通过运行代码进行断点调试,所以难免会存在错误。
首先说说TypeScript
,后半部分基本没有再介绍过它,所以可以发现想要阅读一份TypeScript
代码是并不难的,只要了解一些常用的语法基本就没有障碍了,但是离自己能熟练的使用那还是存在很远的距离,很多东西就是这样,你可以看的懂,但是你自己写就不会了,也没啥捷径,归根结底还是要多用多思考。
然后是BetterScroll
,代码总体来说还是比较清晰的,因为是插件化,所以事件机制是少不了的,优点是功能解耦,各部分独立,缺点也显而易见,首先是每个类都有自己的事件,很多事件还是同名的,所以很容易看着看着就晕了,其次是因为事件订阅发布,很难清楚的理解事件流,所以这也是比如vue
更提倡通过属性来显示传递和接收。
总的来说,这个库的核心滚动是一个很简单的功能,自己实现什么都不考虑的话一百多行代码可能也就够了,但是并不妨碍可以将它扩展成一个功能强大的库,这样要考虑的事情就比较多了,首先要考虑到各种边界情况,其次是要考虑兼容性,比如css样式,可能还会遇到特定机型的bug,代码如何组织也很重要,要尽量的复用,比如BetterScroll
里两种动画方式就存在很多共同操作,那么就可以把这些提取到公共的父类里,又比如水平滚动和垂直滚动肯定也是大量代码都是一样的,所以也需要进行抽象提炼,因为设计成插件化,所以还要考虑插件的开发和集成,最后还需要完善的测试,所以一个优秀的开源项目都是不容易的。
- 点赞
- 收藏
- 关注作者
评论(0)