/** * virtual list core calculating center */ 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 = 0 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 comming 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.sizes.values()].reduce((acc, val) => acc + val, 0) 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 goiong 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.param) { return } 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, corrent it base on end start = end - keeps + 1 } if (this.range.start !== start) { this.updateRange(start, end) } } // setting to a new range and rerender 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 truelyEnd = Math.min(theoryEnd, this.getLastIndex()) return truelyEnd } // 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) } }