Angular MatTree结合虚拟滚动:持久化节点展开状态方案
Angular虚拟滚动MatTree节点展开状态持久化方案
当前实现中,虚拟滚动切换渲染范围时,dataSource.data被重新赋值,导致树控件的展开状态丢失。这是因为每次切片数据后,树控件会重新生成扁平节点,原有展开状态没有被持久化。以下是解决方法:
实现步骤
- 全局缓存展开状态:用
Set<string>存储所有展开节点的唯一标识(示例中用节点name作为唯一键) - 监听展开/折叠事件:捕获树节点的状态变化,同步更新缓存
- 数据更新后恢复状态:每次虚拟滚动加载新数据范围后,根据缓存自动恢复对应节点的展开状态
修改后的完整代码
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; // 存储展开节点的唯一标识 expandedNodes = new Set<string>(); 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); // 监听节点展开/折叠事件,更新缓存 this.treeControl.expansionModel.changed.subscribe(change => { change.added.forEach(node => this.expandedNodes.add(node.name)); change.removed.forEach(node => this.expandedNodes.delete(node.name)); }); } 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); // 数据更新后,恢复节点展开状态 this.treeControl.dataNodes.forEach(node => { if (this.expandedNodes.has(node.name)) { this.treeControl.expand(node); } }); }); } } bootstrapApplication(AppComponent, { providers: [ provideExperimentalZonelessChangeDetection(), provideAnimationsAsync(), ], }).catch((err) => console.error(err));
关键修改说明
- 添加
expandedNodes集合:用Set<string>存储所有展开节点的name,作为唯一标识追踪状态 - 监听展开状态变化:通过
treeControl.expansionModel.changed事件,将展开的节点加入集合,折叠的节点移除 - 虚拟滚动数据更新后恢复状态:在
renderedRangeStream订阅中,新数据加载完成后遍历所有扁平节点,根据expandedNodes集合恢复对应节点的展开状态
这样就能保证虚拟滚动切换范围时,之前展开的节点会自动保持展开状态。
内容的提问来源于stack exchange,提问作者ar099968
相关产品推荐
相关产品推荐

