Search code examples
angularangular-material

Angular MatTree with FlatTreeControl and CdkVirtualScrollViewport, preserve expanded node


I want store the opening state of the nodes so that in virtual scrolling the user finds them already open if he had them open:

https://stackblitz.com/edit/stackblitz-starters-cqbnsy

import { bootstrapApplication } from '@angular/platform-browser';
import {
  AfterViewInit,
  Component,
  ViewChild,
  provideExperimentalZonelessChangeDetection,
} from '@angular/core';
import { Observable, of } from 'rxjs';
import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling';
import { FlatTreeControl } from '@angular/cdk/tree';
import {
  MatTreeFlatDataSource,
  MatTreeFlattener,
} from '@angular/material/tree';
import { MatIconModule } from '@angular/material/icon';
import { MatTreeModule } from '@angular/material/tree';
import { MatButtonModule } from '@angular/material/button';
import { ScrollingModule } from '@angular/cdk/scrolling';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';

export interface NestedFileSystemItem {
  children: NestedFileSystemItem[];
  name: string;
}

export interface NestedFileSystemItemFlatNode {
  expandable: boolean;
  name: string;
  level: number;
}

const DATI = new Array(100_000)
  .fill({
    name: 'Folder',
    children: [
      { name: 'File 1' },
      { name: 'File 2' },
      {
        name: 'Sub Folder',
        children: [
          { name: 'Sub File 1' },
          { name: 'Sub File 2' },
          {
            name: 'Sub Sub Folder',
            children: [{ name: 'Sub Sub File 1' }, { name: 'Sub Sub File 2' }],
          },
        ],
      },
    ],
  })
  .map((item, index) => {
    return { ...item, name: item.name + ' (' + index + ')' };
  });

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [MatIconModule, MatButtonModule, MatTreeModule, ScrollingModule],
  styles: `@import "https://fonts.googleapis.com/icon?family=Material+Icons";`,
  template: `
  <cdk-virtual-scroll-viewport itemSize="48" style="height: 500px;">
    <ng-container *cdkVirtualFor="let item of fullDataSource"></ng-container>

    <mat-tree [dataSource]="dataSource" [treeControl]="treeControl">
        <mat-tree-node *matTreeNodeDef="let node" matTreeNodeToggle matTreeNodePadding>
          <button mat-icon-button disabled></button>
          {{node.name}}
        </mat-tree-node>

        <mat-tree-node *matTreeNodeDef="let node;when: hasChild" matTreeNodePadding>
          <button mat-icon-button matTreeNodeToggle
                  [attr.aria-label]="'toggle ' + node.name">
            <mat-icon class="mat-icon-rtl-mirror">
              {{treeControl.isExpanded(node) ? 'expand_more' : 'chevron_right'}}
            </mat-icon>
          </button>
          {{node.name}}
        </mat-tree-node>
    </mat-tree>
  </cdk-virtual-scroll-viewport>
  `,
})
export class AppComponent implements AfterViewInit {
  treeControl: FlatTreeControl<NestedFileSystemItemFlatNode>;
  treeFlattener: MatTreeFlattener<
    NestedFileSystemItem,
    NestedFileSystemItemFlatNode
  >;
  dataSource: MatTreeFlatDataSource<
    NestedFileSystemItem,
    NestedFileSystemItemFlatNode
  >;
  fullDataSource = DATI;
  @ViewChild(CdkVirtualScrollViewport) virtualScroll!: CdkVirtualScrollViewport;

  constructor() {
    this.treeFlattener = new MatTreeFlattener(
      this.transformer,
      this._getLevel,
      this._isExpandable,
      this._getChildren
    );
    this.treeControl = new FlatTreeControl<NestedFileSystemItemFlatNode>(
      this._getLevel,
      this._isExpandable
    );
    this.dataSource = new MatTreeFlatDataSource(
      this.treeControl,
      this.treeFlattener
    );
    this.dataSource.data = this.fullDataSource.slice(0, 10);
  }

  transformer = (
    node: NestedFileSystemItem,
    level: number
  ): NestedFileSystemItemFlatNode => ({
    expandable: !!node.children,
    name: node.name,
    level,
  });

  _getLevel = (node: NestedFileSystemItemFlatNode) => node.level;

  _isExpandable = (node: NestedFileSystemItemFlatNode) => node.expandable;

  _getChildren = (
    node: NestedFileSystemItem
  ): Observable<NestedFileSystemItem[]> => of(node.children);

  hasChild = (_: number, _nodeData: NestedFileSystemItemFlatNode) =>
    _nodeData.expandable;

  ngAfterViewInit() {
    this.virtualScroll.renderedRangeStream.subscribe((range: any) => {
      this.dataSource.data = this.fullDataSource.slice(range.start, range.end);
    });
  }
}

bootstrapApplication(AppComponent, {
  providers: [
    provideExperimentalZonelessChangeDetection(),
    provideAnimationsAsync(),
  ],
}).catch((err) => console.error(err));

Solution

  • Unfortunately I am unable to make it work with flat datasource but it is achievable using nested data source, please find below working stackblitz and a reference stackblitz used for solving this problem. This nested tree data has the advantage of controlling the child rendering container, which we can hide using the css [class.example-tree-invisible]="node.expanded" and by using the property expanded which we store on the data object, we can make the view remember which was opened.

    FULL CODE:

    import { bootstrapApplication } from '@angular/platform-browser';
    import {
      AfterViewInit,
      Component,
      ViewChild,
      provideExperimentalZonelessChangeDetection,
    } from '@angular/core';
    import { Observable, of } from 'rxjs';
    import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling';
    import { FlatTreeControl, NestedTreeControl } from '@angular/cdk/tree';
    import {
      MatTreeFlatDataSource,
      MatTreeFlattener,
      MatTreeNestedDataSource,
    } from '@angular/material/tree';
    import { MatIconModule } from '@angular/material/icon';
    import { MatTreeModule } from '@angular/material/tree';
    import { MatButtonModule } from '@angular/material/button';
    import { ScrollingModule } from '@angular/cdk/scrolling';
    import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
    import { CommonModule } from '@angular/common';
    
    export interface NestedFileSystemItem {
      children: NestedFileSystemItem[];
      name: string;
    }
    
    export interface NestedFileSystemItemFlatNode {
      expandable: boolean;
      name: string;
      level: number;
    }
    
    const DATI = new Array(100_000)
      .fill({
        name: 'Folder',
        children: [
          { name: 'File 1' },
          { name: 'File 2' },
          {
            name: 'Sub Folder',
            children: [
              { name: 'Sub File 1' },
              { name: 'Sub File 2' },
              {
                name: 'Sub Sub Folder',
                children: [{ name: 'Sub Sub File 1' }, { name: 'Sub Sub File 2' }],
              },
            ],
          },
        ],
      })
      .map((item, index) => {
        return { ...item, name: item.name + ' (' + index + ')' };
      });
    
    @Component({
      selector: 'app-root',
      standalone: true,
      imports: [
        MatIconModule,
        MatButtonModule,
        MatTreeModule,
        ScrollingModule,
        CommonModule,
      ],
      styles: `@import "https://fonts.googleapis.com/icon?family=Material+Icons";`,
      template: `
      <cdk-virtual-scroll-viewport itemSize="48" style="height: 500px;">
        <ng-container *cdkVirtualFor="let item of fullDataSource"></ng-container>
    
        <mat-tree [dataSource]="dataSource" [treeControl]="treeControl" class="example-tree">
      <mat-tree-node *matTreeNodeDef="let node">
        <li class="mat-tree-node">
          <button mat-icon-button disabled></button>
          {{node.name}}
        </li>
      </mat-tree-node>
    
      <mat-nested-tree-node *matTreeNodeDef="let node; when: hasNestedChild">
        <li>
          <div class="mat-tree-node">
            <button mat-icon-button
                    [attr.aria-label]="'toggle ' + node.name"
                    (click)="changeState(node)">
              <mat-icon class="mat-icon-rtl-mirror">
                {{node.expanded ? 'expand_more' : 'chevron_right'}}
              </mat-icon>
            </button>
            {{node.name}}
          </div>
          <ul [class.example-tree-invisible]="node.expanded">
            <ng-container matTreeNodeOutlet></ng-container>
          </ul>
        </li>
      </mat-nested-tree-node>
    </mat-tree>
      </cdk-virtual-scroll-viewport>
      `,
    })
    export class AppComponent implements AfterViewInit {
      treeControl: FlatTreeControl<NestedFileSystemItemFlatNode>;
      treeFlattener: any;
      dataSource: any;
      fullDataSource = DATI;
      @ViewChild(CdkVirtualScrollViewport) virtualScroll!: CdkVirtualScrollViewport;
    
      constructor() {
        this.treeControl = new NestedTreeControl<any>(this._getChildren);
        this.dataSource = new MatTreeNestedDataSource();
        this.dataSource.data = this.fullDataSource.slice(0, 10);
      }
    
      transformer = (
        node: NestedFileSystemItem,
        level: number
      ): NestedFileSystemItemFlatNode => ({
        expandable: !!node.children,
        name: node.name,
        level,
      });
    
      private _getChildren = (node: any) => node?.children;
    
      ngAfterViewInit() {
        this.virtualScroll.renderedRangeStream.subscribe((range: any) => {
          this.dataSource.data = this.fullDataSource.slice(range.start, range.end);
        });
      }
    
      hasNestedChild = (_: number, nodeData: any) => nodeData?.children?.length > 0;
    
      changeState(node: any) {
        node.expanded = !node.expanded;
        console.log(node);
      }
    }
    
    bootstrapApplication(AppComponent, {
      providers: [
        provideExperimentalZonelessChangeDetection(),
        provideAnimationsAsync(),
      ],
    }).catch((err) => console.error(err));
    

    Stackblitz Demo

    Reference Stackblitz Demo