You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

305 lines
8.1 KiB

const DIRECTION_TYPE = {
FRONT: 'FRONT', // scroll up or left
BEHIND: 'BEHIND' // scroll down or right
}
const CALC_TYPE = {
INIT: 'INIT',
FIXED: 'FIXED',
DYNAMIC: 'DYNAMIC'
}
const LEADING_BUFFER = 2
export default class Virtual {
constructor(param, callUpdate) {
this.init(param, callUpdate)
}
init(param, callUpdate) {
// param data
this.param = param
this.callUpdate = callUpdate
// size data
this.sizes = new Map()
this.firstRangeTotalSize = 0
this.firstRangeAverageSize = 0
this.lastCalcIndex = 0
this.fixedSizeValue = 0
this.calcType = CALC_TYPE.INIT
// scroll data
this.offset = 0
this.direction = ''
// range data
this.range = Object.create(null)
if (param) {
this.checkRange(0, param.keeps - 1)
}
// benchmark test data
// this.__bsearchCalls = 0
// this.__getIndexOffsetCalls = 0
}
destroy() {
this.init(null, null)
}
// return current render range
getRange() {
const range = Object.create(null)
range.start = this.range.start
range.end = this.range.end
range.padFront = this.range.padFront
range.padBehind = this.range.padBehind
return range
}
isBehind() {
return this.direction === DIRECTION_TYPE.BEHIND
}
isFront() {
return this.direction === DIRECTION_TYPE.FRONT
}
// return start index offset
getOffset(start) {
return (start < 1 ? 0 : this.getIndexOffset(start)) + this.param.slotHeaderSize
}
updateParam(key, value) {
if (this.param && (key in this.param)) {
// if uniqueIds change, find out deleted id and remove from size map
if (key === 'uniqueIds') {
this.sizes.forEach((v, key) => {
if (!value.includes(key)) {
this.sizes.delete(key)
}
})
}
this.param[key] = value
}
}
// save each size map by id
saveSize(id, size) {
this.sizes.set(id, size)
// we assume size type is fixed at the beginning and remember first size value
// if there is no size value different from this at next coming saving
// we think it's a fixed size list, otherwise is dynamic size list
if (this.calcType === CALC_TYPE.INIT) {
this.fixedSizeValue = size
this.calcType = CALC_TYPE.FIXED
} else if (this.calcType === CALC_TYPE.FIXED && this.fixedSizeValue !== size) {
this.calcType = CALC_TYPE.DYNAMIC
// it's no use at all
delete this.fixedSizeValue
}
// calculate the average size only in the first range
if (this.calcType !== CALC_TYPE.FIXED && typeof this.firstRangeTotalSize !== 'undefined') {
if (this.sizes.size < Math.min(this.param.keeps, this.param.uniqueIds.length)) {
this.firstRangeTotalSize = this.firstRangeTotalSize + size
this.firstRangeAverageSize = Math.round(this.firstRangeTotalSize / this.sizes.size)
} else {
// it's done using
delete this.firstRangeTotalSize
}
}
}
// in some special situation (e.g. length change) we need to update in a row
// try going to render next range by a leading buffer according to current direction
handleDataSourcesChange() {
let start = this.range.start
if (this.isFront()) {
start = start - LEADING_BUFFER
} else if (this.isBehind()) {
start = start + LEADING_BUFFER
}
start = Math.max(start, 0)
this.updateRange(this.range.start, this.getEndByStart(start))
}
// when slot size change, we also need force update
handleSlotSizeChange() {
this.handleDataSourcesChange()
}
// calculating range on scroll
handleScroll(offset) {
this.direction = offset < this.offset ? DIRECTION_TYPE.FRONT : DIRECTION_TYPE.BEHIND
this.offset = offset
if (this.direction === DIRECTION_TYPE.FRONT) {
this.handleFront()
} else if (this.direction === DIRECTION_TYPE.BEHIND) {
this.handleBehind()
}
}
// ----------- public method end -----------
handleFront() {
const overs = this.getScrollOvers()
// should not change range if start doesn't exceed overs
if (overs > this.range.start) {
return
}
// move up start by a buffer length, and make sure its safety
const start = Math.max(overs - this.param.buffer, 0)
this.checkRange(start, this.getEndByStart(start))
}
handleBehind() {
const overs = this.getScrollOvers()
// range should not change if scroll overs within buffer
if (overs < this.range.start + this.param.buffer) {
return
}
this.checkRange(overs, this.getEndByStart(overs))
}
// return the pass overs according to current scroll offset
getScrollOvers() {
// if slot header exist, we need subtract its size
const offset = this.offset - this.param.slotHeaderSize
if (offset <= 0) {
return 0
}
// if is fixed type, that can be easily
if (this.isFixedType()) {
return Math.floor(offset / this.fixedSizeValue)
}
let low = 0
let middle = 0
let middleOffset = 0
let high = this.param.uniqueIds.length
while (low <= high) {
// this.__bsearchCalls++
middle = low + Math.floor((high - low) / 2)
middleOffset = this.getIndexOffset(middle)
if (middleOffset === offset) {
return middle
} else if (middleOffset < offset) {
low = middle + 1
} else if (middleOffset > offset) {
high = middle - 1
}
}
return low > 0 ? --low : 0
}
// return a scroll offset from given index, can efficiency be improved more here?
// although the call frequency is very high, its only a superposition of numbers
getIndexOffset(givenIndex) {
if (!givenIndex) {
return 0
}
let offset = 0
let indexSize = 0
for (let index = 0; index < givenIndex; index++) {
// this.__getIndexOffsetCalls++
indexSize = this.sizes.get(this.param.uniqueIds[index])
offset = offset + (typeof indexSize === 'number' ? indexSize : this.getEstimateSize())
}
// remember last calculate index
this.lastCalcIndex = Math.max(this.lastCalcIndex, givenIndex - 1)
this.lastCalcIndex = Math.min(this.lastCalcIndex, this.getLastIndex())
return offset
}
// is fixed size type
isFixedType() {
return this.calcType === CALC_TYPE.FIXED
}
// return the real last index
getLastIndex() {
return this.param.uniqueIds.length - 1
}
// in some conditions range is broke, we need correct it
// and then decide whether need update to next range
checkRange(start, end) {
const keeps = this.param.keeps
const total = this.param.uniqueIds.length
// datas less than keeps, render all
if (total <= keeps) {
start = 0
end = this.getLastIndex()
} else if (end - start < keeps - 1) {
// if range length is less than keeps, current it base on end
start = end - keeps + 1
}
if (this.range.start !== start) {
this.updateRange(start, end)
}
}
// setting to a new range and re-render
updateRange(start, end) {
this.range.start = start
this.range.end = end
this.range.padFront = this.getPadFront()
this.range.padBehind = this.getPadBehind()
this.callUpdate(this.getRange())
}
// return end base on start
getEndByStart(start) {
const theoryEnd = start + this.param.keeps - 1
const trulyEnd = Math.min(theoryEnd, this.getLastIndex())
return trulyEnd
}
// return total front offset
getPadFront() {
if (this.isFixedType()) {
return this.fixedSizeValue * this.range.start
} else {
return this.getIndexOffset(this.range.start)
}
}
// return total behind offset
getPadBehind() {
const end = this.range.end
const lastIndex = this.getLastIndex()
if (this.isFixedType()) {
return (lastIndex - end) * this.fixedSizeValue
}
// if it's all calculated, return the exactly offset
if (this.lastCalcIndex === lastIndex) {
return this.getIndexOffset(lastIndex) - this.getIndexOffset(end)
} else {
// if not, use a estimated value
return (lastIndex - end) * this.getEstimateSize()
}
}
// get the item estimate size
getEstimateSize() {
return this.isFixedType() ? this.fixedSizeValue : (this.firstRangeAverageSize || this.param.estimateSize)
}
}