How to track and save the last scroll position of a virtual scroll list in javascript

1.9k Views Asked by At

I have a virtual scrolling list built with JS in a Cordova app and I want to save the exact node that was at the top of the viewport after each scroll. The complication with virtual scroll is that using scrollTop is not reliable because nodes are being removed from the top and bottom of the list, which changes scroll position. Is there a reliable and performant way to do this in Javascript? The use case is this: when the user closes and reopens the app, I want to place the user at the same scroll position and ensure that the element at that position is the element that the user last saw right before closing the app. I'm considering this strategy:

  • get the element at the top of the list on each scroll, something like this:
scrollableList.addEventListener('scroll', e => {
  const topMostElement = document.elementFromPoint(150, 150); // <-- 150px due to some positioning in the app
});
  • saving an identifier for that element so that I can then preload the list on app init such that the element is always rendered in that position

I'd like to know if there are other strategies for dealing with this issue in a JS-based virtual scroller. I've seen tutorials for building a virtual scroller in Javascript but haven't seen one that provides implementation details on how to get around this problem. Thanks in advance!

Update 1

The virtual scroll list has a lot of nodes, can be more than 100, each with its own child nodes. This causes its own set of problems so it's important that (where possible) the solution doesn't require iterating through the list of nodes or performing expensive operations on each node, to avoid blocking the main thread.

2

There are 2 best solutions below

1
On
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <style >
        .scroll-item {
            height: 400px;
            background: red;
        }

        .scroll-item:nth-of-type(2n) {
            background: #000;
        }
    </style>
</head>

<body>
    <div id="scrollable-list">
        <div id="item-1" class="scroll-item"></div>
        <div id="item-2" class="scroll-item"></div>
        <div id="item-3" class="scroll-item"></div>
        <div id="item-4" class="scroll-item"></div>
        <div id="item-5" class="scroll-item"></div>
    </div>

    <script>
        const lastItem = localStorage.getItem('lastItem');
        if (lastItem) {
            const itemElement = document.getElementById(lastItem);
            if (itemElement) {
                itemElement.scrollIntoView();
            }
        }
        let options = {
            rootMargin: '0px',
            threshold: 1.0
        }
        const target = document.querySelectorAll('.scroll-item');
        const observer = new IntersectionObserver((entries, observer) => {
            entries.forEach(entry => {
                if (!entry.isIntersecting) return;
                console.log(entry.target);
                localStorage.setItem('lastItem', entry.target.id);
            })
        }, options);

        target.forEach(item => {
            observer.observe(item);
        });
    </script>
</body>

</html>

You can use IntesectionObserver and save the current element inside the localstorage

example: https://jsfiddle.net/6bu0ynkp/

  • first, we are going to select all "scroll-item" items
  • then we are going to create a new IntersectionObserver and loop over the entries to check if the item is intersecting
  • if the item inside the viewport we are going to save its id to localStorage
  • on the next page reload we are checking if we have "lastItem" and scroll the item into view
1
On

My guess is you may save row index, not position. This seems more semantically. Say, your datasource has items from MIN to MAX indexes. By persisting START index, you may initialize the view along with the simplest calculation of the initial position as follows:

await renderVirtualScrollUI();
viewportElement.scrollTop = (START - MIN) * ITEM_SIZE;

You may read START value from the API, Local Storage, query string, whatever. Now how it can be determined. As one of the possible approaches I would traverse viewport's inner elements and look for the first visible one using getBoundingClientRect:

const getTopRowIndex = () => {
  const rows = viewportElement.children; // depends on the template
  const viewportTop = viewportElement.getBoundingClientRect().top;
  for (let i = 0; i < rows.length; i++) {
    const row = rows[i];
    const rowTop = row.getBoundingClientRect().top;
    if (rowTop > viewportTop) {
      // calculate topmost visible index based on position
      const index = Math.floor(viewportElement.scrollTop / ITEM_SIZE) + MIN;
      persistTopIndex(index);
      return;
    }
  }
}

Run getTopRowIndex() method when you want to save the value. For example, in response to scroll event throttled by 500ms (via lodash.throttle):

viewportElement.addEventListener('scroll', _.throttle(getTopRowIndex, 500));