Skip to content

virtual-scroll: add incremental scroll logic in AutosizeVirtualScrollStrategy #10504

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Apr 5, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 134 additions & 9 deletions src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,12 @@ export class ItemSizeAverager {
/** The current average item size. */
private _averageItemSize: number;

/** The default size to use for items when no data is available. */
private _defaultItemSize: number;

/** @param defaultItemSize The default size to use for items when no data is available. */
constructor(defaultItemSize = 50) {
this._defaultItemSize = defaultItemSize;
this._averageItemSize = defaultItemSize;
}

Expand All @@ -49,6 +53,12 @@ export class ItemSizeAverager {
}
}
}

/** Resets the averager. */
reset() {
this._averageItemSize = this._defaultItemSize;
this._totalWeight = 0;
}
}


Expand All @@ -66,6 +76,15 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy {
/** The estimator used to estimate the size of unseen items. */
private _averager: ItemSizeAverager;

/** The last measured scroll offset of the viewport. */
private _lastScrollOffset: number;

/** The last measured size of the rendered content in the viewport. */
private _lastRenderedContentSize: number;

/** The last measured size of the rendered content in the viewport. */
private _lastRenderedContentOffset: number;

/**
* @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.
Expand All @@ -85,8 +104,9 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy {
* @param viewport The viewport to attach this strategy to.
*/
attach(viewport: CdkVirtualScrollViewport) {
this._averager.reset();
this._viewport = viewport;
this._renderContentForOffset(this._viewport.measureScrollOffset());
this._setScrollOffset();
}

/** Detaches this scroll strategy from the currently attached viewport. */
Expand All @@ -97,14 +117,15 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy {
/** Implemented as part of VirtualScrollStrategy. */
onContentScrolled() {
if (this._viewport) {
this._renderContentForOffset(this._viewport.measureScrollOffset());
this._updateRenderedContentAfterScroll();
}
}

/** Implemented as part of VirtualScrollStrategy. */
onDataLengthChanged() {
if (this._viewport) {
this._renderContentForOffset(this._viewport.measureScrollOffset());
// TODO(mmalebra): Do something smarter here.
this._setScrollOffset();
}
}

Expand All @@ -126,23 +147,127 @@ export class AutoSizeVirtualScrollStrategy implements VirtualScrollStrategy {
this._addBufferPx = addBufferPx;
}

/** Update the rendered content after the user scrolls. */
private _updateRenderedContentAfterScroll() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method is getting pretty big, can it be broken into smaller ones?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still need to add some more logic here for calculating the amount of skew between what our item size estimate says our scroll position should be and what our scroll position actually is. Once I'm done with that I'll try to figure out how to best separate it.

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.
const scrollDelta = scrollOffset - this._lastScrollOffset;
// The magnitude of the scroll delta.
const scrollMagnitude = Math.abs(scrollDelta);

// TODO(mmalerba): Record error between actual scroll offset and predicted scroll offset given
// the index of the first rendered element. Fudge the scroll delta to slowly eliminate the error
// as the user scrolls.

// 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._setScrollOffset();
} 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
// `addBufferPx`. 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._addBufferPx) /
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.
const removeItems = Math.max(0, Math.floor(overscan / this._averager.getAverageItemSize()));

// The currently rendered range.
const renderedRange = viewport.getRenderedRange();
// 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) {
const removedSize = viewport.measureRangeSize({
start: range.end,
end: renderedRange.end,
});
contentOffset =
this._lastRenderedContentOffset + this._lastRenderedContentSize - removedSize;
contentOffsetTo = 'to-end';
} else {
const removedSize = viewport.measureRangeSize({
start: renderedRange.start,
end: range.start,
});
contentOffset = this._lastRenderedContentOffset + removedSize;
contentOffsetTo = 'to-start';
}

// Set the range and offset we calculated above.
viewport.setRenderedRange(range);
viewport.setRenderedContentOffset(contentOffset, contentOffsetTo);
}
}

// Save the scroll offset to be compared to the new value on the next scroll event.
this._lastScrollOffset = scrollOffset;
}

/**
* Checks the size of the currently rendered content and uses it to update the estimated item size
* and estimated total content size.
*/
private _checkRenderedContentSize() {
const viewport = this._viewport!;
const renderedContentSize = viewport.measureRenderedContentSize();
this._averager.addSample(viewport.getRenderedRange(), renderedContentSize);
this._updateTotalContentSize(renderedContentSize);
this._lastRenderedContentOffset = viewport.measureRenderedContentOffset();
this._lastRenderedContentSize = viewport.measureRenderedContentSize();
this._averager.addSample(viewport.getRenderedRange(), this._lastRenderedContentSize);
this._updateTotalContentSize(this._lastRenderedContentSize);
}

/**
* Render the content that we estimate should be shown for the given scroll offset.
* Note: must not be called if `this._viewport` is null
* Sets the scroll offset and renders the content we estimate should be shown at that point.
* @param scrollOffset The offset to jump to. If not specified the scroll offset will not be
* changed, but the rendered content will be recalculated based on our estimate of what should
* be shown at the current scroll offset.
*/
private _renderContentForOffset(scrollOffset: number) {
private _setScrollOffset(scrollOffset?: number) {
const viewport = this._viewport!;
if (scrollOffset == null) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of doing this check, you can change the signature to _setScrollOffset(scrollOffset: number = viewport.measureScrollOffset()).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have an else clause though, which that would mess up

scrollOffset = viewport.measureScrollOffset();
} else {
viewport.setScrollOffset(scrollOffset);
}
this._lastScrollOffset = scrollOffset;

const itemSize = this._averager.getAverageItemSize();
const firstVisibleIndex =
Math.min(viewport.getDataLength() - 1, Math.floor(scrollOffset / itemSize));
Expand Down
70 changes: 33 additions & 37 deletions src/cdk-experimental/scrolling/virtual-for-of.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ export type CdkVirtualForOfContext<T> = {
};


/** Helper to extract size from a ClientRect. **/
function getSize(orientation: 'horizontal' | 'vertical', rect: ClientRect): number {
return orientation == 'horizontal' ? rect.width : rect.height;
}


/**
* A directive similar to `ngForOf` to be used for rendering data inside a virtual scrolling
* container.
Expand Down Expand Up @@ -151,47 +157,37 @@ export class CdkVirtualForOf<T> implements CollectionViewer, DoCheck, OnDestroy
}

/**
* Get the client rect for the given index.
* @param index The index of the data element whose client rect we want to measure.
* @return The combined client rect for all DOM elements rendered as part of the given index.
* Or null if no DOM elements are rendered for the given index.
* @throws If the given index is not in the rendered range.
* Measures the combined size (width for horizontal orientation, height for vertical) of all items
* in the specified range. Throws an error if the range includes items that are not currently
* rendered.
*/
measureClientRect(index: number): ClientRect | null {
if (index < this._renderedRange.start || index >= this._renderedRange.end) {
throw Error(`Error: attempted to measure an element that isn't rendered.`);
measureRangeSize(range: ListRange, orientation: 'horizontal' | 'vertical'): number {
if (range.start >= range.end) {
return 0;
}
if (range.start < this._renderedRange.start || range.end > this._renderedRange.end) {
throw Error(`Error: attempted to measure an item that isn't rendered.`);
}
const renderedIndex = index - this._renderedRange.start;
let view = this._viewContainerRef.get(renderedIndex) as
EmbeddedViewRef<CdkVirtualForOfContext<T>> | null;
if (view && view.rootNodes.length) {
// There may be multiple root DOM elements for a single data element, so we merge their rects.
// These variables keep track of the minimum top and left as well as maximum bottom and right
// that we have encoutnered on any rectangle, so that we can merge the results into the
// smallest possible rect that contains all of the root rects.
let minTop = Infinity;
let minLeft = Infinity;
let maxBottom = -Infinity;
let maxRight = -Infinity;

for (let i = view.rootNodes.length - 1; i >= 0 ; i--) {
let rect = (view.rootNodes[i] as Element).getBoundingClientRect();
minTop = Math.min(minTop, rect.top);
minLeft = Math.min(minLeft, rect.left);
maxBottom = Math.max(maxBottom, rect.bottom);
maxRight = Math.max(maxRight, rect.right);
}

return {
top: minTop,
left: minLeft,
bottom: maxBottom,
right: maxRight,
height: maxBottom - minTop,
width: maxRight - minLeft
};
// The index into the list of rendered views for the first item in the range.
const renderedStartIndex = range.start - this._renderedRange.start;
// The length of the range we're measuring.
const rangeLen = range.end - range.start;

// Loop over all root nodes for all items in the range and sum up their size.
// TODO(mmalerba): Make this work with non-element nodes.
let totalSize = 0;
let i = rangeLen;
while (i--) {
const view = this._viewContainerRef.get(i + renderedStartIndex) as
EmbeddedViewRef<CdkVirtualForOfContext<T>> | null;
let j = view ? view.rootNodes.length : 0;
while (j--) {
totalSize += getSize(orientation, (view!.rootNodes[j] as Element).getBoundingClientRect());
}
}
return null;

return totalSize;
}

ngDoCheck() {
Expand Down
46 changes: 46 additions & 0 deletions src/cdk-experimental/scrolling/virtual-scroll-viewport.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import {Component, ViewChild} from '@angular/core';
import {ComponentFixture, fakeAsync, flush, TestBed} from '@angular/core/testing';
import {ScrollingModule} from './scrolling-module';
import {CdkVirtualScrollViewport} from './virtual-scroll-viewport';

describe('Basic CdkVirtualScrollViewport', () => {
let fixture: ComponentFixture<BasicViewport>;
let viewport: CdkVirtualScrollViewport;

beforeEach(() => {
TestBed.configureTestingModule({
imports: [ScrollingModule],
declarations: [BasicViewport],
}).compileComponents();

fixture = TestBed.createComponent(BasicViewport);
viewport = fixture.componentInstance.viewport;
});

it('should sanitize transform inputs', fakeAsync(() => {
fixture.detectChanges();
flush();

viewport.orientation = 'arbitrary string as orientation' as any;
viewport.setRenderedContentOffset(
'arbitrary string as offset' as any, 'arbitrary string as to' as any);
fixture.detectChanges();
flush();

expect((viewport._renderedContentTransform as any).changingThisBreaksApplicationSecurity)
.toEqual('translateY(NaNpx)');
}));
});

@Component({
template: `
<cdk-virtual-scroll-viewport itemSize="50">
<span *cdkVirtualFor="let item of items">{{item}}</span>
</cdk-virtual-scroll-viewport>
`
})
class BasicViewport {
@ViewChild(CdkVirtualScrollViewport) viewport;

items = Array(10).fill(0);
}
Loading