import Vue from 'vue' import Virtual from './virtual' import { Item, Slot } from './Item' import { VirtualProps } from './props' const EVENT_TYPE = { ITEM: 'item_resize', SLOT: 'slot_resize' } const SLOT_TYPE = { HEADER: 'header', // string value also use for aria role attribute FOOTER: 'footer' } const VirtualList = Vue.component('virtual-list', { props: VirtualProps, data() { return { range: null } }, watch: { 'dataSources.length'() { this.virtual.updateParam('uniqueIds', this.getUniqueIdFromDataSources()) this.virtual.handleDataSourcesChange() }, start(newValue) { this.scrollToIndex(newValue) }, offset(newValue) { this.scrollToOffset(newValue) } }, created() { this.isHorizontal = this.direction === 'horizontal' this.directionKey = this.isHorizontal ? 'scrollLeft' : 'scrollTop' this.installVirtual() // listen item size change this.$on(EVENT_TYPE.ITEM, this.onItemResized) // listen slot size change if (this.$slots.header || this.$slots.footer) { this.$on(EVENT_TYPE.SLOT, this.onSlotResized) } }, // set back offset when awake from keep-alive activated() { this.scrollToOffset(this.virtual.offset) }, mounted() { // set position if (this.start) { this.scrollToIndex(this.start) } else if (this.offset) { this.scrollToOffset(this.offset) } // in page mode we bind scroll event to document if (this.pageMode) { this.updatePageModeFront() document.addEventListener('scroll', this.onScroll, { passive: false }) } }, beforeDestroy() { this.virtual.destroy() if (this.pageMode) { document.removeEventListener('scroll', this.onScroll) } }, methods: { // get item size by id getSize(id) { return this.virtual.sizes.get(id) }, // get the total number of stored (rendered) items getSizes() { return this.virtual.sizes.size }, // return current scroll offset getOffset() { if (this.pageMode) { return document.documentElement[this.directionKey] || document.body[this.directionKey] } else { const { root } = this.$refs return root ? Math.ceil(root[this.directionKey]) : 0 } }, // return client viewport size getClientSize() { const key = this.isHorizontal ? 'clientWidth' : 'clientHeight' if (this.pageMode) { return document.documentElement[key] || document.body[key] } else { const { root } = this.$refs return root ? Math.ceil(root[key]) : 0 } }, // return all scroll size getScrollSize() { const key = this.isHorizontal ? 'scrollWidth' : 'scrollHeight' if (this.pageMode) { return document.documentElement[key] || document.body[key] } else { const { root } = this.$refs return root ? Math.ceil(root[key]) : 0 } }, // set current scroll position to a expectant offset scrollToOffset(offset) { if (this.pageMode) { document.body[this.directionKey] = offset document.documentElement[this.directionKey] = offset } else { const { root } = this.$refs if (root) { root[this.directionKey] = offset } } }, // set current scroll position to a expectant index scrollToIndex(index) { // scroll to bottom if (index >= this.dataSources.length - 1) { this.scrollToBottom() } else { const offset = this.virtual.getOffset(index) this.scrollToOffset(offset) } }, // set current scroll position to bottom scrollToBottom() { const { shepherd } = this.$refs if (shepherd) { const offset = shepherd[this.isHorizontal ? 'offsetLeft' : 'offsetTop'] this.scrollToOffset(offset) // check if it's really scrolled to the bottom // maybe list doesn't render and calculate to last range // so we need retry in next event loop until it really at bottom setTimeout(() => { if (this.getOffset() + this.getClientSize() < this.getScrollSize()) { this.scrollToBottom() } }, 3) } }, // when using page mode we need update slot header size manually // taking root offset relative to the browser as slot header size updatePageModeFront() { const { root } = this.$refs if (root) { const rect = root.getBoundingClientRect() const { defaultView } = root.ownerDocument const offsetFront = this.isHorizontal ? (rect.left + defaultView.pageXOffset) : (rect.top + defaultView.pageYOffset) this.virtual.updateParam('slotHeaderSize', offsetFront) } }, // reset all state back to initial reset() { this.virtual.destroy() this.scrollToOffset(0) this.installVirtual() }, // ----------- public method end ----------- installVirtual() { this.virtual = new Virtual({ slotHeaderSize: 0, slotFooterSize: 0, keeps: this.keeps, estimateSize: this.estimateSize, buffer: Math.round(this.keeps / 3), // recommend for a third of keeps uniqueIds: this.getUniqueIdFromDataSources() }, this.onRangeChanged) // sync initial range this.range = this.virtual.getRange() }, getUniqueIdFromDataSources() { const { dataKey } = this return this.dataSources.map((dataSource) => typeof dataKey === 'function' ? dataKey(dataSource) : dataSource[dataKey]) }, // event called when each item mounted or size changed onItemResized(id, size) { this.virtual.saveSize(id, size) this.$emit('resized', id, size) }, // event called when slot mounted or size changed onSlotResized(type, size, hasInit) { if (type === SLOT_TYPE.HEADER) { this.virtual.updateParam('slotHeaderSize', size) } else if (type === SLOT_TYPE.FOOTER) { this.virtual.updateParam('slotFooterSize', size) } if (hasInit) { this.virtual.handleSlotSizeChange() } }, // here is the re-rendering entry onRangeChanged(range) { this.range = range }, onScroll(evt) { const offset = this.getOffset() const clientSize = this.getClientSize() const scrollSize = this.getScrollSize() // iOS scroll-spring-back behavior will make direction mistake if (offset < 0 || (offset + clientSize > scrollSize + 1) || !scrollSize) { return } this.virtual.handleScroll(offset) this.emitEvent(offset, clientSize, scrollSize, evt) }, // emit event in special position emitEvent(offset, clientSize, scrollSize, evt) { this.$emit('scroll', evt, this.virtual.getRange()) if (this.virtual.isFront() && !!this.dataSources.length && (offset - this.topThreshold <= 0)) { this.$emit('totop') } else if (this.virtual.isBehind() && (offset + clientSize + this.bottomThreshold >= scrollSize)) { this.$emit('tobottom') } }, // get the real render slots based on range data // in-place patch strategy will try to reuse components as possible // so those components that are reused will not trigger lifecycle mounted getRenderSlots(h) { const slots = [] const { start, end } = this.range const { dataSources, dataKey, itemClass, itemTag, itemStyle, isHorizontal, extraProps, dataComponent, itemScopedSlots } = this for (let index = start; index <= end; index++) { const dataSource = dataSources[index] if (dataSource) { const uniqueKey = typeof dataKey === 'function' ? dataKey(dataSource) : dataSource[dataKey] if (typeof uniqueKey === 'string' || typeof uniqueKey === 'number') { slots.push(h(Item, { props: { index, tag: itemTag, event: EVENT_TYPE.ITEM, horizontal: isHorizontal, uniqueKey: uniqueKey, source: dataSource, extraProps: extraProps, component: dataComponent, scopedSlots: itemScopedSlots }, style: itemStyle, class: `${itemClass}${this.itemClassAdd ? ' ' + this.itemClassAdd(index) : ''}` })) } else { console.warn(`Cannot get the data-key '${dataKey}' from data-sources.`) } } else { console.warn(`Cannot get the index '${index}' from data-sources.`) } } return slots } }, // render function, a closer-to-the-compiler alternative to templates // https://vuejs.org/v2/guide/render-function.html#The-Data-Object-In-Depth render(h) { const { header, footer } = this.$slots const { padFront, padBehind } = this.range const { isHorizontal, pageMode, rootTag, wrapTag, wrapClass, wrapStyle, headerTag, headerClass, headerStyle, footerTag, footerClass, footerStyle } = this const paddingStyle = { padding: isHorizontal ? `0px ${padBehind}px 0px ${padFront}px` : `${padFront}px 0px ${padBehind}px` } const wrapperStyle = wrapStyle ? Object.assign({}, wrapStyle, paddingStyle) : paddingStyle return h(rootTag, { ref: 'root', on: { '&scroll': !pageMode && this.onScroll } }, [ // header slot header ? h(Slot, { class: headerClass, style: headerStyle, props: { tag: headerTag, event: EVENT_TYPE.SLOT, uniqueKey: SLOT_TYPE.HEADER } }, header) : null, // main list h(wrapTag, { class: wrapClass, attrs: { role: 'group' }, style: wrapperStyle }, this.getRenderSlots(h)), // footer slot footer ? h(Slot, { class: footerClass, style: footerStyle, props: { tag: footerTag, event: EVENT_TYPE.SLOT, uniqueKey: SLOT_TYPE.FOOTER } }, footer) : null, // an empty element use to scroll to bottom h('div', { ref: 'shepherd', style: { width: isHorizontal ? '0px' : '100%', height: isHorizontal ? '100%' : '0px' } }) ]) } }) export default VirtualList