From 5f0c86147014c00ceb42c48f99c0306397b09634 Mon Sep 17 00:00:00 2001
From: Maximilian Koeller <maximilian.koeller@siemens.com>
Date: Wed, 29 Mar 2023 19:17:04 +0200
Subject: [PATCH] feat(cdk-experimental/scrolling): support scrollToIndex

implements `VirtualScrollStrategy#scrollToIndex` in `AutoSizeVirtualScrollStrategy`.

See #10113
---
 .../scrolling/auto-size-virtual-scroll.ts     | 344 ++++++++++++++++--
 src/cdk/scrolling/virtual-for-of.ts           |   2 +-
 .../scrolling/virtual-scroll-viewport.spec.ts |   4 +-
 .../virtual-scroll/virtual-scroll-demo.html   |  34 +-
 .../virtual-scroll/virtual-scroll-demo.ts     |   4 +-
 5 files changed, 356 insertions(+), 32 deletions(-)

diff --git a/src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts b/src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts
index 1f5fb1a84610..615e723eab03 100644
--- a/src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts
+++ b/src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts
@@ -99,6 +99,12 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy {
   /** The last measured size of the rendered content in the viewport. */
   private _lastRenderedContentOffset: number;
 
+  /**
+   * The last rendered total content size based on the estimated item size.
+   *  Initialized with zero, as it will be used before properly calculated the first time.
+   */
+  private _lastRenderedTotalContentSize = 0;
+
   /**
    * The number of consecutive cycles where removing extra items has failed. Failure here means that
    * we estimated how many items we could safely remove, but our estimate turned out to be too much
@@ -106,6 +112,16 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy {
    */
   private _removalFailures = 0;
 
+  /** Target information when scrolling to an index. */
+  private _scrollToIndexTarget?: {
+    readonly index: number;
+    readonly fromIndex: number;
+    readonly offset: number;
+    readonly delta: number;
+    forceRenderedContentAdjustment?: boolean;
+    optimalOffsetAdjustmentDone?: boolean;
+  };
+
   /**
    * @param minBufferPx The minimum amount of buffer rendered beyond the viewport (in pixels).
    *     If the amount of buffer dips below this number, more items will be rendered.
@@ -165,13 +181,94 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy {
   }
 
   /** Scroll to the offset for the given index. */
-  scrollToIndex(): void {
-    if (typeof ngDevMode === 'undefined' || ngDevMode) {
-      // TODO(mmalerba): Implement.
-      throw Error(
-        'cdk-virtual-scroll: scrollToIndex is currently not supported for the autosize' +
-          ' scroll strategy',
-      );
+  scrollToIndex(index: number, behavior: ScrollBehavior): void {
+    if (this._viewport) {
+      const viewport = this._viewport;
+      const itemSize = this._averager.getAverageItemSize();
+      const renderedRange = viewport.getRenderedRange();
+      const currentIndex = this._getFirstVisibleIndex();
+
+      if (this._isIndexInRange(index, renderedRange)) {
+        // Index is within the rendered range, so we scroll by the exact amount of pixels
+        const toOffset = Math.round(
+          viewport.measureRangeSize({start: renderedRange.start, end: index - 1}) +
+            this._lastRenderedContentOffset,
+        );
+        this._scrollToIndexTarget = {
+          index,
+          fromIndex: currentIndex,
+          offset: toOffset,
+          delta: Math.abs(toOffset - this._lastScrollOffset),
+        };
+      } else {
+        // Index is out of rendered range, so the target offset is estimated.
+
+        let targetOffset: number;
+        const estimatedTargetOffset = Math.min(
+          this._getScrollOffsetForIndex(index),
+          this._lastRenderedTotalContentSize - viewport.getViewportSize(),
+        );
+        const predictedContentOffset = this._getScrollOffsetForIndex(renderedRange.start);
+        const contentOffsetDifference = predictedContentOffset - this._lastRenderedContentOffset;
+        const estimatedScrollMagnitude = Math.abs(this._lastScrollOffset - estimatedTargetOffset);
+        let relativeAdjustment: number;
+        if (index < renderedRange.start) {
+          // scrolling to start
+          // The corrected amount is relative to the amount from the scroll magnitude to remaining space to the top (=target offset).
+          relativeAdjustment = Math.min(estimatedTargetOffset / estimatedScrollMagnitude, 1);
+        } else {
+          // scrolling to end
+          // The corrected amount is relative to the amount from the scroll magnitude to remaining space to the bottom.
+          relativeAdjustment = Math.min(
+            (this._lastRenderedTotalContentSize -
+              viewport.getViewportSize() -
+              estimatedTargetOffset) /
+              estimatedScrollMagnitude,
+            1,
+          );
+        }
+        const offsetCorrection = contentOffsetDifference * relativeAdjustment;
+        targetOffset = estimatedTargetOffset - offsetCorrection;
+
+        if (
+          targetOffset > this._lastRenderedContentOffset &&
+          targetOffset < this._lastRenderedContentOffset + this._lastRenderedContentSize
+        ) {
+          // We are not scrolling beyond the current rendered content, but we should as our target index is not rendered yet.
+          // Adjusting the targetOffset, to scroll at least beyond the current rendered content.
+          if (index < renderedRange.start) {
+            const renderedContentSizeBeforeCurrentIndex = viewport.measureRangeSize({
+              start: renderedRange.start,
+              end: currentIndex - 1,
+            });
+            targetOffset =
+              this._lastScrollOffset -
+              renderedContentSizeBeforeCurrentIndex -
+              renderedContentSizeBeforeCurrentIndex +
+              this._lastRenderedContentOffset -
+              this._lastScrollOffset - // portion of the current index, which already scrolled away
+              (renderedRange.start - index - 1) * itemSize;
+          } else {
+            targetOffset =
+              this._lastScrollOffset +
+              viewport.measureRangeSize({
+                start: currentIndex,
+                end: renderedRange.end,
+              }) +
+              itemSize / 2 +
+              (index - renderedRange.end) * itemSize;
+          }
+        }
+
+        const toOffset = Math.round(targetOffset);
+        this._scrollToIndexTarget = {
+          index,
+          fromIndex: currentIndex,
+          offset: toOffset,
+          delta: Math.abs(toOffset - this._lastScrollOffset),
+        };
+      }
+      viewport.scrollToOffset(this._scrollToIndexTarget.offset, behavior);
     }
   }
 
@@ -206,8 +303,9 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy {
     // If we're scrolling toward the top, we need to account for the fact that the predicted amount
     // of content and the actual amount of scrollable space may differ. We address this by slowly
     // correcting the difference on each scroll event.
+    // When scrolling to an index, the offset must not be corrected. As we need to scroll precisely in this case.
     let offsetCorrection = 0;
-    if (scrollDelta < 0) {
+    if (scrollDelta < 0 && !this._scrollToIndexTarget) {
       // The content offset we would expect based on the average item size.
       const predictedOffset = renderedRange.start * this._averager.getAverageItemSize();
       // The difference between the predicted size of the un-rendered content at the beginning and
@@ -231,6 +329,18 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy {
       scrollMagnitude = Math.abs(scrollDelta);
     }
 
+    if (this._scrollToIndexTarget) {
+      const correctedRange = this._getCorrectedRangeForIndexScrolling(scrollOffset);
+      // We need force rendering the content when the target item is entering the rendered range the first time.
+      // Even if there is currently no sufficient underscan or the scroll magnitude is smaller than the viewport.
+      // We do this, so that we can calculate the optimal offset as early as possible, so that we can
+      // reduce the jitterness when adjusting the content.
+      this._scrollToIndexTarget.forceRenderedContentAdjustment =
+        this._isIndexInRange(this._scrollToIndexTarget.index, correctedRange) &&
+        (!this._isIndexInRange(this._scrollToIndexTarget.index, renderedRange) ||
+          !this._scrollToIndexTarget.optimalOffsetAdjustmentDone);
+    }
+
     // The current amount of buffer past the start of the viewport.
     const startBuffer = this._lastScrollOffset - this._lastRenderedContentOffset;
     // The current amount of buffer past the end of the viewport.
@@ -244,13 +354,19 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy {
       scrollMagnitude + this._minBufferPx - (scrollDelta < 0 ? startBuffer : endBuffer);
 
     // Check if there's unfilled space that we need to render new elements to fill.
-    if (underscan > 0) {
+    if (
+      (underscan > 0 || this._scrollToIndexTarget?.forceRenderedContentAdjustment) &&
+      !this._scrollToIndexTarget?.optimalOffsetAdjustmentDone
+    ) {
       // Check if the scroll magnitude was larger than the viewport size. In this case the user
       // won't notice a discontinuity if we just jump to the new estimated position in the list.
       // However, if the scroll magnitude is smaller than the viewport the user might notice some
       // jitteriness if we just jump to the estimated position. Instead we make sure to scroll by
       // the same number of pixels as the scroll magnitude.
-      if (scrollMagnitude >= viewport.getViewportSize()) {
+      if (
+        scrollMagnitude >= viewport.getViewportSize() ||
+        this._scrollToIndexTarget?.forceRenderedContentAdjustment
+      ) {
         this._renderContentForCurrentOffset();
       } else {
         // The number of new items to render on the side the user is scrolling towards. Rather than
@@ -301,7 +417,7 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy {
         if (scrollDelta < 0) {
           let removedSize = viewport.measureRangeSize({
             start: range.end,
-            end: renderedRange.end,
+            end: renderedRange.end - 1,
           });
           // Check that we're not removing too much.
           if (removedSize <= overscan) {
@@ -319,7 +435,7 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy {
         } else {
           const removedSize = viewport.measureRangeSize({
             start: renderedRange.start,
-            end: range.start,
+            end: range.start - 1,
           });
           // Check that we're not removing too much.
           if (removedSize <= overscan) {
@@ -345,6 +461,10 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy {
       viewport.setRenderedContentOffset(this._lastRenderedContentOffset + offsetCorrection);
     }
 
+    if (this._scrollToIndexTarget?.offset === scrollOffset) {
+      Promise.resolve().then(() => (this._scrollToIndexTarget = undefined));
+    }
+
     // Save the scroll offset to be compared to the new value on the next scroll event.
     this._lastScrollOffset = scrollOffset;
   }
@@ -357,7 +477,12 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy {
     const viewport = this._viewport!;
     this._lastRenderedContentSize = viewport.measureRenderedContentSize();
     this._averager.addSample(viewport.getRenderedRange(), this._lastRenderedContentSize);
-    this._updateTotalContentSize(this._lastRenderedContentSize);
+
+    // We cannot update the total content size when scrolling to an index which is after the current offset.
+    // Otherwise, we may add space after the last item.
+    if (!this._scrollToIndexTarget || this._scrollToIndexTarget.offset < this._lastScrollOffset) {
+      this._updateTotalContentSize(this._lastRenderedContentSize);
+    }
   }
 
   /** Checks the currently rendered content offset and saves the value for later use. */
@@ -368,28 +493,173 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy {
 
   /**
    * Recalculates the rendered content based on our estimate of what should be shown at the current
-   * scroll offset.
+   * scroll offset or, if present, based on the scrollToIndexTarget.
    */
   private _renderContentForCurrentOffset() {
     const viewport = this._viewport!;
     const scrollOffset = viewport.measureScrollOffset();
+    const itemSize = this._averager.getAverageItemSize();
+    const bufferSize = Math.ceil(this._maxBufferPx / itemSize);
     this._lastScrollOffset = scrollOffset;
     this._removalFailures = 0;
 
-    const itemSize = this._averager.getAverageItemSize();
+    // The first index is based on the scroll offset in relation to the total content size
     const firstVisibleIndex = Math.min(
       viewport.getDataLength() - 1,
-      Math.floor(scrollOffset / itemSize),
+      Math.round(
+        (scrollOffset / (this._lastRenderedTotalContentSize - viewport.getViewportSize())) *
+          viewport.getDataLength(),
+      ),
     );
-    const bufferSize = Math.ceil(this._maxBufferPx / itemSize);
+
     const range = this._expandRange(
       this._getVisibleRangeForIndex(firstVisibleIndex),
       bufferSize,
       bufferSize,
     );
 
-    viewport.setRenderedRange(range);
-    viewport.setRenderedContentOffset(itemSize * range.start);
+    let correctedRange = range;
+    let offsetCorrection = 0;
+    let optimalOffsetAdjustmentDoing = false;
+
+    if (this._scrollToIndexTarget) {
+      // scrolling to a specific index
+      // In this case, we must ensure, that we precisely scroll to a specific item.
+
+      // we adjust the content within this block, so we need to reset the force flag
+      this._scrollToIndexTarget.forceRenderedContentAdjustment = false;
+
+      const {offset: targetOffset, index: targetIndex} = this._scrollToIndexTarget;
+      correctedRange = this._getCorrectedRangeForIndexScrolling(scrollOffset);
+      if (
+        this._isIndexInRange(targetIndex, correctedRange) &&
+        !this._scrollToIndexTarget.optimalOffsetAdjustmentDone
+      ) {
+        // We need to correct the content offset, in case the scroll strategy does not provide exact item sizes.
+        // Without this, the target index might not be on top in the viewport, if the preceding visible items
+        // have a different size than estimated.
+        // correctedRange = this._expandRange(correctedRange, 1, 0);
+        optimalOffsetAdjustmentDoing = true;
+        this._scrollToIndexTarget.optimalOffsetAdjustmentDone = true;
+        setTimeout(() => {
+          const renderedRange = viewport.getRenderedRange();
+          if (
+            renderedRange.end === viewport.getDataLength() &&
+            viewport.measureRangeSize({start: targetIndex, end: renderedRange.end}) <
+              viewport.getViewportSize()
+          ) {
+            // No more items left to the end and our target is estimated to be within the viewport when we reach the end,
+            // so we must perfectly align with the end of the viewport.
+            viewport.setRenderedContentOffset(
+              this._lastRenderedTotalContentSize - this._lastRenderedContentSize,
+            );
+          } else {
+            // This is the first time, our target index is rendered. We can now calculate the optimal content offset
+            // so that we can perfectly scroll to it.
+            const optimalOffset =
+              scrollOffset -
+              this._viewport!.measureRangeSize({
+                start: renderedRange.start,
+                end: targetIndex - 1,
+              }) +
+              targetOffset -
+              scrollOffset;
+
+            // The rendered range needs to be adopted to reflect our offset modification. Otherwise, we may have unfilled space in the viewport.
+            // We only append items if there are not enough. Prepending and removing items would require offset adjustment and may cause jitterness.
+            if (optimalOffset < this._lastRenderedContentOffset) {
+              const bufferExtend = Math.ceil(
+                (this._lastRenderedContentOffset - optimalOffset) / itemSize,
+              );
+              viewport.setRenderedRange(this._expandRange(renderedRange, 0, bufferExtend));
+            }
+
+            this._viewport!.setRenderedContentOffset(
+              this._lastScrollOffset -
+                this._viewport!.measureRangeSize({
+                  start: renderedRange.start,
+                  end: targetIndex - 1,
+                }) +
+                targetOffset -
+                scrollOffset,
+            );
+          }
+        });
+      }
+    }
+
+    if (!this._scrollToIndexTarget?.optimalOffsetAdjustmentDone || optimalOffsetAdjustmentDoing) {
+      console.log('regular adjustment', correctedRange);
+      console.log(this._scrollToIndexTarget);
+      viewport.setRenderedRange(correctedRange);
+      viewport.setRenderedContentOffset(
+        (this._lastRenderedTotalContentSize / viewport.getDataLength()) * range.start -
+          offsetCorrection,
+      );
+    }
+  }
+
+  /**
+   * Get the range to render when scrolling to specific index for a scroll offset
+   * Note: can only be called when scrolling to an index
+   * @param scrollOffset the scroll offset to get the range for
+   * @return a range that contains items the should be visible for the provided scroll offset
+   */
+  private _getCorrectedRangeForIndexScrolling(scrollOffset: number) {
+    const {delta, offset: targetOffset, index, fromIndex} = this._scrollToIndexTarget!;
+    const bufferSize = Math.ceil(this._maxBufferPx / this._averager.getAverageItemSize());
+
+    const relativeProgress = (delta - targetOffset + scrollOffset) / delta;
+    const currentIndex = (index - fromIndex) * relativeProgress + fromIndex;
+    return this._expandRange(
+      this._getVisibleRangeForIndex(Math.round(currentIndex)),
+      bufferSize,
+      bufferSize,
+    );
+  }
+
+  /** Calculates the scrollOffset for an index based on the available max scroll offset */
+  private _getScrollOffsetForIndex(index: number) {
+    const viewport = this._viewport!;
+    return Math.round(
+      (index / viewport.getDataLength()) *
+        (this._lastRenderedTotalContentSize - viewport.getViewportSize()),
+    );
+  }
+
+  /** Precisely reads the first visible index */
+  private _getFirstVisibleIndex() {
+    const viewport = this._viewport!;
+    const renderedRange = viewport.getRenderedRange();
+    const itemSize = this._averager.getAverageItemSize();
+
+    let renderedStartOverflow = Math.round(
+      this._lastScrollOffset - this._lastRenderedContentOffset,
+    );
+    let firstVisibleIndex = Math.min(
+      Math.floor(renderedStartOverflow / itemSize) + renderedRange.start,
+      renderedRange.end - 1,
+    );
+
+    let corrected = true;
+    do {
+      const itemStart = Math.round(
+        viewport.measureRangeSize({start: renderedRange.start, end: firstVisibleIndex - 1}),
+      );
+      const itemEnd = Math.round(
+        viewport.measureRangeSize({start: renderedRange.start, end: firstVisibleIndex}),
+      );
+
+      if (itemStart > renderedStartOverflow) {
+        firstVisibleIndex--;
+      } else if (itemEnd < renderedStartOverflow) {
+        firstVisibleIndex++;
+      } else {
+        corrected = false;
+      }
+    } while (corrected);
+
+    return firstVisibleIndex;
   }
 
   // TODO: maybe move to base class, can probably share with fixed size strategy.
@@ -414,6 +684,11 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy {
     return range;
   }
 
+  /** Checks if index is in the given range. */
+  private _isIndexInRange(index: number, range: ListRange) {
+    return range.start <= index && range.end > index;
+  }
+
   // TODO: maybe move to base class, can probably share with fixed size strategy.
   /**
    * Expand the given range by the given amount in either direction.
@@ -434,11 +709,28 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy {
   private _updateTotalContentSize(renderedContentSize: number) {
     const viewport = this._viewport!;
     const renderedRange = viewport.getRenderedRange();
-    const totalSize =
-      renderedContentSize +
-      (viewport.getDataLength() - (renderedRange.end - renderedRange.start)) *
-        this._averager.getAverageItemSize();
-    viewport.setTotalContentSize(totalSize);
+    const itemSize = this._averager.getAverageItemSize();
+
+    if (!this._lastRenderedTotalContentSize) {
+      // initially use the estimated item size to calculate the total size
+      this._lastRenderedTotalContentSize =
+        renderedContentSize +
+        (viewport.getDataLength() - (renderedRange.end - renderedRange.start)) * itemSize;
+    }
+
+    // The total content size might be completely off, as it is not updated when scrolling to an index.
+    // We only update it slightly by just adding/removing space based on what is missing from the currently rendered range.
+    // This ensures that we do not add more space at the end than we have content to fill it.
+    const neededSpace = Math.round((viewport.getDataLength() - renderedRange.end) * itemSize);
+    const availableSpace =
+      this._lastRenderedTotalContentSize - this._lastRenderedContentOffset - renderedContentSize;
+    const correction = Math.round(neededSpace - availableSpace);
+
+    if (correction) {
+      this._lastRenderedTotalContentSize += correction;
+    }
+
+    viewport.setTotalContentSize(this._lastRenderedTotalContentSize);
   }
 }
 
@@ -472,9 +764,11 @@ export class CdkAutoSizeVirtualScroll implements OnChanges {
   get minBufferPx(): number {
     return this._minBufferPx;
   }
+
   set minBufferPx(value: NumberInput) {
     this._minBufferPx = coerceNumberProperty(value);
   }
+
   _minBufferPx = 100;
 
   /**
@@ -487,9 +781,11 @@ export class CdkAutoSizeVirtualScroll implements OnChanges {
   get maxBufferPx(): number {
     return this._maxBufferPx;
   }
+
   set maxBufferPx(value: NumberInput) {
     this._maxBufferPx = coerceNumberProperty(value);
   }
+
   _maxBufferPx = 200;
 
   /** The scroll strategy used by this directive. */
diff --git a/src/cdk/scrolling/virtual-for-of.ts b/src/cdk/scrolling/virtual-for-of.ts
index f6bc01b86447..f6d8b2b6ffb9 100644
--- a/src/cdk/scrolling/virtual-for-of.ts
+++ b/src/cdk/scrolling/virtual-for-of.ts
@@ -246,7 +246,7 @@ export class CdkVirtualForOf<T>
     }
 
     // Find the last node by starting from the end and going backwards.
-    for (let i = rangeLen - 1; i > -1; i--) {
+    for (let i = rangeLen; i > -1; i--) {
       const view = this._viewContainerRef.get(i + renderedStartIndex) as EmbeddedViewRef<
         CdkVirtualForOfContext<T>
       > | null;
diff --git a/src/cdk/scrolling/virtual-scroll-viewport.spec.ts b/src/cdk/scrolling/virtual-scroll-viewport.spec.ts
index c61ec400f7d9..484cd52ba85a 100644
--- a/src/cdk/scrolling/virtual-scroll-viewport.spec.ts
+++ b/src/cdk/scrolling/virtual-scroll-viewport.spec.ts
@@ -151,7 +151,7 @@ describe('CdkVirtualScrollViewport', () => {
     it('should measure range size', fakeAsync(() => {
       finishInit(fixture);
 
-      expect(viewport.measureRangeSize({start: 1, end: 3}))
+      expect(viewport.measureRangeSize({start: 2, end: 3}))
         .withContext('combined size of 2 50px items should be 100px')
         .toBe(testComponent.itemSize * 2);
     }));
@@ -160,7 +160,7 @@ describe('CdkVirtualScrollViewport', () => {
       fixture.componentInstance.hasMargin = true;
       finishInit(fixture);
 
-      expect(viewport.measureRangeSize({start: 1, end: 3}))
+      expect(viewport.measureRangeSize({start: 2, end: 3}))
         .withContext('combined size of 2 50px items with a 10px margin should be 110px')
         .toBe(testComponent.itemSize * 2 + 10);
     }));
diff --git a/src/dev-app/virtual-scroll/virtual-scroll-demo.html b/src/dev-app/virtual-scroll/virtual-scroll-demo.html
index 76c17f993488..7de43ab6df98 100644
--- a/src/dev-app/virtual-scroll/virtual-scroll-demo.html
+++ b/src/dev-app/virtual-scroll/virtual-scroll-demo.html
@@ -9,7 +9,8 @@ <h3>Uniform size</h3>
 </cdk-virtual-scroll-viewport>
 
 <h3>Increasing size</h3>
-<cdk-virtual-scroll-viewport class="demo-viewport" autosize>
+
+<cdk-virtual-scroll-viewport class="demo-viewport" autosize #increasingViewport>
   <div *cdkVirtualFor="let size of increasingSizeData; let i = index" class="demo-item"
        [style.height.px]="size">
     Item #{{i}} - ({{size}}px)
@@ -17,7 +18,34 @@ <h3>Increasing size</h3>
 </cdk-virtual-scroll-viewport>
 
 <h3>Decreasing size</h3>
-<cdk-virtual-scroll-viewport class="demo-viewport" autosize>
+
+<mat-form-field>
+  <mat-label>Behavior</mat-label>
+  <mat-select [(ngModel)]="scrollToBehavior">
+    <mat-option value="auto">Auto</mat-option>
+    <mat-option value="instant">Instant</mat-option>
+    <mat-option value="smooth">Smooth</mat-option>
+  </mat-select>
+</mat-form-field>
+<mat-form-field>
+  <mat-label>Offset</mat-label>
+  <input matInput type="number" [(ngModel)]="scrollToOffset">
+</mat-form-field>
+<button mat-button (click)="increasingViewport.scrollToOffset(scrollToOffset, scrollToBehavior)">
+  Go to offset
+</button>
+<mat-form-field>
+  <mat-label>Index</mat-label>
+  <input matInput type="number" [(ngModel)]="scrollToIndex">
+</mat-form-field>
+<button mat-button (click)="increasingViewport.scrollToIndex(scrollToIndex, scrollToBehavior)">
+  Go to index
+</button>
+<p>
+  Currently scrolled to item: {{scrolledIndex.get(viewport1) || 0}}
+</p>
+
+<cdk-virtual-scroll-viewport class="demo-viewport" autosize #decreasingViewport>
   <div *cdkVirtualFor="let size of decreasingSizeData; let i = index" class="demo-item"
        [style.height.px]="size">
     Item #{{i}} - ({{size}}px)
@@ -25,7 +53,7 @@ <h3>Decreasing size</h3>
 </cdk-virtual-scroll-viewport>
 
 <h3>Random size</h3>
-<cdk-virtual-scroll-viewport class="demo-viewport" autosize>
+<cdk-virtual-scroll-viewport class="demo-viewport" autosize  #randomViewport>
   <div *cdkVirtualFor="let size of randomData; let i = index" class="demo-item"
        [style.height.px]="size">
     Item #{{i}} - ({{size}}px)
diff --git a/src/dev-app/virtual-scroll/virtual-scroll-demo.ts b/src/dev-app/virtual-scroll/virtual-scroll-demo.ts
index bea4e5775491..8e53ad96672e 100644
--- a/src/dev-app/virtual-scroll/virtual-scroll-demo.ts
+++ b/src/dev-app/virtual-scroll/virtual-scroll-demo.ts
@@ -44,8 +44,8 @@ type State = {
 })
 export class VirtualScrollDemo implements OnDestroy {
   scrollToOffset = 0;
-  scrollToIndex = 0;
-  scrollToBehavior: ScrollBehavior = 'auto';
+  scrollToIndex = 9999;
+  scrollToBehavior: ScrollBehavior = 'smooth';
   scrolledIndex = new Map<CdkVirtualScrollViewport, number>();
   fixedSizeData = Array(10000).fill(50);
   increasingSizeData = Array(10000)