vue-virtual-scroller
vue-virtual-scroller copied to clipboard
proposal: an api for loading more data if scroller has reached last the items
I'm subscribing to the @update
event to load more data, when the scroller has reached the end.
I would have expected that the event fires when the indices of the loaded items have changed – instead it is fired for every scroll event. Thus, depending on the average scroll item size, there are a lot of redundant event that could be skipped.
Is there a reason for that or should this be changed to only fire if the actual indices have changed?
Maybe this use-case is common enough to get a separate event. One that only fires once if a certain threshold is reached. Sending hundreds or thousands of unused events seems wasteful ...
I would propose the following api:
- new event
itemEnd
– calls a function with one argumentitemEndIndex
when the item withitemIndex === items.length - itemEndThreshold
for the first time - new prop:
emitItemEnd
(int) defaultnull
– if set to a positive integer the conditions for theitemEnd
event will be checked, otherwise not- if
emitItemEnd === 1
the event will be fired if the last item is loaded, ifemitItemEnd === 10
the event will be fired if one of the last 10 items is loaded
- if
Once the event has fired it will not be fired again, except if the items have changed.
I could prepare a PR if you are interested in this functionality.
@qwesda I think this goes outside the scope of this component. There are many approaches to infinite loading and it would be more of a hindrance if vue-virtual-scroller
supported a particular type.
You can always hook on the native scroll
event for the RecycleScroller
and trigger your own infinite loading logic there.
<template>
<RecycleScroller
@scroll.native.passive="handleScroll"
>
<!-- your template -->
</RecycleScroller>
</template>
<script>
const LOAD_THRESHOLD_PX = 400
export default {
// ...
methods: {
handleScroll(e) {
const { target } = e;
const currentScroll = target.scrollTop;
const scrollableDistance = Math.max(0, target.scrollHeight - target.offsetHeight);
if (currentScroll >= scrollableDistance - LOAD_THRESHOLD_PX) {
// Your infinite loading logic here
}
},
},
// ...
}
</script>
@tibineagu maybe I phrased the proposal wrong.
The proposed api is just for notifying about reaching the end of the items - it can be used for loading more data, but there are probably different uses as well.
It's just that when I implemented the logic, I was annoyed, that I got a lot of the scroll events I wasn't really interested in. It seemed very wasteful and since the scroller has to process these events anyway I thought the logic could go there.
I think this event would be useful for a lot of people and that it would be a pretty small change to the codebase.
If you still think it doesn't belong there I understand, but please reconsider it.
I think should tell some examples about how to handle the infinite scroller !
@qwesda you can use vue-infinite-scroll wrapper the RecycleScroller outside
@qwesda I totally agree with your suggestion here.
I would suggest we follow the same API React Native implemented for their FlatList component, using https://facebook.github.io/react-native/docs/flatlist.html#onendreached and onEndReachedThreshold
yes, that API looks exactly like what I would have needed.
We really have unnecessary events but have not necessary ones
@qwesda you may add this event by your self. Just create your own RecycleScroller component extending existed RecycleScroller. You just need to add 1 line. In this example, we will add 'bottom' event. updateVisibleItems
was copied from the original RecycleScroller component. At the bottom of this method add our event
import { RecycleScroller } from 'vue-virtual-scroller'
import config from '~/node_modules/vue-virtual-scroller/src/config'
export default {
name: 'RecycleScrollerComponent',
extends: RecycleScroller,
methods: {
updateVisibleItems(checkItem) {
const itemSize = this.itemSize
const typeField = this.typeField
const keyField = this.simpleArray ? null : this.keyField
const items = this.items
const count = items.length
const sizes = this.sizes
const views = this.$_views
const unusedViews = this.$_unusedViews
const pool = this.pool
let startIndex, endIndex
let totalSize
if (!count) {
startIndex = endIndex = totalSize = 0
} else if (this.$isServer) {
startIndex = 0
endIndex = this.prerender
totalSize = null
} else {
const scroll = this.getScroll()
const buffer = this.buffer
scroll.start -= buffer
scroll.end += buffer
// Variable size mode
if (itemSize === null) {
let h
let a = 0
let b = count - 1
let i = ~~(count / 2)
let oldI
// Searching for startIndex
do {
oldI = i
h = sizes[i].accumulator
if (h < scroll.start) {
a = i
} else if (
i < count - 1 &&
sizes[i + 1].accumulator > scroll.start
) {
b = i
}
i = ~~((a + b) / 2)
} while (i !== oldI)
i < 0 && (i = 0)
startIndex = i
// For container style
totalSize = sizes[count - 1].accumulator
// Searching for endIndex
for (
endIndex = i;
endIndex < count && sizes[endIndex].accumulator < scroll.end;
endIndex++
);
if (endIndex === -1) {
endIndex = items.length - 1
} else {
endIndex++
// Bounds
endIndex > count && (endIndex = count)
}
} else {
// Fixed size mode
startIndex = ~~(scroll.start / itemSize)
endIndex = Math.ceil(scroll.end / itemSize)
// Bounds
startIndex < 0 && (startIndex = 0)
endIndex > count && (endIndex = count)
totalSize = count * itemSize
}
}
if (endIndex - startIndex > config.itemsLimit) {
this.itemsLimitError()
}
this.totalSize = totalSize
let view
const continuous =
startIndex <= this.$_endIndex && endIndex >= this.$_startIndex
let unusedIndex
if (this.$_continuous !== continuous) {
if (continuous) {
views.clear()
unusedViews.clear()
for (let i = 0, l = pool.length; i < l; i++) {
view = pool[i]
this.unuseView(view)
}
}
this.$_continuous = continuous
} else if (continuous) {
for (let i = 0, l = pool.length; i < l; i++) {
view = pool[i]
if (view.nr.used) {
// Update view item index
if (checkItem) {
view.nr.index = items.findIndex((item) =>
keyField
? item[keyField] === view.item[keyField]
: item === view.item
)
}
// Check if index is still in visible range
if (
view.nr.index === -1 ||
view.nr.index < startIndex ||
view.nr.index >= endIndex
) {
this.unuseView(view)
}
}
}
}
if (!continuous) {
unusedIndex = new Map()
}
let item, type, unusedPool
let v
for (let i = startIndex; i < endIndex; i++) {
item = items[i]
const key = keyField ? item[keyField] : item
view = views.get(key)
if (!itemSize && !sizes[i].size) {
if (view) this.unuseView(view)
continue
}
// No view assigned to item
if (!view) {
type = item[typeField]
if (continuous) {
unusedPool = unusedViews.get(type)
// Reuse existing view
if (unusedPool && unusedPool.length) {
view = unusedPool.pop()
view.item = item
view.nr.used = true
view.nr.index = i
view.nr.key = key
view.nr.type = type
} else {
view = this.addView(pool, i, item, key, type)
}
} else {
unusedPool = unusedViews.get(type)
v = unusedIndex.get(type) || 0
// Use existing view
// We don't care if they are already used
// because we are not in continous scrolling
if (unusedPool && v < unusedPool.length) {
view = unusedPool[v]
view.item = item
view.nr.used = true
view.nr.index = i
view.nr.key = key
view.nr.type = type
unusedIndex.set(type, v + 1)
} else {
view = this.addView(pool, i, item, key, type)
this.unuseView(view, true)
}
v++
}
views.set(key, view)
} else {
view.nr.used = true
view.item = item
}
// Update position
if (itemSize === null) {
view.position = sizes[i - 1].accumulator
} else {
view.position = i * itemSize
}
}
this.$_startIndex = startIndex
this.$_endIndex = endIndex
if (this.emitUpdate) this.$emit('update', startIndex, endIndex)
/* Additional event */
if (endIndex === this.items.length) this.$emit('bottom', startIndex, endIndex)
/* !Additional event */
return {
continuous
}
}
}
}
Thanks @invisor, but my main point was that I wanted this as part of the (official) api. I already have a workaround – I just thought, that many people would have the same use case ...
I'd love to have this built in, would be really useful for my use case.
@wzc0x0 Did you manage to make this work with vue-infinite-scroll?? I'm trying to make it work with DynamicScroller but it's calling the event as soon as the container renders
Hello everyone, personally I found every solution relying on scrollHeight
or offsetHeight
so on where scroll is, to be unreliable because scroll isn't exactly as it should be, its content height is estimated based on presumption of min-height
of not yet rendered elements. In other words there is no way of knowing height of not rendered elements. So what I did used placeholder on bottom. (in my implementation, not showed here I also did infinite scrolling to top - might be useful e.g for loading older comments scrolling to top)
// ITS not full code but just to show solution I did
// Using unrefElement from vueuse
import { unrefElement, useDebounceFn } from '@vueuse/core';
// element is placeholder element (ref="lastElementRef") placed in #after slot
// searchList is virtualbox list element (ref)
const isRendered = (elm: HTMLElement) => (unrefElement(elm) as HTMLElement).offsetHeight > 0;
const isVisible = (element: HTMLElement, searchList: HTMLElement, distance: number) => {
const ele = unrefElement(element) as HTMLElement;
const eleTop = ele.offsetTop;
const eleBottom = eleTop + ele.clientHeight;
const container = unrefElement(searchList) as HTMLElement;
const containerTop = container.scrollTop;
const containerBottom = containerTop + container.clientHeight;
return (
(eleTop - distance >= containerTop && eleBottom + distance <= containerBottom) ||
(eleTop - distance < containerTop && containerTop < eleBottom + distance) ||
(eleTop - distance < containerBottom && containerBottom < eleBottom + distance)
);
};
const isLastElementVisible = (lastElementRef: HTMLElement, listRef: HTMLElement, distance: number) =>
isRendered(lastElementRef) && isVisible(lastElementRef, listRef, distance);
//Event attached on virtual list @scroll.native.passive="onScroll" - however in vue3 I had problem with attaching event on component, but still can be attached listener on ref element
const onScroll = useDebounceFn(async () => {
if (isLastElementVisible(lastElementRef.value, listRef.value, distance.value)) {
await triggerCallback(); //Your promise callback
return;
}
}, debounceScroll.value); //Debounce on scroll just to not spam with events - its up to you how big it is, I use 50ms
<template #after>
<div ref="lastElementRef" :class="$style['placeholder']" /> //Placeholder 'element; we use to check if someone reached end of list
</template>
And placeholder needs to be visible so it needs to be at least 1px tall
<style lang="scss" module>
.placeholder {
height: 1px;
}
</style>
Hopefully it will help someone, its easy solution, not bad in performance terms, can be done the same while scrolling to top. Not so many code and works to pixel accuracy.
Cheers