better-scroll源码阅读(四):轮播图slide相关代码
better-scroll源码阅读(四):轮播图slide相关代码
最近开发中需要使用better-scroll实现轮播图的效果,因此今天看一下better-scroll实现slide组件的相关代码。
由于今天的相关内容不多,开始写之前一直在纠结,要不要写。最后还是觉得,写这个东西不是为了给别人看的,主要还是给自己做个笔记。
直接进入正题。从哪里开始看起?
从初始化开始。第一篇文章已经介绍过better-scroll的初始化过程,如果想用better-scroll实现slide组件,即配置项中snap不为false时。better-scroll的初始化中会多一步snap的初始化。所以我们今天就从_initSnap()开始。这个方法与其他snap相关的代码都放在了src/scroll/snap.js文件中。
BScroll.prototype._initExtFeatures = function () {
if (this.options.snap) {
this._initSnap()
}
...
}
snap初始化过程
snap的初始化过程初看有点复杂,代码量不少,所以我们先精简一部分,看主要操作。
初始化主要做了这几件事:
- 如果开启loop,在轮播元素最前插入最后一个元素,最后插入第一个元素。
- 添加"refresh"事件回调,记录每个轮播页面的位置信息,滚动到第一个页面,更新threshold。
- 添加"scrollEnd"事件回调,滑动结束后如果需要循环,悄悄滚动(过渡时间为0,用户察觉不到)到正确位置。
- 添加"destroy"事件回调,如果开启loop,删除最初添加的轮播元素。
可以看到这里第2步是最核心的初始化,记录页面位置信息,为之后的页面滚动提供数据。其他三个步骤都是跟loop配置项有关。
BScroll.prototype._initSnap = function () {
this.currentPage = {}
const snap = this.options.snap
if (snap.loop) { // 如果开启循环,在最前插入最后一个元素,在最后插入第一个元素
let children = this.scroller.children
if (children.length > 0) {
prepend(children[children.length - 1].cloneNode(true), this.scroller)
this.scroller.appendChild(children[1].cloneNode(true))
}
}
let el = snap.el
if (typeof el === 'string') {
el = this.scroller.querySelectorAll(el)
}
this.on('refresh', () => { // refresh后执行
this.pages = []
if (!this.wrapperWidth || !this.wrapperHeight || !this.scrollerWidth || !this.scrollerHeight) {
return
}
let stepX = snap.stepX || this.wrapperWidth
let stepY = snap.stepY || this.wrapperHeight
let x = 0
let y
let cx
let cy
let i = 0
let l
let m = 0
let n
let rect
if (!el) { // 如果没有提供el配置项
cx = Math.round(stepX / 2)
cy = Math.round(stepY / 2)
// 存储每个轮播页面的位置信息
while (x > -this.scrollerWidth) {
this.pages[i] = []
l = 0
y = 0
while (y > -this.scrollerHeight) {
this.pages[i][l] = { // 保存每个页面位置信息
x: Math.max(x, this.maxScrollX),
y: Math.max(y, this.maxScrollY),
width: stepX,
height: stepY,
cx: x - cx, // 页面的中心位置
cy: y - cy
}
y -= stepY
l++
}
x -= stepX
i++
}
} else {
...
}
// 初始化显示第一个页面
let initPage = snap.loop ? 1 : 0
this._goToPage(this.currentPage.pageX || initPage, this.currentPage.pageY || 0, 0)
// 更新threshold 取值 0 ~ 1
const snapThreshold = snap.threshold
if (snapThreshold % 1 === 0) {
this.snapThresholdX = snapThreshold
this.snapThresholdY = snapThreshold
} else {
this.snapThresholdX = Math.round(this.pages[this.currentPage.pageX][this.currentPage.pageY].width * snapThreshold)
this.snapThresholdY = Math.round(this.pages[this.currentPage.pageX][this.currentPage.pageY].height * snapThreshold)
}
})
this.on('scrollEnd', () => {
if (snap.loop) { // 如果开启loop,在滑动结束后,悄悄(过渡时间为0,用户察觉不到)滚动到正确位置
if (this.currentPage.pageX === 0) {
this._goToPage(this.pages.length - 2, this.currentPage.pageY, 0)
}
if (this.currentPage.pageX === this.pages.length - 1) {
this._goToPage(1, this.currentPage.pageY, 0)
}
}
})
if (snap.listenFlick !== false) { // 暂不关心
...
}
this.on('destroy', () => {
if (snap.loop) { // 如果开启loop,destory时删除最初前后添加的元素
let children = this.scroller.children
if (children.length > 2) {
removeChild(this.scroller, children[children.length - 1])
removeChild(this.scroller, children[0])
}
}
})
}
可以看到,实现循环播放的方法就是:最初在轮播元素的前后分别克隆一份最后一个元素和第一个元素,然后当页面处于第一个轮播元素时如果用户继续向左滑动,因为我们已经添加了最后一个元素,所以用户可以看到一个滑动到最后一个元素的过渡动画。重要的是,滑动结束后,"scrollEnd"事件回调函数中,会悄悄的滚动到真正的最后一个元素的位置。因为没有设置过渡时间,因此察觉不到,这样当用户继续向左滑动的时候,就会滑动到倒数第二个元素了。
"scrollEnd"的回调里有一个_goToPage()方法。这个方法比较简单,主要是拿到页面的位置信息,然后使用scrollTo()滚动到相应位置。代码直接拿过来,有兴趣可以看一下。
BScroll.prototype._goToPage = function (x, y = 0, time, easing) {
const snap = this.options.snap
if (!snap || !this.pages) {
return
}
easing = easing || snap.easing || ease.bounce
if (x >= this.pages.length) {
x = this.pages.length - 1
} else if (x < 0) {
x = 0
}
if (!this.pages[x]) {
return
}
if (y >= this.pages[x].length) {
y = this.pages[x].length - 1
} else if (y < 0) {
y = 0
}
let posX = this.pages[x][y].x
let posY = this.pages[x][y].y
time = time === undefined ? snap.speed || Math.max(
Math.max(
Math.min(Math.abs(posX - this.x), 1000),
Math.min(Math.abs(posY - this.y), 1000)
), 300) : time
this.currentPage = {
x: posX,
y: posY,
pageX: x,
pageY: y
}
this.scrollTo(posX, posY, time, easing)
}
滑动结束后的响应过程
在snap.js文件里有一个_nearestSnap()方法,它会在滑动结束时的_end()方法中调用。
BScroll.prototype._end = function (e) {
...
let easing = ease.swipe
if (this.options.snap) {
let snap = this._nearestSnap(newX, newY) // 获得滑动方向的下一个页面
this.currentPage = snap
time = this.options.snapSpeed || Math.max(
Math.max(
Math.min(Math.abs(newX - snap.x), 1000),
Math.min(Math.abs(newY - snap.y), 1000)
), 300)
newX = snap.x
newY = snap.y
this.directionX = 0
this.directionY = 0
easing = this.options.snap.easing || ease.bounce
}
if (newX !== this.x || newY !== this.y) {
// change easing function when scroller goes out of the boundaries
if (newX > 0 || newX < this.maxScrollX || newY > 0 || newY < this.maxScrollY) {
easing = ease.swipeBounce
}
this.scrollTo(newX, newY, time, easing)
return
}
...
this.trigger('scrollEnd', {
x: this.x,
y: this.y
})
}
可以看到滑动结束后,和snap相关的关键步骤即根据_nearestSnap()得到应该展示的下一个页面(前一个,后一个或者保持当前页面不变)。然后根据得到页面的位置信息,使用scrollTo()滚动到指定位置。
接下来就是看看_nearestSnap()方法的实现过程。
BScroll.prototype._nearestSnap = function (x, y) {
if (!this.pages.length) {
return {x: 0, y: 0, pageX: 0, pageY: 0}
}
/****************下面这部分是否有意义*****************************/
let i = 0
// 如果滑动距离没有超过threshold直接返回当前页面
if (Math.abs(x - this.absStartX) <= this.snapThresholdX &&
Math.abs(y - this.absStartY) <= this.snapThresholdY) {
return this.currentPage
}
// 保证滚动位置x,y,没有超过边界值。 正常范围是maxScrollX/Y ~ 0
if (x > 0) {
x = 0
} else if (x < this.maxScrollX) {
x = this.maxScrollX
}
...
// 得到当前滚动位置右侧的页面,如果是向左滑动,那么右侧页面就是我们想要看到的页面。
// 如果向右滑动,那么右侧页面是当前页面,我们想看到的是左侧的页面,所以下面会做调整。
let l = this.pages.length
for (; i < l; i++) {
if (x >= this.pages[i][0].cx) {
x = this.pages[i][0].x
break
}
}
l = this.pages[i].length
...
/****************上面面这部分是否有意义****************************/
// 什么情况下会得到当前页面?
// 1. 上面提到的,每次向右滑动,应该得到左侧的页面,但是计算得到了当前页面。
// 2. 如果已经是最右侧的页面,向左滑动时x被强制为maxScrollX,最后一个页面的位置即(maxScrollX, 0),
// 因此最终计算得到了当前页面
if (i === this.currentPage.pageX) {
i += this.directionX // 对以上两种情况做调整,得到正确的页面
if (i < 0) { // 保证page索引没有越界
i = 0
} else if (i >= this.pages.length) {
i = this.pages.length - 1
}
x = this.pages[i][0].x
}
...
return {
x,
y,
pageX: i,
pageY: m
}
}
原方法里对横向和纵向做了相同的计算过程,因此我们隐去纵向y轴的计算,只看横向x轴的计算过程。
关于得到下一个应该展示的页面的算法问题,我认为用分割线/**/截出来的部分,有点多余。完全可以用一句i = this.currentPage来代替。我对代码的理解都写在了注释里,或许是我理解的不对?
暂时略过,反正我们知道_nearSnap()是为了得到下一个页面就好。
辅助方法
除了上面介绍的方法外,"snap.js"文件还定义了goToPage(), getCurrentPage(), next()和pre()方法供better-scroll实例调用。这几个方法都比较简单,直接拿过来,简单看下就好。
BScroll.prototype.goToPage = function (x, y, time, easing) {
const snap = this.options.snap
if (snap) {
if (snap.loop) {
let len = this.pages.length - 2
if (x >= len) {
x = len - 1
} else if (x < 0) {
x = 0
}
x += 1
}
this._goToPage(x, y, time, easing)
}
}
BScroll.prototype.next = function (time, easing) {
let x = this.currentPage.pageX
let y = this.currentPage.pageY
x++
if (x >= this.pages.length && this.hasVerticalScroll) {
x = 0
y++
}
this._goToPage(x, y, time, easing)
}
BScroll.prototype.prev = function (time, easing) {
let x = this.currentPage.pageX
let y = this.currentPage.pageY
x--
if (x < 0 && this.hasVerticalScroll) {
x = 0
y--
}
this._goToPage(x, y, time, easing)
}
BScroll.prototype.getCurrentPage = function () {
const snap = this.options.snap
if (snap) {
if (snap.loop) {
let currentPage = extend({}, this.currentPage, {
pageX: this.currentPage.pageX - 1
})
return currentPage
}
return this.currentPage
}
return null
}
}
结语
可以看到better-scroll如果实现轮播图要么是横向要么是纵向,只能选择一个方向,同时只有横向支持循环,使用时需要注意。
最后我还有一个问题,为什么这部分要叫"snap"呢?我们主要用来实现轮播图,为什么不叫slide呢?
今天的内容就这么多,有不正确的地方欢迎讨论。
我大概猜了下,原因(bscroll 0.01版本的时候,黄老师应该是大部分代码借(fu)鉴(zhi)了Iscroll,加了一些修改。所以名字就是 snap。iscroll 的作者是韩国人有自己的命名习惯吧。最后说下我之前写的: https://github.com/jxZhangLi/better-scroll-blog 文笔太差了,向你学习。