You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

354 lines
10 KiB

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

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