Skip to content

feat(cdk/table): virtual scroll directive for tables #21708

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

Closed
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
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@
/src/cdk-experimental/combobox/** @jelbourn
/src/cdk-experimental/popover-edit/** @andrewseguin
/src/cdk-experimental/scrolling/** @mmalerba
/src/cdk-experimental/table/** @michaeljamesparsons @andrewseguin
/src/cdk-experimental/table-scroll-container/** @andrewseguin
/src/cdk-experimental/listbox/** @jelbourn
/src/cdk-experimental/selection/** @andrewseguin
Expand Down
1 change: 1 addition & 0 deletions src/cdk-experimental/config.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ CDK_EXPERIMENTAL_ENTRYPOINTS = [
"popover-edit",
"scrolling",
"selection",
"table",
"table-scroll-container",
]

Expand Down
22 changes: 22 additions & 0 deletions src/cdk-experimental/table/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
load(
"//tools:defaults.bzl",
"ng_module",
)

package(default_visibility = ["//visibility:public"])

ng_module(
name = "table",
srcs = glob(
["**/*.ts"],
exclude = ["**/*.spec.ts"],
),
deps = [
"//src/cdk/bidi",
"//src/cdk/platform",
"//src/cdk/table",
"@npm//@angular/common",
"@npm//@angular/core",
"@npm//rxjs",
],
)
9 changes: 9 additions & 0 deletions src/cdk-experimental/table/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

export * from './public-api';
10 changes: 10 additions & 0 deletions src/cdk-experimental/table/public-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

export * from './table-virtual-scroll';
export * from './table-module';
23 changes: 23 additions & 0 deletions src/cdk-experimental/table/table-module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import {NgModule} from '@angular/core';
import {CdkTableModule as TableModule} from '@angular/cdk/table';

import {CdkTableVirtualScroll} from './table-virtual-scroll';



@NgModule({
declarations: [CdkTableVirtualScroll],
exports: [CdkTableVirtualScroll],
imports: [
TableModule,
],
})
export class CdkTableModule {}
270 changes: 270 additions & 0 deletions src/cdk-experimental/table/table-virtual-scroll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {
Directive,
Inject,
Input,
OnDestroy,
SkipSelf,
} from '@angular/core';
import {
_RecycleViewRepeaterStrategy,
_VIEW_REPEATER_STRATEGY,
ListRange
} from '@angular/cdk/collections';
import {
_TABLE_VIEW_CHANGE_STRATEGY,
CdkTable,
RenderRow,
RowContext,
STICKY_POSITIONING_LISTENER,
StickyPositioningListener,
StickyUpdate
} from '@angular/cdk/table';
import {
BehaviorSubject,
combineLatest,
Observable,
ReplaySubject,
Subject,
} from 'rxjs';
import {
shareReplay,
takeUntil
} from 'rxjs/operators';
import {
CdkVirtualScrollRepeater,
CdkVirtualScrollViewport,
} from '@angular/cdk/scrolling';

/**
* An implementation of {@link StickyPositioningListener} that forwards sticky updates to another
* listener.
*
* The {@link CdkTableVirtualScroll} directive cannot provide itself as a
* {@link StickyPositioningListener} because the providers for both entities would point to the same
* instance. The {@link CdkTable} depends on the sticky positioning listener and the table virtual
* scroll depends on the table. Since the sticky positioning listener and table virtual scroll would
* be the same instance, this would create a circular dependency.
*
* The {@link CdkTableVirtualScroll} instead provides this class and attaches itself as the
* receiving listener so {@link StickyPositioningListener} and {@link CdkTableVirtualScroll} are
* provided as separate instances.
*
* @docs-private
*/
export class _PositioningListenerProxy implements StickyPositioningListener {
private _listener?: StickyPositioningListener;

setListener(listener: StickyPositioningListener) {
this._listener = listener;
}

stickyColumnsUpdated(update: StickyUpdate): void {
Copy link
Contributor

Choose a reason for hiding this comment

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

Can you add documentation to each of these functions? The naming throws me off a little - they sound more like boolean values. Should they be something like onStickyColumnsUpdated?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

These method names are inherited from the StickyPositioningListener interface. I added inline docs to clarify their purpose.

this._listener?.stickyColumnsUpdated(update);
}

stickyEndColumnsUpdated(update: StickyUpdate): void {
this._listener?.stickyEndColumnsUpdated(update);
}

stickyFooterRowsUpdated(update: StickyUpdate): void {
this._listener?.stickyFooterRowsUpdated(update);
}

stickyHeaderRowsUpdated(update: StickyUpdate): void {
this._listener?.stickyHeaderRowsUpdated(update);
}
}

/** @docs-private */
export const _TABLE_VIRTUAL_SCROLL_COLLECTION_VIEWER_FACTORY =
() => new BehaviorSubject<ListRange>({start: 0, end: 0});


/**
* A directive that enables virtual scroll for a {@link CdkTable}.
*/
@Directive({
selector: 'cdk-table[virtualScroll], table[cdk-table][virtualScroll]',
exportAs: 'cdkVirtualScroll',
providers: [
{provide: _VIEW_REPEATER_STRATEGY, useClass: _RecycleViewRepeaterStrategy},
// The directive cannot provide itself as the sticky positions listener because it introduces
// a circular dependency. Use an intermediate listener as a proxy.
{provide: STICKY_POSITIONING_LISTENER, useClass: _PositioningListenerProxy},
// Initially emit an empty range. The virtual scroll viewport will update the range after it is
// initialized.
{
provide: _TABLE_VIEW_CHANGE_STRATEGY,
useFactory: _TABLE_VIRTUAL_SCROLL_COLLECTION_VIEWER_FACTORY,
},
],
host: {
'class': 'cdk-table-virtual-scroll',
},
})
export class CdkTableVirtualScroll<T>
implements CdkVirtualScrollRepeater<T>, OnDestroy, StickyPositioningListener {
/** Emits when the component is destroyed. */
private _destroyed = new ReplaySubject<void>(1);

/** Emits when the header rows sticky state changes. */
private readonly _headerRowStickyUpdates = new Subject<StickyUpdate>();

/** Emits when the footer rows sticky state changes. */
private readonly _footerRowStickyUpdates = new Subject<StickyUpdate>();

/**
* Observable that emits the data source's complete data set. This exists to implement
* {@link CdkVirtualScrollRepeater}.
*/
get dataStream(): Observable<readonly T[]> {
return this._dataStream;
}
private _dataStream = this._table._dataStream.pipe(shareReplay(1));

/**
* The size of the cache used to store unused views. Setting the cache size to `0` will disable
* caching.
*/
@Input()
get viewCacheSize(): number {
return this._viewRepeater.viewCacheSize;
}
set viewCacheSize(size: number) {
this._viewRepeater.viewCacheSize = size;
}

constructor(
private readonly _table: CdkTable<T>,
@Inject(_TABLE_VIEW_CHANGE_STRATEGY) private readonly _viewChange: BehaviorSubject<ListRange>,
@Inject(STICKY_POSITIONING_LISTENER) positioningListener: _PositioningListenerProxy,
@Inject(_VIEW_REPEATER_STRATEGY)
private readonly _viewRepeater: _RecycleViewRepeaterStrategy<T, RenderRow<T>, RowContext<T>>,
@SkipSelf() private readonly _viewport: CdkVirtualScrollViewport) {
positioningListener.setListener(this);

// Force the table to enable `fixedLayout` to prevent column widths from changing as the user
// scrolls. This also enables caching in the table's sticky styler which reduces calls to
// expensive DOM APIs, such as `getBoundingClientRect()`, and improves overall performance.
if (!this._table.fixedLayout && (typeof ngDevMode === 'undefined' || ngDevMode)) {
throw Error('[virtualScroll] requires input `fixedLayout` to be set on the table.');
}

// Update sticky styles for header rows when either the render range or sticky state change.
combineLatest([this._viewport._renderedContentOffsetRendered, this._headerRowStickyUpdates])
.pipe(takeUntil(this._destroyed))
.subscribe(([offset, update]) => {
this._stickHeaderRows(offset, update);
});

// Update sticky styles for footer rows when either the render range or sticky state change.
combineLatest([this._viewport._renderedContentOffsetRendered, this._footerRowStickyUpdates])
.pipe(takeUntil(this._destroyed))
.subscribe(([offset, update]) => {
this._stickFooterRows(offset, update);
});

// Forward the rendered range computed by the virtual scroll viewport to the table.
this._viewport.renderedRangeStream.pipe(takeUntil(this._destroyed)).subscribe(this._viewChange);
this._viewport.attach(this);
}

ngOnDestroy() {
this._destroyed.next();
this._destroyed.complete();
}

/**
* Measures the combined size (width for horizontal orientation, height for vertical) of all items
* in the specified range.
*/
measureRangeSize(range: ListRange, orientation: 'horizontal' | 'vertical'): number {
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: Can this be getRangeSize instead of measure? Right now it sounds like its telling the directive to perform an action rather than to retrieve some values

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

No, this method is must be implemented as part of the CdkVirtualScrollRepeater interface. It's required to use the virtual scroll autosize behavior.

// TODO(michaeljamesparsons) Implement method so virtual tables can use the `autosize` virtual
// scroll strategy.
if ((typeof ngDevMode === 'undefined' || ngDevMode)) {
throw new Error('autoSize is not supported for tables with virtual scroll enabled.');
}
return 0;
}

stickyColumnsUpdated(update: StickyUpdate): void {
// no-op
}

stickyEndColumnsUpdated(update: StickyUpdate): void {
// no-op
}

stickyHeaderRowsUpdated(update: StickyUpdate): void {
this._headerRowStickyUpdates.next(update);
}

stickyFooterRowsUpdated(update: StickyUpdate): void {
this._footerRowStickyUpdates.next(update);
}

/**
* The {@link StickyStyler} sticks elements by applying a `top` position offset to them. However,
* the virtual scroll viewport applies a `translateY` offset to a container div that
* encapsulates the table. The translation causes the header rows to also be offset by the
* distance from the top of the scroll viewport in addition to their `top` offset. This method
* negates the translation to move the header rows to their correct positions.
*
* @param offsetFromTop The distance scrolled from the top of the container.
* @param update Metadata about the sticky headers that changed in the last sticky update.
* @private
*/
private _stickHeaderRows(offsetFromTop: number, update: StickyUpdate) {
Copy link
Member

Choose a reason for hiding this comment

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

Could you add some explanation here that touches on the need to apply these extra styles on top of/instead of what's happening with StickyStyler? It might be because it's been long enough that I forgot how sticky positioning works today, but I feel like I'm missing context on why virtual scroll needs the extra styling logic.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Done

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

On a related note, #21576 (comment) mentioned that the flickering observed while scrolling quickly is potentially caused by the browser applying the viewport's transform after top. So the offsets occasionally get out of sync for 1 frame.

I took some time to experiment with this a bit more today and refactored the code to keep top as-is, and use transformY to shift the table cells into their correct positions. The end result was much worse than just updating top directly.

Moving the headers/footers outside the scroll viewport entirely seems to be the only reliable solution to the flickering. In the future, it would be worth experimenting with an accessible flex table that scrolls only the body content.

if (!update.sizes || !update.offsets || !update.elements) {
return;
}

for (let i = 0; i < update.elements.length; i++) {
if (!update.elements[i]) {
continue;
}
let offset = offsetFromTop !== 0
? Math.max(offsetFromTop - update.offsets[i]!, update.offsets[i]!)
: -update.offsets[i]!;

this._stickCells(update.elements[i]!, 'top', -offset);
}
}

/**
* The {@link StickyStyler} sticks elements by applying a `bottom` position offset to them.
* However, the virtual scroll viewport applies a `translateY` offset to a container div that
* encapsulates the table. The translation causes the footer rows to also be offset by the
* distance from the top of the scroll viewport in addition to their `bottom` offset. This method
* negates the translation to move the footer rows to their correct positions.
*
* @param offsetFromTop The distance scrolled from the top of the container.
* @param update Metadata about the sticky footers that changed in the last sticky update.
* @private
*/
private _stickFooterRows(offsetFromTop: number, update: StickyUpdate) {
if (!update.sizes || !update.offsets || !update.elements) {
return;
}

for (let i = 0; i < update.elements.length; i++) {
if (!update.elements[i]) {
continue;
}
this._stickCells(update.elements[i]!, 'bottom', offsetFromTop + update.offsets[i]!);
}
}

private _stickCells(cells: HTMLElement[], position: 'bottom'|'top', offset: number) {
for (const cell of cells) {
cell.style[position] = `${offset}px`;
}
}
}
4 changes: 2 additions & 2 deletions src/cdk/collections/recycle-view-repeater-strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ export class _RecycleViewRepeaterStrategy<T, R, C extends _ViewRepeaterItemConte
implements _ViewRepeater<T, R, C>
{
/**
* The size of the cache used to store unused views.
* Setting the cache size to `0` will disable caching. Defaults to 20 views.
* The size of the cache used to store unused views. Setting the cache size to `0` will disable
Copy link
Contributor

Choose a reason for hiding this comment

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

Can be reverted?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Done

* caching. Defaults to 20 views.
*/
viewCacheSize: number = 20;

Expand Down
Loading