vue-virtual-scroller icon indicating copy to clipboard operation
vue-virtual-scroller copied to clipboard

proposal: an api for loading more data if scroller has reached last the items

Open qwesda opened this issue 5 years ago • 13 comments

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

qwesda avatar Feb 26 '19 15:02 qwesda

I would propose the following api:

  • new event itemEnd – calls a function with one argument itemEndIndex when the item with itemIndex === items.length - itemEndThreshold for the first time
  • new prop: emitItemEnd (int) default null – if set to a positive integer the conditions for the itemEnd event will be checked, otherwise not
    • if emitItemEnd === 1 the event will be fired if the last item is loaded, if emitItemEnd === 10 the event will be fired if one of the last 10 items is loaded

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 avatar Feb 27 '19 11:02 qwesda

@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 avatar Mar 06 '19 17:03 tibineagu

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

qwesda avatar Mar 06 '19 18:03 qwesda

I think should tell some examples about how to handle the infinite scroller !

wzc0x0 avatar May 30 '19 17:05 wzc0x0

@qwesda you can use vue-infinite-scroll wrapper the RecycleScroller outside

wzc0x0 avatar May 31 '19 03:05 wzc0x0

@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

feliperaul avatar Sep 16 '19 18:09 feliperaul

yes, that API looks exactly like what I would have needed.

qwesda avatar Sep 16 '19 18:09 qwesda

We really have unnecessary events but have not necessary ones

invisor avatar Jan 23 '20 06:01 invisor

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

invisor avatar Jan 23 '20 07:01 invisor

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

qwesda avatar Jan 23 '20 16:01 qwesda

I'd love to have this built in, would be really useful for my use case.

fgilio avatar Jan 23 '20 18:01 fgilio

@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

hriverahdez avatar Jun 09 '20 14:06 hriverahdez

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

Szymon-dziewonski avatar Oct 05 '21 09:10 Szymon-dziewonski