You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Angular17 CDK拖拽cdkDropListDropped事件仅触发一次问题排查

Angular 17嵌套树拖拽异常:节点首次拖拽后无法触发后续事件

问题现象

基于Angular Material拖拽API开发的嵌套树组件,页面包含左右两棵树,需求是将右侧树节点拖拽至左侧树。当前存在以下异常:

  • 首次拖拽右侧节点到左侧树时,cdkDropListDropped事件正常触发
  • 后续针对该节点的拖拽操作完全无事件触发,但拖拽到同层级其他节点仍正常工作
  • 只要节点触发过任意拖拽相关事件,后续所有事件均失效,检查DOM发现节点的ID和类名无变化

组件代码

HTML模板

<mat-tree [dataSource]="categories" [treeControl]="treeControl" class="fb-tree">
<mat-tree-node *matTreeNodeDef="let category" matTreeNodeToggle matTreeNodePadding
    [ngClass]="{' border-t ': treeMode === 'INTERNAL'}">
    @if(treeMode === " INTERNAL"){
    <div class=" uppercase" cdkDropList [id]="getCategoryCode(category)" (cdkDropListDropped)="drop($event)"
        [cdkDropListData]="nodeCategoryList">
        <div class=" grid grid-cols-2 gap-0">
            <div class="grid grid-cols-8">
                @if(hasEmojiAndRank(category)){
                <div *ng-if="hasEmojiAndRank(category)" class=" justify-start items-start w-1/6 self-center"
                    [class.ml-11]="isRoot(category)">
                    {{ category.emoji }}
                </div>

                <div class=" justify-start items-start w-1/6 self-center" [class.ml-2]="!isRoot(category)"
                    [class.ml-12]="isRoot(category)">
                    {{ category.rank }}
                </div>}

                <div class=" justify-start items-start w-4/6  uppercase" [class.ml-10]="isRoot(category)"
                    [class.ml-1.5]="!isRoot(category)">
                    {{ category.code }}
                </div>
            </div>
            <div class="group flex relative justify-end">
                <button mat-icon-button [matMenuTriggerFor]="menu" aria-label=" icon-button with a menu">
                    <mat-icon>more_vert</mat-icon>
                </button>
                <mat-menu #menu="matMenu">
                    <button mat-menu-item (click)="addNewItem.emit(category)">
                        <mat-icon>add</mat-icon>
                        <span>Add Category</span>
                    </button>
                    <button mat-menu-item (click)="editItem.emit(category)">
                        <mat-icon>edit</mat-icon>
                        <span>Edit</span>
                    </button>
                    <button mat-menu-item (click)="removeItem.emit(category)">
                        <mat-icon>delete</mat-icon>
                        <span>Delete</span>
                    </button>
                </mat-menu>
            </div>
        </div>
        <div class="ml-10">
            <mat-chip-set #chipGrid>
                @for (ex of category.partners; track ex.entries) {
                @for (value of ex[1]; track value) {
                <mat-chip class="uppercase" (removed)="chipRemoved(category, value, ex[0])">
                    [{{ ex[0] }}] {{ value }}
                    <button matChipRemove [attr.aria-label]="'remove ' + value">
                        <mat-icon>cancel</mat-icon>
                    </button>
                </mat-chip>
                }}
            </mat-chip-set>
        </div>
    </div>
    }@else {
    <div cdkDropList CdkDragStart [id]="buildPartnerId(category)" [cdkDropListConnectedTo]="nodeCategoryList"
        (cdkDropListDropped)="partnerDrop($event)" (cdkDragStarted)="startDrag(category)"
        [cdkDropListData]="nodeCategoryList">
        <div cdkDrag class="w-max h-max">{{ category.code }}</div>
    </div>
    }

</mat-tree-node>

<mat-nested-tree-node *matTreeNodeDef="let category; when: hasChild" matTreeNodePadding>
    @if(treeMode === "INTERNAL"){
    <div class="flex flex-col" cdkDropList id="{{ category.code }}" (cdkDropListDropped)="drop($event)"
        [cdkDropListData]="nodeCategoryList">
        <div class="mat-tree-node grid grid-cols-2 gap-0 border-t">
            <div class="grid grid-cols-8">
                <div class="flex flex-col justify-start items-start w-1/6 mr-10">
                    <button mat-icon-button matTreeNodeToggle [attr.aria-label]="'Toggle ' + category.code">
                        <mat-icon class="mat-icon-rtl-mirror">
                            {{ treeControl.isExpanded(category) ? 'expand_more' : 'chevron_right' }}
                        </mat-icon>
                    </button>
                </div>
                <div class="flex flex-col justify-start items-start w-1/6 ml-2 self-center">
                    {{ category.emoji }}
                </div>
                <div class="flex flex-col justify-start items-start w-1/6 self-center ml-3">
                    {{ category.rank }}
                </div>

                <div class="flex flex-col justify-start items-start ml-1 w-5/6 self-center">
                    {{ category.code }}
                </div>
            </div>

            <div class="group flex relative justify-end">
                <button mat-icon-button [matMenuTriggerFor]="menu" aria-label=" icon-button with a menu">
                    <mat-icon>more_vert</mat-icon>
                </button>
                <mat-menu #menu="matMenu">
                    <button mat-menu-item (click)="addNewItem.emit(category)">
                        <mat-icon>add</mat-icon>
                        <span>Add Category</span>
                    </button>
                    <button mat-menu-item (click)="editItem.emit(category)">
                        <mat-icon>edit</mat-icon>
                        <span>Edit</span>
                    </button>
                    <button mat-menu-item (click)="removeItem.emit(category)">
                        <mat-icon>delete</mat-icon>
                        <span>Delete</span>
                    </button>
                </mat-menu>
            </div>
        </div>
        <div class="ml-10">
            <mat-chip-set #chipGrid>
                @for (ex of category.partners; track ex.entries) {
                @for (value of ex[1]; track value) {
                <mat-chip class="uppercase" (removed)="chipRemoved(category, value, ex[0])">
                    [{{ ex[0] }}] {{ value }}
                    <button matChipRemove [attr.aria-label]="'remove ' + value">
                        <mat-icon>cancel</mat-icon>
                    </button>
                </mat-chip>
                }}
            </mat-chip-set>
        </div>
    </div>
    }@else {
    <div class="mat-tree-node" cdkDropList id="{{ category.code }}">
        <button mat-icon-button matTreeNodeToggle [attr.aria-label]="'Toggle ' + category.code">
            <mat-icon class="mat-icon-rtl-mirror">
                {{ treeControl.isExpanded(category) ? 'expand_more' : 'chevron_right' }}
            </mat-icon>
        </button>
        <div cdkDropList [id]="buildPartnerId(category)" [cdkDropListConnectedTo]="nodeCategoryList"
            (cdkDropListDropped)="drop($event)" [cdkDropListData]="nodeCategoryList">
            <div cdkDrag class="w-max h-max">{{ category.code }}</div>
        </div>
    </div>
    }

    <div [class.fb-tree-invisible]="!treeControl.isExpanded(category)" role="group">
        <ng-container matTreeNodeOutlet></ng-container>
    </div>
</mat-nested-tree-node>

TypeScript代码

export class FbTreeComponent implements OnInit, OnDestroy {
  @Input() categories = new MatTreeNestedDataSource<Category>();
  @Input() partnerCategories!: PartnerCategory[];
  @Input() treeControl = new NestedTreeControl<Category>((node) => node.children);
  @Input() treeMode: string;
  @Input() nodeCategoryList!: string[];
  @Output() dropCategory = new EventEmitter<CdkDragDrop<string[]>>();
  @Output() partnerDropCategory = new EventEmitter<CdkDragDrop<string[]>>();
  @Output() addNewItem = new EventEmitter<Category>();
  @Output() editItem = new EventEmitter<Category>();
  @Output() removeItem = new EventEmitter<Category>();
  @Output() removeChip = new EventEmitter<any>();

  constructor(
    private cd: ChangeDetectorRef,
    private localStorageService: LocalStorageService
  ) { }

  ngOnInit(): void {
    console.log(this.categories);
  }

  hasChild = (_: number, node: Category) => !!node.children && node.children.length > 0;

  isRoot(node: Category): boolean {
    return !node.children || node.children.length === 0; // or another condition that defines a root node
  }

  drop(event: CdkDragDrop<any>) {
    this.dropCategory.emit(event);
  }
}

排查与解决方案

1. 确保cdkDropList全局ID唯一

拖拽系统依赖唯一ID识别拖拽列表,需保证左右两棵树的所有cdkDropListID不重复。检查buildPartnerId(category)和getCategoryCode(category)的实现,确保返回值全局唯一,比如给右侧树节点ID添加前缀(如partner-{{category.code}})。

2. 拖拽后强制触发变更检测

当节点被拖拽到左侧树并更新数据源后,调用变更检测方法确保组件视图和拖拽系统同步:

drop(event: CdkDragDrop<any>) {
  this.dropCategory.emit(event);
  // 触发变更检测
  this.cd.detectChanges();
}

3. 移除嵌套的cdkDropList

右侧树模板中存在cdkDropList嵌套(外层div和内层div都标记了cdkDropList),这会导致拖拽事件被拦截。保留最内层或最外层的cdkDropList即可,比如移除外层的cdkDropList属性:

<!-- 修改前 -->
<div class="mat-tree-node" cdkDropList id="{{ category.code }}">
<!-- 修改后 -->
<div class="mat-tree-node">

4. 动态更新cdkDropListConnectedTo的目标列表

当左侧树新增节点时,需要将新节点的ID添加到nodeCategoryList中,确保右侧树的拖拽列表能关联到新的目标:

// 在父组件处理drop事件时更新nodeCategoryList
onDropCategory(event: CdkDragDrop<any>) {
  // 处理节点添加逻辑...
  this.nodeCategoryList.push(newNodeId);
}

5. 调试拖拽事件生命周期

添加cdkDragEnded事件监听,确认拖拽事件是否正常结束,帮助定位问题:

<div cdkDrag class="w-max h-max" (cdkDragEnded)="onDragEnded($event)">{{ category.code }}</div>
onDragEnded(event: CdkDragEnded) {
  console.log('拖拽结束事件:', event);
}

内容的提问来源于stack exchange,提问作者Amine Choukri

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.22 18:55:57