Search code examples
angularscrollangular-cdkangular-cdk-virtual-scroll

Custom scrollToIndex for mixed height items in a virtual scroll viewport


  • I'm using CDK virtual scrolling to display a list of items on a page. Worst case scenario for number of items is around 2K.
  • I'm using the default fixed size scrolling strategy.
  • The source for the data is a DataSource with paging. There is some delay when fetching subsequent pages.
  • The list elements can have a varying height based on the data they contain.
  • There is some non-virtualized content before the list elements. I'm using a separate viewport and scrolling element

The problem I want to solve is to be able to programmatically scroll to an item using it's index. A sample API would be:

async scrollToIndex(viewPort: CdkVirtualScrollViewport, index: number): Promise<boolean>

Here is the high level overview of the approach I'm using:

  1. User initiates scrolling to index N
  2. Make a guess for the offset of index N using the formula offset + (N * itemSize). This guess is expected to be inaccurate as there are different itemSizes.
  3. Scroll to this offset and get the rendered range using const range = viewport.getRenderedRange()
  4. There are 3 possibilities
    • N < range.start - the guess was too high, correct offset and go to 2
    • N >= range.end - the guess was too low, correct offset and go to 2
    • range.start <= N < range.end - the guess is good enough to bring N into the viewport, go to 5
  5. Final scrolling - use scrollIntoView() to scroll the element with index N to the top

This approach is working however there are some issues with it:

  1. There needs to be a delay between the operations in Step 3
      guess = this.getGuess(index, offset, itemHeight);
      viewPort.scrollToOffset(guess);
      await delayFor(400); // ARBITRARY DELAY
      const renderedRange = viewPort.getRenderedRange();
  1. Sometimes the scoll in step 5 doesn't bring the element to the top, it will be a couple of pixels off

I need this to work on different mobile devices, which have varying performance characteristics, so I can't use an arbitrary delay, it could work on my PC/device but for some other device the delay could be not enough. I'm looking for a better solution without a hardcoded delay. For example wait for some event signifying that the range has changed after the scrollToOffset() call.

Regarding issue 2 I'm not sure why it's happening and therefore don't know how to fix it. I'm more interested in solving the first issue but any help is appreciated. On the example scrolling to index 2 gives me incorrect results.

I created a minimal example here - in this example every 3rd list element is taller than the other elements.


Solution

  • To solve your problems, try this approach without unnecessary delays and handle scrolling more efficiently. Also, for the issue of the element not scrolling to the top, use the scrolledIndexChange event to track when the viewport has finished scrolling and then scroll the element into view.

    import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling';
    
    async function scrollToIndex(viewport: CdkVirtualScrollViewport, index: number): Promise<boolean> {
      // Step 1: Make a guess for the offset
      const itemHeight = getItemHeight(index); // Implement a function to get the item height based on the index
    
      const guess = index * itemHeight;
    
      // Step 2: Scroll to the guessed offset
      viewport.scrollToOffset(guess);
    
      // Step 3: Wait for the viewport to finish scrolling
      await new Promise(resolve => {
        const subscription = viewport.scrolledIndexChange.subscribe(() => {
          subscription.unsubscribe();
          resolve();
        });
      });
    
      // Step 4: Get the rendered range
      const renderedRange = viewport.getRenderedRange();
    
      // Step 5: Check if the item is in the viewport
      if (index < renderedRange.start) {
        // The guess was too high, correct offset and scroll again
        return scrollToIndex(viewport, index);
      } else if (index >= renderedRange.end) {
        // The guess was too low, correct offset and scroll again
        const correctedOffset = (index + 1) * itemHeight - viewport.getViewportSize().height;
        viewport.scrollToOffset(correctedOffset);
        return scrollToIndex(viewport, index);
      } else {
        // The guess is good enough, scroll the element into view
        const element = viewport.elementRef.nativeElement.querySelector(`[cdkvirtualforitemindex="${index}"]`);
        if (element) {
          element.scrollIntoView({ behavior: 'auto', block: 'start', inline: 'nearest' });
          return true;
        }
        return false;
      }
    }
    
    // Example usage:
    const indexToScroll = 2; // Replace with the desired index
    await scrollToIndex(yourViewportInstance, indexToScroll);