| 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 |