Skip to content
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

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

Conversation

MichaelJamesParsons
Copy link
Collaborator

@MichaelJamesParsons MichaelJamesParsons commented Jan 26, 2021

Adds a virtualRows directive which enables virtual scrolling for CDK tables.

Open questions

Blocks of code that are subject to further discussion or contain potentially breaking changes are annotated with FIXME.

Out of scope for now

  • Variable size rows (virtual scroll autosize directive)
  • Material tables support (let's hash out the CDK variant first)
  • Checkbox state
  • Row animations

Known issues

  • Sticky headers/footers flicker if an animation frame exceeds ~35ms. Karl and I will investigate further.

Performance considerations

We've already improved row rendering by 50% or better. Without headers/footers, the scrolling experience is comparable to most other Angular material virtual scroll demos.

However, the entire table shifts position as the user scrolls. The position of sticky headers and footers must be offset by the distance scrolled to keep them in place. On average, the table is able to keep 15-20ms per animation frame. Frames that occasionally exceed ~35ms cause sticky headers/footers to flicker. It looks like longer than usual style calculations are hindering performance the most.

While I believe we can further optimize rendering, we will likely reach a threshold where the table can no longer be optimized and non-trivial tables will still experience flickering (e.g. tables with inline editing, cells with custom components, etc.). A while back, I experimented with rendering tables in multiple pieces so the body content can scroll independently of header/footers (#20414). There are some a11y challenges to this solution, but it would resolve the flickering.

For example:

<table><!-- table header --></table>
<div class="scroll-viewport">
  <table>
    <thead><!-- visually hidden header --></thead>
    <tbody>...</tbody>
    <tfoot><!--visually hidden footer --></tfoot>
</div>
<table><!-- table footer --></table>

Related PRs

@google-cla google-cla bot added the cla: yes PR author has agreed to Google's Contributor License Agreement label Jan 26, 2021
@MichaelJamesParsons MichaelJamesParsons added P2 The issue is important to a large percentage of users, with a workaround area: cdk/table G This is is related to a Google internal issue labels Jan 26, 2021
src/cdk-experimental/table/virtual-rows.ts Outdated Show resolved Hide resolved
src/cdk-experimental/table/virtual-rows.ts Outdated Show resolved Hide resolved
src/cdk-experimental/table/virtual-rows.ts Outdated Show resolved Hide resolved
src/cdk-experimental/table/virtual-rows.ts Outdated Show resolved Hide resolved
src/cdk/collections/recycle-view-repeater-strategy.ts Outdated Show resolved Hide resolved
src/cdk/table/table.scss Outdated Show resolved Hide resolved
src/cdk/table/table.ts Outdated Show resolved Hide resolved
src/cdk/table/table.ts Outdated Show resolved Hide resolved
src/cdk-experimental/table/virtual-rows.ts Outdated Show resolved Hide resolved
src/cdk-experimental/table/virtual-rows.ts Outdated Show resolved Hide resolved
src/cdk-experimental/table/virtual-rows.ts Outdated Show resolved Hide resolved
src/cdk-experimental/table/virtual-rows.ts Outdated Show resolved Hide resolved
src/cdk-experimental/table/virtual-rows.ts Outdated Show resolved Hide resolved
@crisbeto
Copy link
Member

Since the description mentions an issue with flickering, could it be the same problem as #21576?

@MichaelJamesParsons
Copy link
Collaborator Author

Since the description mentions an issue with flickering, could it be the same problem as #21576?

Yes, though the flickering I'm seeing is not nearly as significant as observed in #21576. Likely due to internal performance optimizations that are present in this PR, but not yet accessible through the public API (e.g. recycle view repeater and enhancements made in #21704).

src/cdk-experimental/table/virtual-rows.ts Outdated Show resolved Hide resolved
src/cdk-experimental/table/virtual-rows.ts Outdated Show resolved Hide resolved
src/cdk-experimental/table/virtual-rows.ts Outdated Show resolved Hide resolved
src/cdk-experimental/table/virtual-rows.ts Outdated Show resolved Hide resolved
src/cdk-experimental/table/virtual-rows.ts Outdated Show resolved Hide resolved
src/cdk-experimental/table/virtual-rows.ts Outdated Show resolved Hide resolved
src/cdk-experimental/table/virtual-rows.ts Outdated Show resolved Hide resolved
src/cdk/table/table.ts Outdated Show resolved Hide resolved
Comment on lines 529 to 565
// FIXME DO NOT SUBMIT: Is the following behavior acceptable?
//
// The table will override the StickyPositioningListener provider to `null` to prevent child
// tables from inheriting it. However, if a directive on the table configures a new provider, it
// should be used instead. Therefore, when a directive on the table configures a positioning
// listener provider, the provider will be inherited by child tables.
this._stickyPositioningListener = this._positioningListener ?? this._parentPositioningListener;
Copy link
Member

Choose a reason for hiding this comment

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

To clarify that I understand this correctly: the goal here is to support multiple different directives adding their own sticky position listener? If that's the case, we should probably refactor this in one of two ways:

  • Have one "notifier" that all those directives subscribe to
  • Change this to a multi provider and let multiple directives register listeners

I would definitely lean towards the first option here as being more in line with the rest of the codebase

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The table currently retrieves the StickyPositioningListener provider from the parent component and overrides the provider to null to prevent child tables from inheriting it.

<parent-with-listener>
  <table></table>
</parent-with-listener>

However, the virtual scroll directive is bound directly on the table, so the table must get the provider from @Self() when present.

<table virtualScroll></table>

src/cdk-experimental/table/table-virtual-scroll.ts Outdated Show resolved Hide resolved
src/cdk-experimental/table/table-virtual-scroll.ts Outdated Show resolved Hide resolved
src/cdk-experimental/table/table-virtual-scroll.ts Outdated Show resolved Hide resolved
Comment on lines 114 to 115
* Collection viewer for the `CdkTable`. For backwards compatibility, the collection viewer must
* be a `BehaviorSubject`.
Copy link
Member

Choose a reason for hiding this comment

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

We can potentially change this; I assume that this isn't going to be widely used inside Google since viewChange has just been a placeholder

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Internal targets could be quickly migrated. What about external apps?

Copy link
Member

Choose a reason for hiding this comment

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

For a behavior change like this, I think this is something we could change for a major version (which we're currently in the window for) so long as we document it.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@jelbourn Following up since it's been a while. Is now a still a good time to make this change?

Copy link
Collaborator

Choose a reason for hiding this comment

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

What's the benefit of changing it?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The behavior subject emits a range the spans the entire data set when the table is initialized. This causes the table to briefly render all rows, then truncate them once it attaches to the virtual scroll viewport. By changing this to a regular Subject, we'd have more control over when the range is emitted.

For now, I've hacked around it by overriding the behavior subject via provider. Then emitting an empty range as the initial value. This prevents rendering the rows before the virtual scroll viewport is attached.

Copy link
Member

Choose a reason for hiding this comment

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

We'd be able to change it starting in August

Revisiting this, could you expand the comment to touch on why this interface is necessary? (IIRC it's so that the table can inject the viewChange behavior from another directive instead of completely implementing it itself).

Actually typing that out, it might make sense to rename this to something named like ViewChangeXxx instead of CollectionViewer since CollectionViewer could potentially be expanded to include more stuff, but that's not super important with the features it has today.

src/dev-app/index.html Outdated Show resolved Hide resolved
@MichaelJamesParsons MichaelJamesParsons requested a review from a team as a code owner June 15, 2021 00:56
@MichaelJamesParsons MichaelJamesParsons force-pushed the cdk-virtual-table branch 2 times, most recently from 04e4bcf to 904f752 Compare June 15, 2021 01:55
@MichaelJamesParsons MichaelJamesParsons changed the title WIP: feat(cdk/table): virtual scroll directive for tables feat(cdk/table): virtual scroll directive for tables Jun 15, 2021
@MichaelJamesParsons MichaelJamesParsons force-pushed the cdk-virtual-table branch 3 times, most recently from ad96443 to 99b0b31 Compare June 15, 2021 03:16
Copy link
Collaborator

@kseamon kseamon left a comment

Choose a reason for hiding this comment

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

Looks good!

src/cdk-experimental/table/table-virtual-scroll.ts Outdated Show resolved Hide resolved
Comment on lines 114 to 115
* Collection viewer for the `CdkTable`. For backwards compatibility, the collection viewer must
* be a `BehaviorSubject`.
Copy link
Collaborator

Choose a reason for hiding this comment

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

What's the benefit of changing it?

@josephperrott josephperrott removed the request for review from a team June 17, 2021 15:55
@MichaelJamesParsons
Copy link
Collaborator Author

@jelbourn The view_engine_build check fails with the following error, but passes locally.

ERROR: /home/circleci/ng/src/dev-app/table/BUILD.bazel:5:10: Compiling Angular templates (ViewEngine - devmode) //src/dev-app/table:table failed
RangeError: Maximum call stack size exceeded

Local command:

 bazel build --build_tag_filters=-docs-package,-release-package --config=view-engine -- src/...

Am I missing something?

src/cdk-experimental/table/table-virtual-scroll.ts Outdated Show resolved Hide resolved
this._footerRowStickyUpdates.next(update);
}

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.

@@ -1,3 +1,17 @@
.cdk-table-fixed-layout {
table-layout: fixed;
}

.cdk-table-virtual-scroll {
border-collapse: collapse;
Copy link
Member

Choose a reason for hiding this comment

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

Add a comment for why border-collapse is important here?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Tables with multiple headers/footers that have border-spacing will initially render with spacing between cells. However, the spacing collapses after scrolling. I'm able to reproduce this with non-virtualized CDK tables, so I believe this is an issue with the stickystyler.

I added this style so border-spacing wouldn't collapse on scroll. It can probably be removed since it's an existing behavior with the sticky styler.

Comment on lines 114 to 115
* Collection viewer for the `CdkTable`. For backwards compatibility, the collection viewer must
* be a `BehaviorSubject`.
Copy link
Member

Choose a reason for hiding this comment

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

We'd be able to change it starting in August

Revisiting this, could you expand the comment to touch on why this interface is necessary? (IIRC it's so that the table can inject the viewChange behavior from another directive instead of completely implementing it itself).

Actually typing that out, it might make sense to rename this to something named like ViewChangeXxx instead of CollectionViewer since CollectionViewer could potentially be expanded to include more stuff, but that's not super important with the features it has today.

@MichaelJamesParsons MichaelJamesParsons force-pushed the cdk-virtual-table branch 2 times, most recently from 2382ec7 to 30d988c Compare August 10, 2021 19:46
@MichaelJamesParsons
Copy link
Collaborator Author

MichaelJamesParsons commented Aug 10, 2021

I resolved the view engine failure described in #21708 (comment) by adding a missing build target to the dev-app/BUILD.bazel file. I'm surprised the dev-app was able to build at all without it. Strange.

@jelbourn Ready for another review.

@devversion devversion removed their request for review August 18, 2021 13:12
src/cdk-experimental/table/table-virtual-scroll.ts Outdated Show resolved Hide resolved
src/cdk/collections/recycle-view-repeater-strategy.ts Outdated Show resolved Hide resolved
src/cdk/scrolling/virtual-for-of.ts Outdated Show resolved Hide resolved
src/cdk/table/table.scss Outdated Show resolved Hide resolved
Copy link
Contributor

@andrewseguin andrewseguin left a comment

Choose a reason for hiding this comment

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

It may be wise to add an experimental-version table.md focused on documenting this feature to talk about usage, limitations, and perhaps performance considerations like making sure you use trackBy. We'll want it for when it's moved out of experimental, and now would be a good time to write it while the details are fresh in your mind

{position: 10, name: 'Neon', weight: 20.1797, symbol: 'Ne'},
];

const EXPANDED_ELEMENT_DATA: PeriodicElement[] = [];
Copy link
Contributor

Choose a reason for hiding this comment

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

Add comment to explain that this logic is simply to create a large dataset to so the table has a lot of rows

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

export class CdkVirtualTableExample {
displayedColumns: string[] = ['position', 'name', 'weight', 'symbol'];
dataSource = EXPANDED_ELEMENT_DATA;
trackBy = (index: number, el: PeriodicElement) => el.position;
Copy link
Contributor

Choose a reason for hiding this comment

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

Is trackBy required for virtual scroll? Adding it to the example may imply that its necessary. If its for performance reasons, this would be a good spot to mention that whether it significantly improves things.

@@ -0,0 +1,37 @@
<cdk-virtual-scroll-viewport class="example-container" [itemSize]="48" [maxBufferPx]="1200" [minBufferPx]="400">
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we provide a comment on why we chose these values for this example?

<th cdk-footer-cell *cdkFooterCellDef> Symbol </th>
</ng-container>

<tr cdk-header-row *cdkHeaderRowDef="displayedColumns; sticky: true;"></tr>
Copy link
Contributor

Choose a reason for hiding this comment

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

I think these extra rows are added to make sure we don't regress with the feature or performance, but it's not as useful to someone using this example to understand how to use the table.

Can we have a separate example that is more filled out or complex, and leave this as a barebones or typical example (e.g. single sticky header and no footer).

@@ -11,5 +11,8 @@ import '@angular/localize/init';

import {platformBrowser} from '@angular/platform-browser';
import {MainModuleNgFactory} from './main-module.ngfactory';
import {enableProdMode} from '@angular/core';

enableProdMode();
Copy link
Contributor

Choose a reason for hiding this comment

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

How come this is being added?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This should be reverted. Done.

@@ -37,8 +37,8 @@ import {
export class _RecycleViewRepeaterStrategy<T, R, C extends _ViewRepeaterItemContext<T>>
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

/**
* Stream containing the latest information on what rows are being displayed on screen.
* Can be used by the data source to as a heuristic of what data should be provided.
*
* @docs-private
*/
readonly viewChange =
new BehaviorSubject<{start: number, end: number}>({start: 0, end: Number.MAX_VALUE});
readonly viewChange: BehaviorSubject<ListRange>;
Copy link
Contributor

Choose a reason for hiding this comment

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

Would it make more sense to make this a ReplaySubject so that users will get the latest value on subscribe but you don't need to set an initial value?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Changing this to a ReplaySubject would be a breaking change. Is it alright to make that change in this PR?

takeUntil(this._onDestroy))
.subscribe(([data, range]) => {
this._data = data;
this._renderedRange = range;
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: Should this be _renderRange or _dataRange to imply that this is the range we intend render, rather than the one we have rendered?

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.

* 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.

@andrewseguin andrewseguin removed the cla: yes PR author has agreed to Google's Contributor License Agreement label Dec 28, 2021
@googlebot googlebot added the cla: yes PR author has agreed to Google's Contributor License Agreement label Dec 29, 2021
@andrewseguin
Copy link
Contributor

@MichaelJamesParsons Would you like to rebase and continue iterating on this?

@andrewseguin andrewseguin added the needs: clarification The issue does not contain enough information for the team to determine if it is a real bug label Mar 24, 2022
@MichaelJamesParsons
Copy link
Collaborator Author

Yes, I'll set aside time to wrap this up.

@josephperrott josephperrott added action: cleanup The PR is in need of cleanup, either due to needing a rebase or in response to comments from reviews and removed needs rebase labels Nov 16, 2022
@CharlieGreenman
Copy link

CharlieGreenman commented Oct 17, 2024

@andrewseguin would it be ok if I work on this one? I have the same issue here: #10122 (comment) and I see this hasn't been touched for sometime. Would love to help and have this one go over the finish line

I think it makes sense for me to open up a new pull request all together given the time that has elapsed between now and then. Especially since signals now exists

@josephperrott josephperrott requested a review from a team as a code owner December 18, 2024 17:40
@josephperrott josephperrott requested review from crisbeto and amysorto and removed request for a team December 18, 2024 17:40
@andrewseguin
Copy link
Contributor

Closing since the code is fairly out of date and it's unlikely we'll get this refreshed. @CharlieGreenman you are welcome to open a new up-to-date PR for us to iterate on

@andrewseguin andrewseguin self-assigned this Jan 15, 2025
@CharlieGreenman
Copy link

i will see what I can do

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
action: cleanup The PR is in need of cleanup, either due to needing a rebase or in response to comments from reviews area: cdk/table cla: yes PR author has agreed to Google's Contributor License Agreement G This is is related to a Google internal issue needs: clarification The issue does not contain enough information for the team to determine if it is a real bug P2 The issue is important to a large percentage of users, with a workaround
Projects
None yet
Development

Successfully merging this pull request may close these issues.

9 participants