beter-scroll-source-code-reading icon indicating copy to clipboard operation
beter-scroll-source-code-reading copied to clipboard

better-scroll源码阅读(四):轮播图slide相关代码

Open tank0317 opened this issue 8 years ago • 1 comments

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的初始化过程初看有点复杂,代码量不少,所以我们先精简一部分,看主要操作。

初始化主要做了这几件事:

  1. 如果开启loop,在轮播元素最前插入最后一个元素,最后插入第一个元素。
  2. 添加"refresh"事件回调,记录每个轮播页面的位置信息,滚动到第一个页面,更新threshold。
  3. 添加"scrollEnd"事件回调,滑动结束后如果需要循环,悄悄滚动(过渡时间为0,用户察觉不到)到正确位置。
  4. 添加"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呢?

今天的内容就这么多,有不正确的地方欢迎讨论。

tank0317 avatar Jan 06 '18 15:01 tank0317

我大概猜了下,原因(bscroll 0.01版本的时候,黄老师应该是大部分代码借(fu)鉴(zhi)了Iscroll,加了一些修改。所以名字就是 snap。iscroll 的作者是韩国人有自己的命名习惯吧。最后说下我之前写的: https://github.com/jxZhangLi/better-scroll-blog 文笔太差了,向你学习。

proc07 avatar Jan 14 '18 11:01 proc07