1import Vue from 'vue' 2import Virtual from './virtual' 3import { Item, Slot } from './Item' 4import { VirtualProps } from './props' 5 6const EVENT_TYPE = { 7 ITEM: 'item_resize', 8 SLOT: 'slot_resize' 9} 10const SLOT_TYPE = { 11 HEADER: 'header', // string value also use for aria role attribute 12 FOOTER: 'footer' 13} 14 15const VirtualList = Vue.component('virtual-list', { 16 props: VirtualProps, 17 18 data() { 19 return { 20 range: null 21 } 22 }, 23 24 watch: { 25 'dataSources.length'() { 26 this.virtual.updateParam('uniqueIds', this.getUniqueIdFromDataSources()) 27 this.virtual.handleDataSourcesChange() 28 }, 29 30 start(newValue) { 31 this.scrollToIndex(newValue) 32 }, 33 34 offset(newValue) { 35 this.scrollToOffset(newValue) 36 } 37 }, 38 39 created() { 40 this.isHorizontal = this.direction === 'horizontal' 41 this.directionKey = this.isHorizontal ? 'scrollLeft' : 'scrollTop' 42 43 this.installVirtual() 44 45 // listen item size change 46 this.$on(EVENT_TYPE.ITEM, this.onItemResized) 47 48 // listen slot size change 49 if (this.$slots.header || this.$slots.footer) { 50 this.$on(EVENT_TYPE.SLOT, this.onSlotResized) 51 } 52 }, 53 54 // set back offset when awake from keep-alive 55 activated() { 56 this.scrollToOffset(this.virtual.offset) 57 }, 58 59 mounted() { 60 // set position 61 if (this.start) { 62 this.scrollToIndex(this.start) 63 } else if (this.offset) { 64 this.scrollToOffset(this.offset) 65 } 66 67 // in page mode we bind scroll event to document 68 if (this.pageMode) { 69 this.updatePageModeFront() 70 71 document.addEventListener('scroll', this.onScroll, { 72 passive: false 73 }) 74 } 75 }, 76 77 beforeDestroy() { 78 this.virtual.destroy() 79 if (this.pageMode) { 80 document.removeEventListener('scroll', this.onScroll) 81 } 82 }, 83 84 methods: { 85 // get item size by id 86 getSize(id) { 87 return this.virtual.sizes.get(id) 88 }, 89 90 // get the total number of stored (rendered) items 91 getSizes() { 92 return this.virtual.sizes.size 93 }, 94 95 // return current scroll offset 96 getOffset() { 97 if (this.pageMode) { 98 return document.documentElement[this.directionKey] || document.body[this.directionKey] 99 } else { 100 const { root } = this.$refs 101 return root ? Math.ceil(root[this.directionKey]) : 0 102 } 103 }, 104 105 // return client viewport size 106 getClientSize() { 107 const key = this.isHorizontal ? 'clientWidth' : 'clientHeight' 108 if (this.pageMode) { 109 return document.documentElement[key] || document.body[key] 110 } else { 111 const { root } = this.$refs 112 return root ? Math.ceil(root[key]) : 0 113 } 114 }, 115 116 // return all scroll size 117 getScrollSize() { 118 const key = this.isHorizontal ? 'scrollWidth' : 'scrollHeight' 119 if (this.pageMode) { 120 return document.documentElement[key] || document.body[key] 121 } else { 122 const { root } = this.$refs 123 return root ? Math.ceil(root[key]) : 0 124 } 125 }, 126 127 // set current scroll position to a expectant offset 128 scrollToOffset(offset) { 129 if (this.pageMode) { 130 document.body[this.directionKey] = offset 131 document.documentElement[this.directionKey] = offset 132 } else { 133 const { root } = this.$refs 134 if (root) { 135 root[this.directionKey] = offset 136 } 137 } 138 }, 139 140 // set current scroll position to a expectant index 141 scrollToIndex(index) { 142 // scroll to bottom 143 if (index >= this.dataSources.length - 1) { 144 this.scrollToBottom() 145 } else { 146 const offset = this.virtual.getOffset(index) 147 this.scrollToOffset(offset) 148 } 149 }, 150 151 // set current scroll position to bottom 152 scrollToBottom() { 153 const { shepherd } = this.$refs 154 if (shepherd) { 155 const offset = shepherd[this.isHorizontal ? 'offsetLeft' : 'offsetTop'] 156 this.scrollToOffset(offset) 157 158 // check if it's really scrolled to the bottom 159 // maybe list doesn't render and calculate to last range 160 // so we need retry in next event loop until it really at bottom 161 setTimeout(() => { 162 if (this.getOffset() + this.getClientSize() < this.getScrollSize()) { 163 this.scrollToBottom() 164 } 165 }, 3) 166 } 167 }, 168 169 // when using page mode we need update slot header size manually 170 // taking root offset relative to the browser as slot header size 171 updatePageModeFront() { 172 const { root } = this.$refs 173 if (root) { 174 const rect = root.getBoundingClientRect() 175 const { defaultView } = root.ownerDocument 176 const offsetFront = this.isHorizontal ? (rect.left + defaultView.pageXOffset) : (rect.top + defaultView.pageYOffset) 177 this.virtual.updateParam('slotHeaderSize', offsetFront) 178 } 179 }, 180 181 // reset all state back to initial 182 reset() { 183 this.virtual.destroy() 184 this.scrollToOffset(0) 185 this.installVirtual() 186 }, 187 188 // ----------- public method end ----------- 189 190 installVirtual() { 191 this.virtual = new Virtual({ 192 slotHeaderSize: 0, 193 slotFooterSize: 0, 194 keeps: this.keeps, 195 estimateSize: this.estimateSize, 196 buffer: Math.round(this.keeps / 3), // recommend for a third of keeps 197 uniqueIds: this.getUniqueIdFromDataSources() 198 }, this.onRangeChanged) 199 200 // sync initial range 201 this.range = this.virtual.getRange() 202 }, 203 204 getUniqueIdFromDataSources() { 205 const { dataKey } = this 206 return this.dataSources.map((dataSource) => typeof dataKey === 'function' ? dataKey(dataSource) : dataSource[dataKey]) 207 }, 208 209 // event called when each item mounted or size changed 210 onItemResized(id, size) { 211 this.virtual.saveSize(id, size) 212 this.$emit('resized', id, size) 213 }, 214 215 // event called when slot mounted or size changed 216 onSlotResized(type, size, hasInit) { 217 if (type === SLOT_TYPE.HEADER) { 218 this.virtual.updateParam('slotHeaderSize', size) 219 } else if (type === SLOT_TYPE.FOOTER) { 220 this.virtual.updateParam('slotFooterSize', size) 221 } 222 223 if (hasInit) { 224 this.virtual.handleSlotSizeChange() 225 } 226 }, 227 228 // here is the re-rendering entry 229 onRangeChanged(range) { 230 this.range = range 231 }, 232 233 onScroll(evt) { 234 const offset = this.getOffset() 235 const clientSize = this.getClientSize() 236 const scrollSize = this.getScrollSize() 237 238 // iOS scroll-spring-back behavior will make direction mistake 239 if (offset < 0 || (offset + clientSize > scrollSize + 1) || !scrollSize) { 240 return 241 } 242 243 this.virtual.handleScroll(offset) 244 this.emitEvent(offset, clientSize, scrollSize, evt) 245 }, 246 247 // emit event in special position 248 emitEvent(offset, clientSize, scrollSize, evt) { 249 this.$emit('scroll', evt, this.virtual.getRange()) 250 251 if (this.virtual.isFront() && !!this.dataSources.length && (offset - this.topThreshold <= 0)) { 252 this.$emit('totop') 253 } else if (this.virtual.isBehind() && (offset + clientSize + this.bottomThreshold >= scrollSize)) { 254 this.$emit('tobottom') 255 } 256 }, 257 258 // get the real render slots based on range data 259 // in-place patch strategy will try to reuse components as possible 260 // so those components that are reused will not trigger lifecycle mounted 261 getRenderSlots(h) { 262 const slots = [] 263 const { start, end } = this.range 264 const { dataSources, dataKey, itemClass, itemTag, itemStyle, isHorizontal, extraProps, dataComponent, itemScopedSlots } = this 265 for (let index = start; index <= end; index++) { 266 const dataSource = dataSources[index] 267 if (dataSource) { 268 const uniqueKey = typeof dataKey === 'function' ? dataKey(dataSource) : dataSource[dataKey] 269 if (typeof uniqueKey === 'string' || typeof uniqueKey === 'number') { 270 slots.push(h(Item, { 271 props: { 272 index, 273 tag: itemTag, 274 event: EVENT_TYPE.ITEM, 275 horizontal: isHorizontal, 276 uniqueKey: uniqueKey, 277 source: dataSource, 278 extraProps: extraProps, 279 component: dataComponent, 280 scopedSlots: itemScopedSlots 281 }, 282 style: itemStyle, 283 class: `${itemClass}${this.itemClassAdd ? ' ' + this.itemClassAdd(index) : ''}` 284 })) 285 } else { 286 console.warn(`Cannot get the data-key '${dataKey}' from data-sources.`) 287 } 288 } else { 289 console.warn(`Cannot get the index '${index}' from data-sources.`) 290 } 291 } 292 return slots 293 } 294 }, 295 296 // render function, a closer-to-the-compiler alternative to templates 297 // https://vuejs.org/v2/guide/render-function.html#The-Data-Object-In-Depth 298 render(h) { 299 const { header, footer } = this.$slots 300 const { padFront, padBehind } = this.range 301 const { isHorizontal, pageMode, rootTag, wrapTag, wrapClass, wrapStyle, headerTag, headerClass, headerStyle, footerTag, footerClass, footerStyle } = this 302 const paddingStyle = { padding: isHorizontal ? `0px ${padBehind}px 0px ${padFront}px` : `${padFront}px 0px ${padBehind}px` } 303 const wrapperStyle = wrapStyle ? Object.assign({}, wrapStyle, paddingStyle) : paddingStyle 304 305 return h(rootTag, { 306 ref: 'root', 307 on: { 308 '&scroll': !pageMode && this.onScroll 309 } 310 }, [ 311 // header slot 312 header ? h(Slot, { 313 class: headerClass, 314 style: headerStyle, 315 props: { 316 tag: headerTag, 317 event: EVENT_TYPE.SLOT, 318 uniqueKey: SLOT_TYPE.HEADER 319 } 320 }, header) : null, 321 322 // main list 323 h(wrapTag, { 324 class: wrapClass, 325 attrs: { 326 role: 'group' 327 }, 328 style: wrapperStyle 329 }, this.getRenderSlots(h)), 330 331 // footer slot 332 footer ? h(Slot, { 333 class: footerClass, 334 style: footerStyle, 335 props: { 336 tag: footerTag, 337 event: EVENT_TYPE.SLOT, 338 uniqueKey: SLOT_TYPE.FOOTER 339 } 340 }, footer) : null, 341 342 // an empty element use to scroll to bottom 343 h('div', { 344 ref: 'shepherd', 345 style: { 346 width: isHorizontal ? '0px' : '100%', 347 height: isHorizontal ? '100%' : '0px' 348 } 349 }) 350 ]) 351 } 352}) 353 354export default VirtualList