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

cdk-virtual-scroll-viewport does not handle css position: sticky cleanly #14833

Open
bdirito opened this issue Jan 15, 2019 · 55 comments
Open

cdk-virtual-scroll-viewport does not handle css position: sticky cleanly #14833

bdirito opened this issue Jan 15, 2019 · 55 comments
Labels
area: cdk/scrolling P3 An issue that is relevant to core functions, but does not impede progress. Important, but not urgent

Comments

@bdirito
Copy link

bdirito commented Jan 15, 2019

What is the expected behavior?

using the css attribute position: sticky should allow for elements to remain displayed according to their sticky parameters.

What is the current behavior?

This works for a while but then breaks after a point. Im assuming its a matter of the 'stickied' dom elements getting recycled.

What are the steps to reproduce?

https://stackblitz.com/edit/angular-wnar6b-wzjdmh

This is a slightly modified version of an example from the docs. As you scroll the cdk-virtual-scroll-viewport you will notice the light blue headers stick to the the top of their area as expected. Once you exceed a certain amount of scrolling however the headers disappear. For me items 0-2 work but the 3rd one breaks.

Down below is an example using the [non-virtual] scrolling. Here the css attribute works as expected.

Which versions of Angular, Material, OS, TypeScript, browsers are affected?

Forked from https://stackblitz.com/angular/apvvljrovad from the docs. no versions changed.

Is there anything else we should know?

#11621 also gets at my base requirement; wanting some elements of the virtual scroll list to 'stick around'. It would be ideal if standard css properties worked as via this ticket but that might be hard as the whole point of virtual-scroll is to remove things from the dom that 'shouldn't' be rendered. The position: sticky css attribute would seem to break those assumptions.

@ke1co1
Copy link

ke1co1 commented Feb 5, 2019

any luck? looking for same solution.

@gurshinder78
Copy link

Just following up on this, any update. This is must requirement for table with large dataset.

@ke1co1
Copy link

ke1co1 commented Mar 13, 2019

any luck? looking for same solution.

What i did to keep my header sticky was to use a sticky: true attribute in html.
example:

Hope this helps!

@Dassderdie
Copy link

@kelz2o3 I don't really get what your solution is. Could you add an example?

@ke1co1
Copy link

ke1co1 commented Mar 14, 2019

i added this to my header row like so

    <tr
      mat-header-row
      *matHeaderRowDef="displayedColumns; sticky: true"
    ></tr>

@gurshinder78
Copy link

sticky: true" is working fine with mat table if we don't use virtual scrolling, but it is messed up with virtual scrolling. Header starts moving when you start scrolling.

@florenthobein
Copy link

florenthobein commented Mar 18, 2019

My use case is that I need a sticky header always present above a virtually-scrolling list ; I'm probably not the only one.
Would you guys accept a PR that allows such a behavior (namely, a sticky header)?

@lppedd
Copy link

lppedd commented Apr 30, 2019

Here too, same problem now. @florenthobein which kind of workaround have you come up with?
What's the root cause of this behavior?

Edit: okay, it seems transformY is the cause.

@florenthobein
Copy link

florenthobein commented Apr 30, 2019

It's the transformY indeed, that gets updated whenever elements of the list are replaced (remove+append).

I don't really have a neat solution. Tried to meddle with extending the FixedSizeVirtualScrollStrategy and move back a provided sticky header whenever onRenderedOffsetChanged was triggered but it's ugly and still too far from when transformY is applied, resulting in random blips unsuited for production.

So for now my "workaround" consists in:

  • having a header that is outside of the CDK Virtual Scroll and absolutely positioned over the list. Note: the header height has to be a multiple of your cdk itemSize.
  • prepending the list with "fake" elements that are gonna be hidden behind the header (hence the need for a header that's the total height of those fake elements).

...yeah. 😓 And a bit of a mess to have the good width for the header as it's not positioned inside the list.

@lppedd
Copy link

lppedd commented May 9, 2019

@fabioloreggian Thanks for the explanation!
However, in the end I gave up, I don't want to use workaround that break over time

@wartab
Copy link

wartab commented May 10, 2019

@florenthobein I found a solution to the tranformY compensation, but for some reason it's not sufficient, as once I started scrolling too far down, the headers will scroll with the content. Regardless, this is what I came up with after fiddling around a bit with the CDK scroll view port.

cdk-virtual-scroll-viewport {
    height: 100%;

    table {
        height: 100%;
        width: 100%;
        border-collapse: separate;
        border-spacing: 0;

        thead {
            tr {
                th {
                    height: 59px;
                    border-bottom: 1px solid #d0d0d0;
                    padding: 7px 20px 7px 7px;
                    position: sticky;
                    background-color: #ffffff;
                    top: 0;
            }
        }
    }
}
<cdk-virtual-scroll-viewport #scrollViewport itemSize="59" (scrolledIndexChange)="scrollIndexChanged($event)">
  <table>
    <thead>
      <tr>
        <th [style.transform]="inverseTranslation">Header 1</th>
        <th [style.transform]="inverseTranslation">Header 2</th>
      </tr>
    </thead>
    <tbody>
      <tr *cdkVirtualFor="let movement of movements; even as even; index as i" [class.even]="even">...</tr>
    </tbody>
  </table>
</cdk-virtual-scroll-viewport>
@ViewChild(CdkVirtualScrollViewport)
public viewPort: CdkVirtualScrollViewport;

public get inverseTranslation(): string {
    if (!this.viewPort || !this.viewPort["_renderedContentTransform"]) {
        return "translateY(0px)";
    }
    return this.viewPort["_renderedContentTransform"].replace(/translateY\((\d+)px\)/, "translateY(-$1px)");
}

That gets rid of the weird effects, but only until you scrolled down for a while. Not sure what I'm doing wrong and if that's even a problem I could solve. I'm not too familiar with sticky positions, it's still sometimes a mystery how that works exactly.

@monu0751
Copy link

@wartab your solution is partially worked.
When we scroll down after few moment headers disappear. Can you explain your code so i can find some solution. I'm also facing same challenge.

@wartab
Copy link

wartab commented Jun 27, 2019

@monu0751 Sadly I do not know how to solve this. That is what I mean with "but for some reason it's not sufficient, as once I started scrolling too far down, the headers will scroll with the content".

This technique is to apply the opposite transform: translateY() to the column headers that CDK applies when using the virtual-scroll to create the scrollbar. This is done by adding a - sign in the regex replace.
That CSS transform is contained in a private field _renderedContentTransform in the VirtualScrollViewPort.

@literalpie
Copy link
Contributor

FWIW, I noticed that it does work as expected in Safari if you use position: -webkit-sticky;. Not sure if Webkit is the only one doing it right, or if they're just doing it in a different way that happens to work better for this case.

@literalpie
Copy link
Contributor

I have been somewhat successful in getting around this issue by forcing translateY(0) and then creating a component with height: Xpx at the beginning of the viewport (or in the first row of a table). I bind X to the value from virtualScrollViewport that is usually used for the translate value.

@lonestarjdavis
Copy link

lonestarjdavis commented Jul 15, 2019

@florenthobein I found a solution to the tranformY compensation, but for some reason it's not sufficient, as once I started scrolling too far down, the headers will scroll with the content. Regardless, this is what I came up with after fiddling around a bit with the CDK scroll view port.

cdk-virtual-scroll-viewport {
    height: 100%;

    table {
        height: 100%;
        width: 100%;
        border-collapse: separate;
        border-spacing: 0;

        thead {
            tr {
                th {
                    height: 59px;
                    border-bottom: 1px solid #d0d0d0;
                    padding: 7px 20px 7px 7px;
                    position: sticky;
                    background-color: #ffffff;
                    top: 0;
            }
        }
    }
}
<cdk-virtual-scroll-viewport #scrollViewport itemSize="59" (scrolledIndexChange)="scrollIndexChanged($event)">
  <table>
    <thead>
      <tr>
        <th [style.transform]="inverseTranslation">Header 1</th>
        <th [style.transform]="inverseTranslation">Header 2</th>
      </tr>
    </thead>
    <tbody>
      <tr *cdkVirtualFor="let movement of movements; even as even; index as i" [class.even]="even">...</tr>
    </tbody>
  </table>
</cdk-virtual-scroll-viewport>
@ViewChild(CdkVirtualScrollViewport)
public viewPort: CdkVirtualScrollViewport;

public get inverseTranslation(): string {
    if (!this.viewPort || !this.viewPort["_renderedContentTransform"]) {
        return "translateY(0px)";
    }
    return this.viewPort["_renderedContentTransform"].replace(/translateY\((\d+)px\)/, "translateY(-$1px)");
}

That gets rid of the weird effects, but only until you scrolled down for a while. Not sure what I'm doing wrong and if that's even a problem I could solve. I'm not too familiar with sticky positions, it's still sometimes a mystery how that works exactly.

The problem with the solution you posted is that you're using style.transform. A position: sticky evaluates to a position: fixed once you hit the part where it would scroll, and that doesn't work right with a transformation.

Instead use style.top and set it to the inverse number of pixels, similar to what you're doing now.

@wartab
Copy link

wartab commented Jul 16, 2019

@lonestarjdavis Amazing, thanks for the heads up, that works like a charm!

@artem-galas
Copy link

@wartab Could you please post a final solution, or create a stackblitz ?

Thanks.

@wartab
Copy link

wartab commented Jul 16, 2019

@artem-galas https://stackblitz.com/edit/components-issue-t3xvyz That works for my use case.

@lujian98
Copy link

@wartab Looks great with chrome.
However, when drag the scroll bar with firefox, the table header is still not clean.
Next if turn on the firefox debug inspector, it looks great again.
Do you have any idea what cause the issue with firefox wilthout turn on the debug inspector?

@wartab
Copy link

wartab commented Aug 21, 2019

@lujian98
Not sure what version of Firefox you are using, but it is not happening for me.
I also do not really have much insight into Firefox since we only target Electron, which is Chromium. Apologies.

@lujian98
Copy link

Thank you @wartab I am using latest Firefox.

@matthewiiv
Copy link

Nice one @wartab!

@fernandomeridamatilla
Copy link

fernandomeridamatilla commented Oct 16, 2019

@florenthobein I found a solution to the tranformY compensation, but for some reason it's not sufficient, as once I started scrolling too far down, the headers will scroll with the content. Regardless, this is what I came up with after fiddling around a bit with the CDK scroll view port.

cdk-virtual-scroll-viewport {
    height: 100%;

    table {
        height: 100%;
        width: 100%;
        border-collapse: separate;
        border-spacing: 0;

        thead {
            tr {
                th {
                    height: 59px;
                    border-bottom: 1px solid #d0d0d0;
                    padding: 7px 20px 7px 7px;
                    position: sticky;
                    background-color: #ffffff;
                    top: 0;
            }
        }
    }
}
<cdk-virtual-scroll-viewport #scrollViewport itemSize="59" (scrolledIndexChange)="scrollIndexChanged($event)">
  <table>
    <thead>
      <tr>
        <th [style.transform]="inverseTranslation">Header 1</th>
        <th [style.transform]="inverseTranslation">Header 2</th>
      </tr>
    </thead>
    <tbody>
      <tr *cdkVirtualFor="let movement of movements; even as even; index as i" [class.even]="even">...</tr>
    </tbody>
  </table>
</cdk-virtual-scroll-viewport>
@ViewChild(CdkVirtualScrollViewport)
public viewPort: CdkVirtualScrollViewport;

public get inverseTranslation(): string {
    if (!this.viewPort || !this.viewPort["_renderedContentTransform"]) {
        return "translateY(0px)";
    }
    return this.viewPort["_renderedContentTransform"].replace(/translateY\((\d+)px\)/, "translateY(-$1px)");
}

That gets rid of the weird effects, but only until you scrolled down for a while. Not sure what I'm doing wrong and if that's even a problem I could solve. I'm not too familiar with sticky positions, it's still sometimes a mystery how that works exactly.

inverseTranslation function can be improved as follows:

  get inverseTranslation(): string {
    const offset = this.viewPort.getOffsetToRenderedContentStart();

    return `-${offset}px`;
  }

or

get inverseTranslation(): string {
    return `-${this.viewPort.getOffsetToRenderedContentStart()}px`;
  }

@bdirito
Copy link
Author

bdirito commented Oct 16, 2019

@mmalerba Can you get this on the virtual scrolling bug list
https://github.com/angular/components/projects/20

@diegoale1994
Copy link

diegoale1994 commented Nov 1, 2019

@artem-galas https://stackblitz.com/edit/components-issue-t3xvyz That works for my use case.

that was a good example and it actually keeps the header in top, but in my case I have resizable columns and header position fails when moving :( look here https://gifyu.com/image/kyj4

@mmalerba mmalerba added the needs triage This issue needs to be triaged by the team label May 20, 2020
@Yohandah
Copy link

Yohandah commented Aug 21, 2020

I am in need of this feature as well.

@wartab Can I ask _renderedContentOffset is a private property ? Is there a "clean" way to access it (without string literals) ?

Also, it throws this pretty often:

ERROR Error: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value for 'top': '-700px'. Current value: '-1200px'.

@ebrehault
Copy link

@florenthobein I found a solution to the tranformY compensation, but for some reason it's not sufficient, as once I started scrolling too far down, the headers will scroll with the content. Regardless, this is what I came up with after fiddling around a bit with the CDK scroll view port.

cdk-virtual-scroll-viewport {
    height: 100%;

    table {
        height: 100%;
        width: 100%;
        border-collapse: separate;
        border-spacing: 0;

        thead {
            tr {
                th {
                    height: 59px;
                    border-bottom: 1px solid #d0d0d0;
                    padding: 7px 20px 7px 7px;
                    position: sticky;
                    background-color: #ffffff;
                    top: 0;
            }
        }
    }
}
<cdk-virtual-scroll-viewport #scrollViewport itemSize="59" (scrolledIndexChange)="scrollIndexChanged($event)">
  <table>
    <thead>
      <tr>
        <th [style.transform]="inverseTranslation">Header 1</th>
        <th [style.transform]="inverseTranslation">Header 2</th>
      </tr>
    </thead>
    <tbody>
      <tr *cdkVirtualFor="let movement of movements; even as even; index as i" [class.even]="even">...</tr>
    </tbody>
  </table>
</cdk-virtual-scroll-viewport>
@ViewChild(CdkVirtualScrollViewport)
public viewPort: CdkVirtualScrollViewport;

public get inverseTranslation(): string {
    if (!this.viewPort || !this.viewPort["_renderedContentTransform"]) {
        return "translateY(0px)";
    }
    return this.viewPort["_renderedContentTransform"].replace(/translateY\((\d+)px\)/, "translateY(-$1px)");
}

That gets rid of the weird effects, but only until you scrolled down for a while. Not sure what I'm doing wrong and if that's even a problem I could solve. I'm not too familiar with sticky positions, it's still sometimes a mystery how that works exactly.

The problem with the solution you posted is that you're using style.transform. A position: sticky evaluates to a position: fixed once you hit the part where it would scroll, and that doesn't work right with a transformation.

Instead use style.top and set it to the inverse number of pixels, similar to what you're doing now.

The inverseTranslation method will be call on every view check, maybe it is not ideal regarding performances.

The CdkVirtualScrollViewport has a ScrollStrategy private property offering a onRenderedOffsetChanged method we can use to update the header position:

       <tr>
        <th [style.top]="headerTop">Header 1</th>
        <th [style.top]="headerTop">Header 2</th>
      </tr>
    headerTop = '0px';

    ngAfterViewInit(): void {
        if (!!this.viewPort) {
            this.viewPort['_scrollStrategy'].onRenderedOffsetChanged = () =>
                (this.headerTop = `-${this.viewPort.getOffsetToRenderedContentStart()}px`);
        }
    }

@wiegell
Copy link

wiegell commented Mar 6, 2021

Working example with dynamic sticky items with correct placement:
https://stackblitz.com/edit/angular-ivy-pxmnpx?file=src/app/app.component.html
(css vars need angular 10+)
An issue can arise where the sticky element will jump 1 px up and down if the rows get sub-pixel-height (in my project bc. the rows are calculated from viewport / 4). This can be avoided by making sure that the viewport height is dividable by e.g. 4 in my case (see https://stackoverflow.com/questions/37754542/css-calc-round-down-with-two-decimal-cases/64921523)

Remember to implement something like:

if (platform.SAFARI) {
this.offset = "0px";
}

@DVelbeck
Copy link

DVelbeck commented Jan 27, 2022

Hello there,

I came across a similar issue and used the useful advices from this thread (thanks very much for sharing btw !!!).

However, this doesn't work if your table is handled with an *ngIf directive. The sticky headers workaround gets broken, when the *ngIf directive removes the table from the dom, therefore the @ViewChild assignment "gets lost" as the variable value is not refreshed.

My workaround was to use a setter instead of a variable, in my component :

/** Instead of using a variable, use a setter */
@ViewChild('tableViewPort') private set tableViewPortContent(tbvp: CdkVirtualScrollViewport) {
    this.tableViewPort = tbvp;
    if (this.tableViewPort) {
      this.initiateStickyHeaders();
    }
  }

  public ITEM_SIZE = 50;
  public offset: number;
  private tableViewPort: CdkVirtualScrollViewport;
  private tableSubscribers: any = {};

[...]

public ngOnInit(): void {}

public ngOnChanges(): void {}

public ngOnDestroy(): void {
    this.tableSubscribers.forEach((sub: any) => {
      sub?.unsubscribe();
    });
}

private initiateStickyHeaders(): void {
    /** Remove previous possible existing subscriber for cleanup */
    _.forEach(this.tableSubscribers, (sub: any) => {
      sub?.unsubscribe();
    });

    let subsc = this.tableViewPort?.scrolledIndexChange
      .pipe(
        map(() => this.tableViewPort?.getOffsetToRenderedContentStart() * -1),
        distinctUntilChanged(),
      )
      .subscribe((offset) => {
        this.offset = offset;
      });
    this.tableSubscribers.scrolledIndexChange = subsc;

    subsc = this.tableViewPort?.renderedRangeStream?.subscribe((range) => {
      this.offset = range.start * -this.ITEM_SIZE;
    });
    this.tableSubscribers.renderedRangeStream = subsc;
}

In my template :

<cdk-virtual-scroll-viewport #tableViewPort id="emloyee-table" [style.height.px]="600" [itemSize]="ITEM_SIZE" matSort (matSortChange)="sortContent($event)">
        <table class="base-margin-top table" mat-table [dataSource]="getPagedEmployeesList()">

          <ng-container *ngFor="let header of headers" [matColumnDef]="header">
            
              <th [style.top.px]="offset"  style="background-color: white !important;" mat-header-cell *matHeaderCellDef>{{header}}</th>

            <td mat-cell *matCellDef="let emp"> {{emp[header]}} </td>
          </ng-container>

          <tr mat-header-row *matHeaderRowDef="headers; sticky: true" style="background-color: white !important;" [style.top.px]="offset"></tr>
          <ng-template let-row matRowDef cdkVirtualFor [matRowDefColumns]="headers" [cdkVirtualForOf]="getPagedEmployeesList()">
            <tr mat-row></tr>
          </ng-template>

        </table>
</cdk-virtual-scroll-viewport>

@ojacquemart
Copy link

Here is my solution using a directive. It uses the MutationObserver api to detect attributes changes on the scroll view port wrapper and then update the header top position.

directive:

import { Directive, ElementRef, OnDestroy, OnInit } from '@angular/core';

const TRANSLATE_Y_REGEX = /translateY\((\d+)px\)/;

const MUTATION_ATTRIBUTES_TYPE: MutationRecordType = 'attributes';

@Directive({
  selector: '[appTableStickyHeader]',
})
export class TableStickyHeaderDirective implements OnInit, OnDestroy {

  private translateYObserver: MutationObserver;

  constructor(
    private readonly virtualScrollViewPort: ElementRef,
  ) {
  }

  ngOnInit() {
    this.initStickHeader();
  }

  ngOnDestroy() {
    if (this.translateYObserver) {
      this.translateYObserver.disconnect();
    }
  }

  private initStickHeader() {
    const contentWrapper = this.virtualScrollViewPort.nativeElement.firstChild;
    const tableHeader = this.virtualScrollViewPort.nativeElement.querySelector('thead');

    this.translateYObserver = new MutationObserver((mutations =>
      this.onMutationsChange(mutations, contentWrapper, tableHeader)),
    );
    this.translateYObserver.observe(contentWrapper, {
      attributes: true,
    });
  }

  private onMutationsChange(mutations: MutationRecord[], contentWrapper: HTMLElement, tableHeader: HTMLElement) {
    mutations
      .filter(it => it.type === MUTATION_ATTRIBUTES_TYPE)
      .forEach(() => this.setHeaderTopPosition(contentWrapper, tableHeader));
  }

  private setHeaderTopPosition(contentWrapper: HTMLElement, tableHeader: HTMLElement) {
    const match = contentWrapper.style.transform.match(TRANSLATE_Y_REGEX);
    if (!match) {
      return;
    }

    const translate = parseInt(match[1]);
    tableHeader.style.top = `-${translate}px`;
  }
}

css:

thead {
  position: sticky;
}

@spike-rabbit
Copy link
Contributor

Once #24394 is released (I guess 14.1), it is possible to have sticky elements before the scroll-viewport but within the scrolling element (adopted from the dev-app):

<div class="demo-viewport" cdkVirtualScrollingElement>
  <p class="position: sticky; top: 0">Content before virtual scrolling items</p>
  <cdk-virtual-scroll-viewport itemSize="50">
    <div *cdkVirtualFor="let size of fixedSizeData; let i = index" class="demo-item"
         [style.height.px]="size">
      Item #{{i}} - ({{size}}px)
    </div>
  </cdk-virtual-scroll-viewport>
  <p>Content after virtual scrolling items</p>
</div>

So with this it should be possible to implement sticky headers.

However, using position:; sticky on items within the scroll-viewport is still not working. I guess this cannot be simply fixed within the cdk, but at least it could provide developers a better way to deal with it. As described above, the translate value needs to be compensated in top: ?.

@mmalerba what do you think about this approach: Exposing the current translate value as a css variable. Doing this would allow developers to make elements sticky like this, without the need of any additional js:

.sticky-element {
  position: sticky;
  top: calc(<wanted-top-value> + var(--cdk-scrolling-offset))
}

@mmalerba
Copy link
Contributor

That's an interesting idea. Let me add it to our agenda for team discussion since we don't currently have any APIs like this

@mmalerba
Copy link
Contributor

Hi @spike-rabbit, I had a chance to discuss this with the team. In general we're not opposed to this type of API in situations where it makes sense. Folks did have a couple of concerns:

  1. We're not sure if there would be performance implications of constantly updating a CSS variable. We'd need to do some testing to see if it causes some kind of style thrashing
  2. There was some concern that sticky items may have bigger issues than just the positioning, i.e. if the item scrolls out of range it could be removed from the DOM completely. This wouldn't be a concern using the virtual scroll in append only mode, but in that case the position should be trivial anyways.

I do see that a lot of the requests here have to do with making sticky header elements that are not virtualized items, but just persistent elements that appear within the scrolling element. I guess I'm curious what the reason for putting these headers inside the scrolling element is. Why not just have them outside so they're naturally "sticky"? (Sorry if I'm just missing something obvious)

<div class="box-with-border">
  <div>Header</div>
</div>
<cdk-virtual-scroll-viewport>
  <div *cdkVirtualFor="...">Item</div>
</cdk-virtual-scroll-viewport>

If there's a good motivation for needing them to be inside the scrolling element, and the performance concerns turn out to not be an issue, I could see adding this.

@spike-rabbit
Copy link
Contributor

@mmalerba I poorly reimplemented a use case that I have: https://stackblitz.com/edit/angular-ujjkis. In the real life application, there are much more items so that we needed to virtualize scrolling.

What I am trying to show there is, that sticky elements can be used in larger items that are scrolled, if they have some kind of title or other meta information that should be visible while the item is in the viewport.

Regarding your concerns:

  1. We have a custom virtual scrolling component using css variables running in production for a few years already. There are at least no obvious performance issues. To be honest, the application is only used on desktop computers and I never did actual performance tests.
  2. Based on my experiences, using sticky items works pretty well. It's ok, that the item is removed from the DOM when out of the viewport. The sticky element is not visible at that time anyway.

I hope this is understandable. Unfortunately, I cannot share the link to the real application as it is located in our Intranet.

@mmalerba
Copy link
Contributor

Ah ok, that seems like a reasonable use case. I wasn't thinking about a single item possibly being bigger than the viewport. I think we would probably be fine adding a CSS variable then. The next step would just be to do some quick testing to see if setting the variable somehow impacts performance

@wartab
Copy link

wartab commented Jun 28, 2022

Hi @spike-rabbit, I had a chance to discuss this with the team. In general we're not opposed to this type of API in situations where it makes sense. Folks did have a couple of concerns:

1. We're not sure if there would  be performance implications of constantly updating a CSS variable. We'd need to do some testing to see if it causes some kind of style thrashing

2. There was some concern that sticky items may have bigger issues than just the positioning, i.e. if the item scrolls out of range it could be removed from the DOM completely. This wouldn't be a concern using the virtual scroll in append only mode, but in that case the position should be trivial anyways.

I do see that a lot of the requests here have to do with making sticky header elements that are not virtualized items, but just persistent elements that appear within the scrolling element. I guess I'm curious what the reason for putting these headers inside the scrolling element is. Why not just have them outside so they're naturally "sticky"? (Sorry if I'm just missing something obvious)

<div class="box-with-border">
  <div>Header</div>
</div>
<cdk-virtual-scroll-viewport>
  <div *cdkVirtualFor="...">Item</div>
</cdk-virtual-scroll-viewport>

If there's a good motivation for needing them to be inside the scrolling element, and the performance concerns turn out to not be an issue, I could see adding this.

The reason this gets cited a lot is because specifically tables tend to misbehave frequently if you insert things that don't belong there. Addtionally, they make it quite trivial to have the header columns the same width as the body columns without forcing the width.

@spike-rabbit
Copy link
Contributor

@mmalerba so how do we proceed here? Shall I create a pull request, and you do the performance testing?

@mmalerba
Copy link
Contributor

@spike-rabbit sure, if you send me a PR I can look into how to test the performance

@csisy
Copy link

csisy commented Aug 9, 2022

Once #24394 is released (I guess 14.1), it is possible to have sticky elements before the scroll-viewport but within the scrolling element (adopted from the dev-app):

<div class="demo-viewport" cdkVirtualScrollingElement>
  <p class="position: sticky; top: 0">Content before virtual scrolling items</p>
  <cdk-virtual-scroll-viewport itemSize="50">
    <div *cdkVirtualFor="let size of fixedSizeData; let i = index" class="demo-item"
         [style.height.px]="size">
      Item #{{i}} - ({{size}}px)
    </div>
  </cdk-virtual-scroll-viewport>
  <p>Content after virtual scrolling items</p>
</div>

As you have mentioned, this only works for non-table elements. Since the th element in the thead needs to be sticky, the cdk-virtual-scroll-viewport must be placed inside a table which does not work:

<div cdkVirtualScrollingElement>
  <table>
    <thead>
      <tr>
        <th style="position: sticky; top: 0;">Header</th>
      </tr>
    </thead>
    <tbody>
      <cdk-virtual-scroll-viewport itemSize="50">
        <tr *cdkVirtualFor="let row of rows" style="height: 50px">
          <td>Row data</td>
        </tr>
      </cdk-virtual-scroll-viewport>
    </tbody>
  </table>
</div>

Have you ever considered making this a directive instead? Most of its logic could be the same, but instead of manipulating the DOM directly, the directive would provide (in addition to the other values) two "spacing" values. One that represents an offset from the top, and another one from the bottom.

Using these values, the user could offset the elements appropriately. Take a look at a non-Angular example. This solution only refreshes the table on scroll finish event, however, the idea is the same. When the viewport items needs to be refreshed, the first and last "imaginary" row height is updated accordingly.

@kramar
Copy link

kramar commented Nov 23, 2022

Once #24394 is released (I guess 14.1), it is possible to have sticky elements before the scroll-viewport but within the scrolling element (adopted from the dev-app):

<div class="demo-viewport" cdkVirtualScrollingElement>
  <p class="position: sticky; top: 0">Content before virtual scrolling items</p>
  <cdk-virtual-scroll-viewport itemSize="50">
    <div *cdkVirtualFor="let size of fixedSizeData; let i = index" class="demo-item"
         [style.height.px]="size">
      Item #{{i}} - ({{size}}px)
    </div>
  </cdk-virtual-scroll-viewport>
  <p>Content after virtual scrolling items</p>
</div>

As you have mentioned, this only works for non-table elements. Since the th element in the thead needs to be sticky, the cdk-virtual-scroll-viewport must be placed inside a table which does not work:

<div cdkVirtualScrollingElement>
  <table>
    <thead>
      <tr>
        <th style="position: sticky; top: 0;">Header</th>
      </tr>
    </thead>
    <tbody>
      <cdk-virtual-scroll-viewport itemSize="50">
        <tr *cdkVirtualFor="let row of rows" style="height: 50px">
          <td>Row data</td>
        </tr>
      </cdk-virtual-scroll-viewport>
    </tbody>
  </table>
</div>

Have you ever considered making this a directive instead? Most of its logic could be the same, but instead of manipulating the DOM directly, the directive would provide (in addition to the other values) two "spacing" values. One that represents an offset from the top, and another one from the bottom.

Using these values, the user could offset the elements appropriately. Take a look at a non-Angular example. This solution only refreshes the table on scroll finish event, however, the idea is the same. When the viewport items needs to be refreshed, the first and last "imaginary" row height is updated accordingly.

Hey, is there any news on this? Are there plans to allow sticky elements to work inside a table?

@niteshkhanna1989
Copy link

niteshkhanna1989 commented Nov 24, 2022

i found somewhat a clean solution, it has it's own caveat though the headers do flicker, but that is just about it

const offset = this.cdkVirtualScrollViewport.measureScrollOffset('top');
this.cdkVirtualScrollViewport.scrollToOffset(offset - 96);
asapScheduler.schedule(() => this.cdkVirtualScrollViewport.scrollToOffset(offset), 1);

96 is just the double the row height value

@tomsawdayee
Copy link

Hi all, has anyone found a solution for this without any header flicker?

@naomitayeb
Copy link

I'm also searching for a solution, can it be fixed?
Thanks!

@kramar
Copy link

kramar commented Jan 10, 2023

@mmalerba Is it possible to increase the priority for this issue?

@arozdobudko-incomm
Copy link

+1

1 similar comment
@dmitrydutin
Copy link

+1

@engineerpavel
Copy link

More than 4 years have passed since the opening of the issue, and the solution has not appeared. It is sad.

@lvoiculescu-plenty
Copy link

lvoiculescu-plenty commented May 29, 2023

I managed to make it work. Not perfect, but it works.
Inside the ngOnInit put the next piece of code

this.viewport.scrolledIndexChange.subscribe(() => {

  const el = this.viewport.elementRef.nativeElement.getElementsByClassName('cdk-virtual-scroll-content-wrapper') as HTMLCollectionOf<HTMLElement>;

  if (el.length > 0 && el[0].style.transform) {
    const headerCells = this.viewport.elementRef.nativeElement.getElementsByClassName('mat-header-cell') as HTMLCollectionOf<HTMLElement>;
    const translateY = el[0].style.transform.replace('translateY(', '').slice(0, -1);

    for (let i = 0; i < headerCells.length; i++) {
      if (headerCells[i].style.transform !== ('translateY(-' + translateY + ')')) {
        headerCells[i].style.transform = 'translateY(-' + translateY + ')';
      }
    }
  }

})

@dnesdv
Copy link

dnesdv commented May 29, 2023

my implementation, may help u

import { AfterViewInit, Directive, OnDestroy, } from "@angular/core";
import { Subject, takeUntil, tap } from "rxjs";

// 4 year old issue)
// https://github.com/angular/components/issues/14833
@Directive({
  // eslint-disable-next-line @angular-eslint/directive-selector
  selector: "cdk-virtual-scroll-viewport[tableStickyHeader]"
})
export class CdkTableStickyHeaderDirective implements AfterViewInit, OnDestroy {
  private _destroy$: Subject<void>;

  constructor(
    private host: CdkVirtualScrollViewport,
  ) {
    this._destroy$ = new Subject();
  }

  ngAfterViewInit(): void {
    const header = this._getTableHeader();
    const contentWrapper = this._getContentWrapper();

    if (!header || !contentWrapper) return;

    this._stickyHeader(header);

    this.host.scrolledIndexChange.pipe(
      tap(_ => {
        const headerTranslateY = getComputedStyle(contentWrapper).getPropertyValue('transform')
        const translate = headerTranslateY.replace(/[^0-9\-.,]/g, '').split(',')[5].trim();

        header.style.top = `-${translate}px`;
      }),
      takeUntil(this._destroy$),
    ).subscribe()
  }

  ngOnDestroy(): void {
    this._destroy$.next();
    this._destroy$.complete();
  }

  private _getContentWrapper(): HTMLElement | null {
    return this.host.elementRef.nativeElement.querySelector(".cdk-virtual-scroll-content-wrapper");
  }

  private _getTableHeader(): HTMLElement | null {
    return this.host.elementRef.nativeElement.querySelector("thead");
  }

  private _stickyHeader(header: HTMLElement): void {
    header.style.position = "sticky";
    header.style.top = "0";
    header.style.zIndex = "1000";
  }
}```

@jnrpalma
Copy link

Good afternoon devs, developer here in Brazil, still no plausible solution for this header problem with CdkVirtualScroll?
I have this structure:

<cdk-virtual-scroll-viewport
    #tableVirtualScroll
    [itemSize]="itemSize"
    [style.height.px]="heightTableContainer"
    [minBufferPx]="heightTableContainer < 100 ? 100 : heightTableContainer"
    [maxBufferPx]="heightTableContainer < 200 ? 200 : heightTableContainer"
  >
    <table
      class="po-table"
      [ngClass]="{
        'po-table-striped': striped,
        'po-table-selectable': selectable,
        'po-table-interactive': selectable || sort
      }"
    >
      <thead class="po-table-header-sticky">
        <tr [class.po-table-header]="!height">
          <th
            *ngIf="hasSelectableColumn"
            [style.pointer-events]="hideSelectAll ? 'none' : 'auto'"
            class="po-table-column-selectable"
          >
            <div [class.po-table-header-fixed-inner]="height">
              <po-checkbox
                name="selectAll"
                *ngIf="!hideSelectAll"
                (p-change)="selectAllRows()"
                [p-checkboxValue]="selectAll === null ? 'mixed' : selectAll"
              ></po-checkbox>
            </div>
          </th>

          <th
            *ngIf="(hasMasterDetailColumn || hasRowTemplate) && !hasRowTemplateWithArrowDirectionRight"
            class="po-table-header-column po-table-header-master-detail"
          ></th>

but I don't find a solution... :/

@vnimrod
Copy link

vnimrod commented Oct 26, 2023

@artem-galas https://stackblitz.com/edit/components-issue-t3xvyz That works for my use case.

based on that, i found working clean solution.
inverseOfTranslation (viewportOffsetNumber below) should be equal to the next (-1) * offset number, while scrolling.
how to achieve that?

  <cdk-virtual-scroll-viewport [itemSize]=[virtualItemSize]>
    <table>
      <thead>
        <tr>
          <th [style.top]="viewportOffsetNumber">Column 1</th>
          <th [style.top]="viewportOffsetNumber">Column 2</th>
        </tr>
      </thead>
      <tbody>
        <tr *cdkVirtualFor="let row of rows">
          <td>{{row.col1}}</td>
          <td>{{row.col2}}</td>
        </tr>
      </tbody>
    </table>
  </cdk-virtual-scroll-viewport>
</div>
ngAfterViewInit(): void {
  this.viewport.renderedRangeStream.pipe(
    tap(range => {
      this.viewportOffsetNumber = `-${this.virtualItemSize * range.start}px`;
    })
  ).subscribe();
}

@Frtrillo
Copy link

@artem-galas https://stackblitz.com/edit/components-issue-t3xvyz That works for my use case.

based on that, i found working clean solution. inverseOfTranslation (viewportOffsetNumber below) should be equal to the next (-1) * offset number, while scrolling. how to achieve that?

  <cdk-virtual-scroll-viewport [itemSize]=[virtualItemSize]>
    <table>
      <thead>
        <tr>
          <th [style.top]="viewportOffsetNumber">Column 1</th>
          <th [style.top]="viewportOffsetNumber">Column 2</th>
        </tr>
      </thead>
      <tbody>
        <tr *cdkVirtualFor="let row of rows">
          <td>{{row.col1}}</td>
          <td>{{row.col2}}</td>
        </tr>
      </tbody>
    </table>
  </cdk-virtual-scroll-viewport>
</div>
ngAfterViewInit(): void {
  this.viewport.renderedRangeStream.pipe(
    tap(range => {
      this.viewportOffsetNumber = `-${this.virtualItemSize * range.start}px`;
    })
  ).subscribe();
}

Worked for me although not when you do a responsive table for horizontal scroll(more this is a cdk virtual scrolling issue imo)

@IgorTaranenko
Copy link

@artem-galas https://stackblitz.com/edit/components-issue-t3xvyz That works for my use case.

You are best!
I've resolved my issue

@kapil26021994
Copy link

I have fixed above issue by adding new class in the tr mat-header-row tag with class name-“table-row" put below css in the class name-

.table-row {
  position: sticky;
  top: 0;
  z-index: 10;
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area: cdk/scrolling P3 An issue that is relevant to core functions, but does not impede progress. Important, but not urgent
Projects
None yet
Development

No branches or pull requests