in src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts [193:350]
private _updateRenderedContentAfterScroll() {
const viewport = this._viewport!;
// The current scroll offset.
const scrollOffset = viewport.measureScrollOffset();
// The delta between the current scroll offset and the previously recorded scroll offset.
let scrollDelta = scrollOffset - this._lastScrollOffset;
// The magnitude of the scroll delta.
let scrollMagnitude = Math.abs(scrollDelta);
// The currently rendered range.
const renderedRange = viewport.getRenderedRange();
// 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.
let offsetCorrection = 0;
if (scrollDelta < 0) {
// 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 unrendered content at the beginning and
// the actual available space to scroll over. We need to reduce this to zero by the time the
// user scrolls to the top.
// - 0 indicates that the predicted size and available space are the same.
// - A negative number that the predicted size is smaller than the available space.
// - A positive number indicates the predicted size is larger than the available space
const offsetDifference = predictedOffset - this._lastRenderedContentOffset;
// The amount of difference to correct during this scroll event. We calculate this as a
// percentage of the total difference based on the percentage of the distance toward the top
// that the user scrolled.
offsetCorrection = Math.round(
offsetDifference *
Math.max(0, Math.min(1, scrollMagnitude / (scrollOffset + scrollMagnitude))),
);
// Based on the offset correction above, we pretend that the scroll delta was bigger or
// smaller than it actually was, this way we can start to eliminate the difference.
scrollDelta = scrollDelta - offsetCorrection;
scrollMagnitude = Math.abs(scrollDelta);
}
// 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.
const endBuffer =
this._lastRenderedContentOffset +
this._lastRenderedContentSize -
(this._lastScrollOffset + viewport.getViewportSize());
// The amount of unfilled space that should be filled on the side the user is scrolling toward
// in order to safely absorb the scroll delta.
const underscan =
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) {
// 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()) {
this._renderContentForCurrentOffset();
} else {
// The number of new items to render on the side the user is scrolling towards. Rather than
// just filling the underscan space, we actually fill enough to have a buffer size of
// `maxBufferPx`. This gives us a little wiggle room in case our item size estimate is off.
const addItems = Math.max(
0,
Math.ceil(
(underscan - this._minBufferPx + this._maxBufferPx) /
this._averager.getAverageItemSize(),
),
);
// The amount of filled space beyond what is necessary on the side the user is scrolling
// away from.
const overscan =
(scrollDelta < 0 ? endBuffer : startBuffer) - this._minBufferPx + scrollMagnitude;
// The number of currently rendered items to remove on the side the user is scrolling away
// from. If removal has failed in recent cycles we are less aggressive in how much we try to
// remove.
const unboundedRemoveItems = Math.floor(
overscan / this._averager.getAverageItemSize() / (this._removalFailures + 1),
);
const removeItems = Math.min(
renderedRange.end - renderedRange.start,
Math.max(0, unboundedRemoveItems),
);
// The new range we will tell the viewport to render. We first expand it to include the new
// items we want rendered, we then contract the opposite side to remove items we no longer
// want rendered.
const range = this._expandRange(
renderedRange,
scrollDelta < 0 ? addItems : 0,
scrollDelta > 0 ? addItems : 0,
);
if (scrollDelta < 0) {
range.end = Math.max(range.start + 1, range.end - removeItems);
} else {
range.start = Math.min(range.end - 1, range.start + removeItems);
}
// The new offset we want to set on the rendered content. To determine this we measure the
// number of pixels we removed and then adjust the offset to the start of the rendered
// content or to the end of the rendered content accordingly (whichever one doesn't require
// that the newly added items to be rendered to calculate.)
let contentOffset: number;
let contentOffsetTo: 'to-start' | 'to-end';
if (scrollDelta < 0) {
let removedSize = viewport.measureRangeSize({
start: range.end,
end: renderedRange.end,
});
// Check that we're not removing too much.
if (removedSize <= overscan) {
contentOffset =
this._lastRenderedContentOffset + this._lastRenderedContentSize - removedSize;
this._removalFailures = 0;
} else {
// If the removal is more than the overscan can absorb just undo it and record the fact
// that the removal failed so we can be less aggressive next time.
range.end = renderedRange.end;
contentOffset = this._lastRenderedContentOffset + this._lastRenderedContentSize;
this._removalFailures++;
}
contentOffsetTo = 'to-end';
} else {
const removedSize = viewport.measureRangeSize({
start: renderedRange.start,
end: range.start,
});
// Check that we're not removing too much.
if (removedSize <= overscan) {
contentOffset = this._lastRenderedContentOffset + removedSize;
this._removalFailures = 0;
} else {
// If the removal is more than the overscan can absorb just undo it and record the fact
// that the removal failed so we can be less aggressive next time.
range.start = renderedRange.start;
contentOffset = this._lastRenderedContentOffset;
this._removalFailures++;
}
contentOffsetTo = 'to-start';
}
// Set the range and offset we calculated above.
viewport.setRenderedRange(range);
viewport.setRenderedContentOffset(contentOffset + offsetCorrection, contentOffsetTo);
}
} else if (offsetCorrection) {
// Even if the rendered range didn't change, we may still need to adjust the content offset to
// simulate scrolling slightly slower or faster than the user actually scrolled.
viewport.setRenderedContentOffset(this._lastRenderedContentOffset + offsetCorrection);
}
// Save the scroll offset to be compared to the new value on the next scroll event.
this._lastScrollOffset = scrollOffset;
}