1const DIRECTION_TYPE = { 2 FRONT: 'FRONT', // scroll up or left 3 BEHIND: 'BEHIND' // scroll down or right 4} 5const CALC_TYPE = { 6 INIT: 'INIT', 7 FIXED: 'FIXED', 8 DYNAMIC: 'DYNAMIC' 9} 10const LEADING_BUFFER = 2 11 12export default class Virtual { 13 constructor(param, callUpdate) { 14 this.init(param, callUpdate) 15 } 16 17 init(param, callUpdate) { 18 // param data 19 this.param = param 20 this.callUpdate = callUpdate 21 22 // size data 23 this.sizes = new Map() 24 this.firstRangeTotalSize = 0 25 this.firstRangeAverageSize = 0 26 this.lastCalcIndex = 0 27 this.fixedSizeValue = 0 28 this.calcType = CALC_TYPE.INIT 29 30 // scroll data 31 this.offset = 0 32 this.direction = '' 33 34 // range data 35 this.range = Object.create(null) 36 if (param) { 37 this.checkRange(0, param.keeps - 1) 38 } 39 40 // benchmark test data 41 // this.__bsearchCalls = 0 42 // this.__getIndexOffsetCalls = 0 43 } 44 45 destroy() { 46 this.init(null, null) 47 } 48 49 // return current render range 50 getRange() { 51 const range = Object.create(null) 52 range.start = this.range.start 53 range.end = this.range.end 54 range.padFront = this.range.padFront 55 range.padBehind = this.range.padBehind 56 return range 57 } 58 59 isBehind() { 60 return this.direction === DIRECTION_TYPE.BEHIND 61 } 62 63 isFront() { 64 return this.direction === DIRECTION_TYPE.FRONT 65 } 66 67 // return start index offset 68 getOffset(start) { 69 return (start < 1 ? 0 : this.getIndexOffset(start)) + this.param.slotHeaderSize 70 } 71 72 updateParam(key, value) { 73 if (this.param && (key in this.param)) { 74 // if uniqueIds change, find out deleted id and remove from size map 75 if (key === 'uniqueIds') { 76 this.sizes.forEach((v, key) => { 77 if (!value.includes(key)) { 78 this.sizes.delete(key) 79 } 80 }) 81 } 82 this.param[key] = value 83 } 84 } 85 86 // save each size map by id 87 saveSize(id, size) { 88 this.sizes.set(id, size) 89 90 // we assume size type is fixed at the beginning and remember first size value 91 // if there is no size value different from this at next coming saving 92 // we think it's a fixed size list, otherwise is dynamic size list 93 if (this.calcType === CALC_TYPE.INIT) { 94 this.fixedSizeValue = size 95 this.calcType = CALC_TYPE.FIXED 96 } else if (this.calcType === CALC_TYPE.FIXED && this.fixedSizeValue !== size) { 97 this.calcType = CALC_TYPE.DYNAMIC 98 // it's no use at all 99 delete this.fixedSizeValue 100 } 101 102 // calculate the average size only in the first range 103 if (this.calcType !== CALC_TYPE.FIXED && typeof this.firstRangeTotalSize !== 'undefined') { 104 if (this.sizes.size < Math.min(this.param.keeps, this.param.uniqueIds.length)) { 105 this.firstRangeTotalSize = this.firstRangeTotalSize + size 106 this.firstRangeAverageSize = Math.round(this.firstRangeTotalSize / this.sizes.size) 107 } else { 108 // it's done using 109 delete this.firstRangeTotalSize 110 } 111 } 112 } 113 114 // in some special situation (e.g. length change) we need to update in a row 115 // try going to render next range by a leading buffer according to current direction 116 handleDataSourcesChange() { 117 let start = this.range.start 118 119 if (this.isFront()) { 120 start = start - LEADING_BUFFER 121 } else if (this.isBehind()) { 122 start = start + LEADING_BUFFER 123 } 124 125 start = Math.max(start, 0) 126 127 this.updateRange(this.range.start, this.getEndByStart(start)) 128 } 129 130 // when slot size change, we also need force update 131 handleSlotSizeChange() { 132 this.handleDataSourcesChange() 133 } 134 135 // calculating range on scroll 136 handleScroll(offset) { 137 this.direction = offset < this.offset ? DIRECTION_TYPE.FRONT : DIRECTION_TYPE.BEHIND 138 this.offset = offset 139 140 if (this.direction === DIRECTION_TYPE.FRONT) { 141 this.handleFront() 142 } else if (this.direction === DIRECTION_TYPE.BEHIND) { 143 this.handleBehind() 144 } 145 } 146 147 // ----------- public method end ----------- 148 149 handleFront() { 150 const overs = this.getScrollOvers() 151 // should not change range if start doesn't exceed overs 152 if (overs > this.range.start) { 153 return 154 } 155 156 // move up start by a buffer length, and make sure its safety 157 const start = Math.max(overs - this.param.buffer, 0) 158 this.checkRange(start, this.getEndByStart(start)) 159 } 160 161 handleBehind() { 162 const overs = this.getScrollOvers() 163 // range should not change if scroll overs within buffer 164 if (overs < this.range.start + this.param.buffer) { 165 return 166 } 167 168 this.checkRange(overs, this.getEndByStart(overs)) 169 } 170 171 // return the pass overs according to current scroll offset 172 getScrollOvers() { 173 // if slot header exist, we need subtract its size 174 const offset = this.offset - this.param.slotHeaderSize 175 if (offset <= 0) { 176 return 0 177 } 178 179 // if is fixed type, that can be easily 180 if (this.isFixedType()) { 181 return Math.floor(offset / this.fixedSizeValue) 182 } 183 184 let low = 0 185 let middle = 0 186 let middleOffset = 0 187 let high = this.param.uniqueIds.length 188 189 while (low <= high) { 190 // this.__bsearchCalls++ 191 middle = low + Math.floor((high - low) / 2) 192 middleOffset = this.getIndexOffset(middle) 193 194 if (middleOffset === offset) { 195 return middle 196 } else if (middleOffset < offset) { 197 low = middle + 1 198 } else if (middleOffset > offset) { 199 high = middle - 1 200 } 201 } 202 203 return low > 0 ? --low : 0 204 } 205 206 // return a scroll offset from given index, can efficiency be improved more here? 207 // although the call frequency is very high, its only a superposition of numbers 208 getIndexOffset(givenIndex) { 209 if (!givenIndex) { 210 return 0 211 } 212 213 let offset = 0 214 let indexSize = 0 215 for (let index = 0; index < givenIndex; index++) { 216 // this.__getIndexOffsetCalls++ 217 indexSize = this.sizes.get(this.param.uniqueIds[index]) 218 offset = offset + (typeof indexSize === 'number' ? indexSize : this.getEstimateSize()) 219 } 220 221 // remember last calculate index 222 this.lastCalcIndex = Math.max(this.lastCalcIndex, givenIndex - 1) 223 this.lastCalcIndex = Math.min(this.lastCalcIndex, this.getLastIndex()) 224 225 return offset 226 } 227 228 // is fixed size type 229 isFixedType() { 230 return this.calcType === CALC_TYPE.FIXED 231 } 232 233 // return the real last index 234 getLastIndex() { 235 return this.param.uniqueIds.length - 1 236 } 237 238 // in some conditions range is broke, we need correct it 239 // and then decide whether need update to next range 240 checkRange(start, end) { 241 const keeps = this.param.keeps 242 const total = this.param.uniqueIds.length 243 244 // datas less than keeps, render all 245 if (total <= keeps) { 246 start = 0 247 end = this.getLastIndex() 248 } else if (end - start < keeps - 1) { 249 // if range length is less than keeps, current it base on end 250 start = end - keeps + 1 251 } 252 253 if (this.range.start !== start) { 254 this.updateRange(start, end) 255 } 256 } 257 258 // setting to a new range and re-render 259 updateRange(start, end) { 260 this.range.start = start 261 this.range.end = end 262 this.range.padFront = this.getPadFront() 263 this.range.padBehind = this.getPadBehind() 264 this.callUpdate(this.getRange()) 265 } 266 267 // return end base on start 268 getEndByStart(start) { 269 const theoryEnd = start + this.param.keeps - 1 270 const trulyEnd = Math.min(theoryEnd, this.getLastIndex()) 271 return trulyEnd 272 } 273 274 // return total front offset 275 getPadFront() { 276 if (this.isFixedType()) { 277 return this.fixedSizeValue * this.range.start 278 } else { 279 return this.getIndexOffset(this.range.start) 280 } 281 } 282 283 // return total behind offset 284 getPadBehind() { 285 const end = this.range.end 286 const lastIndex = this.getLastIndex() 287 288 if (this.isFixedType()) { 289 return (lastIndex - end) * this.fixedSizeValue 290 } 291 292 // if it's all calculated, return the exactly offset 293 if (this.lastCalcIndex === lastIndex) { 294 return this.getIndexOffset(lastIndex) - this.getIndexOffset(end) 295 } else { 296 // if not, use a estimated value 297 return (lastIndex - end) * this.getEstimateSize() 298 } 299 } 300 301 // get the item estimate size 302 getEstimateSize() { 303 return this.isFixedType() ? this.fixedSizeValue : (this.firstRangeAverageSize || this.param.estimateSize) 304 } 305}