vimo-dt
Version:
A Vue2.x UI Project For Mobile & HyBrid
435 lines (363 loc) • 11.7 kB
JavaScript
/**
* @class ScrollView
* @classdesc Content组件使用的滚动引擎
*
* 滚动引擎根使用原生滚动
*
* @private
* */
import registerListener from './/register-listener'
import { isBoolean, isNumber, isPresent } from './type'
const SCROLL_END_DEBOUNCE_MS = 80
const FRAME_MS = (1000 / 60)
const EVENT_OPTS = {passive: true}
export default class ScrollView {
constructor () {
this.isScrolling = false // 判断正在滚动
this.initialized = false // 判断是否已完成初始化
this.scrollStart = (ev) => {} // 滚动开始的回调, 传入ev参数, 一般用于事件操作
this.scroll = (ev) => {} // 滚动进行的回调, 传入ev参数, 一般用于事件操作
this.scrollEnd = (ev) => {} // 滚动结束的回调, 传入ev参数, 一般用于事件操作
this.transform = window.VM && window.VM.platform && window.VM.platform.css.transform
this._el = null // scrollElement 当前滚动实例的元素
this._evel = null // scrollElement 当前滚动实例的元素
this._lsn = null // 监听函数 listen, 用于nativeScrll
this._endTmr = null // 事件记录 timeout, 用于nativeScrll
// 滚动对象
this.ev = {
timeStamp: 0,
scrollLeft: 0,
scrollTop: 0,
scrollHeight: 0,
scrollWidth: 0,
contentHeight: 0,
contentWidth: 0,
contentTop: 0, // 内容顶部到上边框的距离
startY: 0,
startX: 0,
deltaY: 0,
deltaX: 0,
velocityY: 0,
velocityX: 0,
directionY: 'down',
directionX: null,
contentElement: null, // HTMLElement
fixedElement: null, // HTMLElement
scrollElement: null, // HTMLElement
headerElement: null, // HTMLElement
footerElement: null // HTMLElement
}
}
/**
* 滚动对象初始化
* @param {Element|Window} element - 滚动元素
* @param {Element|Window} eventElement - 监听滚动的元素
* */
init (element, eventElement = element) {
if (!this.initialized) {
this.initialized = true
this._el = element
this._evel = eventElement
this.ev.scrollHeight = this._el.scrollHeight
this.ev.scrollWidth = this._el.scrollWidth
this.enableNativeScrolling()
}
}
/**
* 初始化原生滚动, 这里: 滚动时能监听滚动的状态
* @private
* */
enableNativeScrolling () {
const self = this
const ev = self.ev
const positions = [] // number[]
/**
* @param {UIEvent} scrollEvent
* */
function scrollCallback (scrollEvent) {
ev.timeStamp = scrollEvent.timeStamp
ev.scrollTop = self.getTop()
ev.scrollLeft = self.getLeft()
ev.scrollHeight = self.getHeight()
ev.scrollWidth = self.getWidth()
if (!self.isScrolling) {
// scroll start
self.isScrolling = true
// 记录开始的位置
ev.startY = ev.scrollTop
ev.startX = ev.scrollLeft
// 开始前重置参数
ev.velocityY = ev.velocityX = 0
ev.deltaY = ev.deltaX = 0
positions.length = 0
// 发送scrollStart事件, 传递ev值
self.scrollStart(ev)
}
// 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 timeOffset = (positions[endPos] - positions[startPos])
var movedTop = (positions[startPos - 2] - positions[endPos - 2])
var movedLeft = (positions[startPos - 1] - positions[endPos - 1])
// based on XXms compute the movement to apply for each render step
ev.velocityY = ((movedTop / timeOffset) * FRAME_MS)
ev.velocityX = ((movedLeft / timeOffset) * FRAME_MS)
// figure out which direction we're scrolling
ev.directionY = (movedTop > 0 ? 'up' : 'down')
ev.directionX = (movedLeft > 0 ? 'left' : 'right')
}
}
function scrollEnd () {
// 当停止滚动一段时间后触发scrollEnd事件
self.isScrolling = false
// reset velocity, do not reset the directions or deltas
ev.velocityY = ev.velocityX = 0
// 发送scrollEnd事件, 传递ev值
self.scrollEnd(ev)
self._endTmr = null
}
// 发送每一次的scroll事件, 传递ev值
self.scroll(ev)
// 定时超时时间SCROLL_END_DEBOUNCE_MS, 如果超时则判断当前滚动结束, 发送scrollEnd事件
window.clearTimeout(self._endTmr)
self._endTmr = window.setTimeout(scrollEnd, SCROLL_END_DEBOUNCE_MS)
}
// 如果有则清楚之前绑定的事件
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(this._evel, 'scroll', scrollCallback, EVENT_OPTS)
}
/**
* 获取scrollHeight
* @private
* */
getHeight () {
return this._el.scrollHeight
}
/**
* 获取scrollHeight
* @private
* */
getWidth () {
return this._el.scrollWidth
}
/**
* 获取滚动的上边距
* return {Number}
* @private
*/
getTop () {
if (this._evel === window) {
return window.scrollY
} else {
return this._el.scrollTop
}
}
/**
* 获取滚动的左边距
* return {Number}
* @private
*/
getLeft () {
return this._el.scrollLeft
}
/**
* 设置滚动上部距离, 只在nativeScroll中用到
* @param {number} top
* @private
*/
setTop (top) {
if (this._evel === window) {
return window.scrollTo(0, top)
} else {
this._el.scrollTop = top
}
}
/**
* 设置滚动左边距离, 只在nativeScroll中用到
* @param {number} left
* @private
*/
setLeft (left) {
this._el.scrollLeft = left
}
/**
* 停止滚动
* @private
* */
stop () {
this.isScrolling = false
}
/**
* 销毁
* @private
*/
destroy () {
this.stop()
this.initialized = false
this._endTmr && window.clearTimeout(this._endTmr)
this._lsn && this._lsn()
this._lsn = null
let ev = this.ev
ev.contentElement = ev.fixedElement = ev.scrollElement = ev.headerElement = {}
this._el = this.ev = ev = null
this.scrollStart && (this.scrollStart = (ev) => {})
this.scroll && (this.scrollStart = (ev) => {})
this.scrollEnd && (this.scrollStart = (ev) => {})
}
// --------- public ---------
/**
* scrollTo
* 滚动到, 绝对滚动
* @param {Number} [x=0] - 横轴位置
* @param {Number} [y=0] - 纵轴位置
* @param {Number} [duration=300] - 滚动时间
* @param {Function} [done]
* @return {Promise}
*/
scrollTo (x = 0, y = 0, duration = 300, done) {
const el = this._el
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
})
}
// scroll animation loop w/ easing
// credit https://gist.github.com/dezinezync/5487119
if (duration < 32) {
this.setTop(y)
this.setLeft(x)
done()
return promise
}
const fromY = this.getTop()
const fromX = this.getLeft()
const maxAttempts = (duration / 16) + 100
const transform = this.transform
let startTime // number
let timeStamp
let attempts = 0
let stopScroll = false
// scroll loop
// eslint-disable-next-line no-inner-declarations
let step = () => {
attempts++
if (stopScroll || attempts > maxAttempts) {
this.isScrolling = false
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) {
this.setTop((easedT * (y - fromY)) + fromY)
}
if (fromX !== x) {
this.setLeft(Math.floor((easedT * (x - fromX)) + fromX))
}
if (easedT < 1) {
// do not use DomController here
// must use window.requestAnimationFrame in order to fire in the next frame
window.requestAnimationFrame(step)
} else {
stopScroll = true
this.isScrolling = false
done()
}
}
// 准备开始滚动循环
this.isScrolling = true
startTime = new Date().getTime()
// 开始第一帧
window.requestAnimationFrame(step)
return promise
}
/**
* @param {number} [duration=300] - 滚动时间
* @return {Promise}
*/
scrollToTop (duration = 300) {
return this.scrollTo(0, 0, duration)
}
/**
* @param {number} [duration=300] - 滚动时间
* @return {Promise}
*/
scrollToBottom (duration = 300) {
let y = this.getHeight() - this._el.clientHeight
return this.scrollTo(0, y, duration)
}
/**
* scrollBy
* 滚动到, 相对滚动
* @param {number} [x=0] - 横轴位置
* @param {number} [y=0] - 纵轴位置
* @param {number} [duration=300] - 滚动时间
* @param {Function} done
* @return {Promise}
*/
scrollBy (x = 0, y = 0, duration = 300, done) {
y += this.getTop()
x += this.getLeft()
return this.scrollTo(x, y, duration, done)
}
/**
* scrollToElement
* 滚动到某个元素, 默认滚动到顶部对其, 如果offsetX/offsetY传入的是boolean类型, 且为true, 则滚动到屏幕中间
* @param {Element} el - 元素Element
* @param {Number} [duration=300] - 滚动时间
* @param {Number|Boolean} [offsetX=0] - 元素位置距离屏幕顶部的距离
* @param {Number|Boolean} [offsetY=0] - 元素位置距离屏幕左侧的距离
* @param {Function} [done]
* @return {Promise}
* */
scrollToElement (el, duration = 300, offsetX = 0, offsetY = 0, done) {
if (!el) {
console.assert(el, 'The method scrollToElement() need element!')
return new Promise((resolve) => { resolve() })
}
let x = 0
let y = el.offsetTop
// Content组件默认是上下滚动, 如果offsetX没有别的值, 则不进行offsetX设置
if (isPresent(offsetX)) {
if (isNumber(offsetX) && offsetX !== 0) {
x = el.offsetLeft + offsetX
}
if (isBoolean(offsetX) && offsetX) {
x = el.offsetLeft + ((this.ev.contentWidth - el.offsetWidth) / 2)
}
}
if (isPresent(offsetY)) {
if (isNumber(offsetY)) {
y -= offsetY
}
if (isBoolean(offsetY) && offsetY) {
y -= ((window.innerHeight - el.offsetHeight) / 2)
}
}
return this.scrollTo(x, y, duration, done)
}
}