content/scroll-view.js

import {registerListener} from '../../util/dom'

const SCROLL_END_DEBOUNCE_MS = 80
const FRAME_MS = (1000 / 60)
const EVENT_OPTS = {
  passive: true,
  zone: false
}

export default class ScrollView {
  constructor () {
    this.ev = null
    this.isScrolling = false
    this.onScrollStart = () => {}
    this.onScroll = () => {}
    this.onScrollEnd = () => {}
    this.initialized = false

    this._el = null
    this._lsn = null
    this._endTmr = null
    this._transform = window.VM && window.VM.platform && window.VM.platform.css.transform || 'transform'

    this.ev = {
      timeStamp: 0,
      scrollTop: 0,
      scrollLeft: 0,
      scrollHeight: 0,
      scrollWidth: 0,
      contentHeight: 0,
      contentWidth: 0,
      contentTop: 0,
      contentBottom: 0,
      startY: 0,
      startX: 0,
      deltaY: 0,
      deltaX: 0,
      velocityY: 0,
      velocityX: 0,
      directionY: 'down',
      directionX: null
    }
  }

  init (ele) {
    console.assert(ele, 'scroll-view, element can not be null')
    this._el = ele
    if (!this.initialized) {
      this.initialized = true
      this.enableNativeScrolling()
    }
  }

  setScrolling (isScrolling, ev) {
    if (this.isScrolling) {
      if (isScrolling) {
        this.onScroll && this.onScroll(ev)
      } else {
        this.isScrolling = false
        this.onScrollEnd && this.onScrollEnd(ev)
      }
    } else if (isScrolling) {
      this.isScrolling = true
      this.onScrollStart && this.onScrollStart(ev)
    }
  }

  enableNativeScrolling () {
    console.assert(this.onScrollStart, 'onScrollStart is not defined')
    console.assert(this.onScroll, 'onScroll is not defined')
    console.assert(this.onScrollEnd, 'onScrollEnd is not defined')

    if (!this._el) {
      return
    }

    const self = this
    const ev = self.ev
    const positions = []

    function scrollCallback (scrollEvent) {
      ev.timeStamp = scrollEvent.timeStamp

      // Event.timeStamp is 0 in firefox
      if (!ev.timeStamp) {
        ev.timeStamp = Date.now()
      }

      // get the current scrollTop
      ev.scrollTop = self.getTop()

      // get the current scrollLeft
      ev.scrollLeft = self.getLeft()

      if (!self.isScrolling) {
        // remember the start positions
        ev.startY = ev.scrollTop
        ev.startX = ev.scrollLeft

        // new scroll, so do some resets
        ev.velocityY = ev.velocityX = 0
        ev.deltaY = ev.deltaX = 0
        positions.length = 0
      }

      // actively scrolling
      positions.push(ev.scrollTop, ev.scrollLeft, ev.timeStamp)

      if (positions.length > 3) {
        // we've gotten at least 2 scroll events so far
        ev.deltaY = (ev.scrollTop - ev.startY)
        ev.deltaX = (ev.scrollLeft - ev.startX)

        var endPos = (positions.length - 1)
        var startPos = endPos
        var timeRange = (ev.timeStamp - 100)

        // move pointer to position measured 100ms ago
        for (var i = endPos; i > 0 && positions[i] > timeRange; i -= 3) {
          startPos = i
        }

        if (startPos !== endPos) {
          // compute relative movement between these two points
          var movedTop = (positions[startPos - 2] - positions[endPos - 2])
          var movedLeft = (positions[startPos - 1] - positions[endPos - 1])
          var factor = FRAME_MS / (positions[endPos] - positions[startPos])
          // based on XXms compute the movement to apply for each render step
          ev.velocityY = movedTop * factor
          ev.velocityX = movedLeft * factor

          // figure out which direction we're scrolling
          ev.directionY = (movedTop > 0 ? 'up' : 'down')
          ev.directionX = (movedLeft > 0 ? 'left' : 'right')
        }
      }

      function scrollEnd () {
        // reset velocity, do not reset the directions or deltas
        ev.velocityY = ev.velocityX = 0

        // emit that the scroll has ended
        self.setScrolling(false, ev)

        self._endTmr = null
      }

      // emit on each scroll event
      self.setScrolling(true, ev)

      // debounce for a moment after the last scroll event
      window.clearTimeout(self._endTmr)
      self._endTmr = window.setTimeout(scrollEnd, SCROLL_END_DEBOUNCE_MS)
    }

    // clear out any existing listeners (just to be safe)
    self._lsn && self._lsn()

    // assign the raw scroll listener
    // note that it does not have a wrapping requestAnimationFrame on purpose
    // a scroll event callback will always be right before the raf callback
    // so there's little to no value of using raf here since it'll all ways immediately
    // call the raf if it was set within the scroll event, so this will save us some time
    self._lsn = registerListener(self._el, 'scroll', scrollCallback, EVENT_OPTS)
  }

  /**
   * DOM READ
   */
  getHeight () {
    return this._el.scrollHeight
  }

  /**
   * DOM READ
   */
  getWidth () {
    return this._el.scrollWidth
  }

  /**
   * DOM READ
   */
  getTop () {
    return this._el.scrollTop
  }

  /**
   * DOM READ
   */
  getLeft () {
    return this._el.scrollLeft
  }

  /**
   * DOM WRITE
   */
  setTop (top) {
    this._el.scrollTop = top
  }

  /**
   * DOM WRITE
   */
  setLeft (left) {
    this._el.scrollLeft = left
  }

  scrollTo (x, y, duration, done) {
    // scroll animation loop w/ easing
    // credit https://gist.github.com/dezinezync/5487119

    let promise
    if (done === undefined) {
      // only create a promise if a done callback wasn't provided
      // done can be a null, which avoids any functions
      promise = new Promise(resolve => {
        done = resolve
      })
    }

    const self = this
    const el = self._el
    if (!el) {
      // invalid element
      done()
      return promise
    }

    if (duration < 32) {
      self.setTop(y)
      self.setLeft(x)
      done()
      return promise
    }

    const fromY = el.scrollTop
    const fromX = el.scrollLeft

    const maxAttempts = (duration / 16) + 100
    const transform = this._transform

    let startTime
    let timeStamp
    let attempts = 0
    let stopScroll = false

    // scroll loop
    let step = () => {
      attempts++

      if (stopScroll || attempts > maxAttempts) {
        self.setScrolling(false, null);
        (el.style)[transform] = ''
        done()
        return
      }

      timeStamp = new Date().getTime()

      let time = Math.min(1, ((timeStamp - startTime) / duration))

      // where .5 would be 50% of time on a linear scale easedT gives a
      // fraction based on the easing method
      let easedT = (--time) * time * time + 1

      if (fromY !== y) {
        self.setTop((easedT * (y - fromY)) + fromY)
      }

      if (fromX !== x) {
        self.setLeft(Math.floor((easedT * (x - fromX)) + fromX))
      }

      if (easedT < 1) {
        // do not use DomController here
        // must use nativeRaf in order to fire in the next frame
        window.requestAnimationFrame(step)
      } else {
        stopScroll = true
        self.setScrolling(false, null);
        (el.style)[transform] = ''
        done()
      }
    }

    // start scroll loop
    self.setScrolling(true, null)
    self.isScrolling = true

    startTime = new Date().getTime()

    // chill out for a frame first
    window.requestAnimationFrame(step)

    return promise
  }

  scrollToTop (duration) {
    return this.scrollTo(0, 0, duration)
  }

  scrollToBottom (duration) {
    let y = 0
    if (this._el) {
      y = this._el.scrollHeight - this._el.clientHeight
    }
    return this.scrollTo(0, y, duration)
  }

  scrollBy (x = 0, y = 0, duration = 300, done) {
    y += this.getTop()
    x += this.getLeft()
    return this.scrollTo(x, y, duration, done)
  }

  scrollToElement (el, duration = 300, done) {
    if (!el) {
      console.assert(el, 'The method scrollToElement() need element!')
      return new Promise((resolve) => { resolve() })
    }

    let x = 0
    let y = el.offsetTop

    return this.scrollTo(x, y, duration, done)
  }

  stop () {
    this.setScrolling(false, null)
  }

  /**
   * @hidden
   */
  destroy () {
    this.stop()

    this._endTmr && window.clearTimeout(this._endTmr)
    this._lsn && this._lsn()

    let ev = this.ev
    ev.contentElement = ev.fixedElement = ev.scrollElement = ev.headerElement = null
    this._lsn = this._el = this.ev = ev = null
    this.onScrollStart = this.onScroll = this.onScrollEnd = null
  }
}