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

Angular/CDK Tree数据更新重渲染后如何实现持续实时拖拽

拖拽树形组件实时UI更新问题

更新: 提供了Stackblitz示例

说明: 我的目标是实现拖拽过程中UI实时更新。技术方案可完全不同(当前方案可能行不通),只要不是维护不佳的小众包,用原生HTML+CSS实现也可

当前方案问题:修改底层数据并首次重渲染后,cdkDragMoved回调不再触发,但我希望拖拽能持续进行,直到用户释放鼠标(拖拽结束),期间可反复检测并更新数据(拖拽过程中已完成更新)。推测重渲染时CdkDrag被销毁重建?

问题: 如何实现在拖拽过程中持续实时更新UI,直到用户释放鼠标?


我基于节点层级数据构建分类树,通过ng-template递归渲染单个分类及其子树结构。使用CDK实现拖拽功能,在cdkDragMoved回调中,将拖拽位置与所有分类边界框比对,匹配到非自身分类时更新底层数据。数据更新会触发ngOnInit中设置的订阅逻辑,更新本地数据并触发重渲染。


// ...imports

type CategoryId = string;

@Component({
  selector: 'app-category-tree-a',
  template: `
    <!-- ---------------------------------------------------------------------- -->
    <!-- RECURSIVE SUB-TREE TEMPLATE -->
    <!-- ---------------------------------------------------------------------- -->

    <ng-template #subtree let-node="node">
      <div
        cdkDrag
        [cdkDragData]="node.categoryModel.id"
        (cdkDragStarted)="dragStarted($event)"
        (cdkDragMoved)="dragMoved($event)"
        (cdkDragReleased)="dragReleased($event)"
      >
        <div
          class="flex flex-col h-10 select-none"
          [ngStyle]="{
            backgroundColor: node.categoryModel.color,
            marginLeft: node.depth * 16 + 'px'
          }"
        >
          {{ node.categoryModel.name }}
        </div>
        <ng-container *ngFor="let child of node.children">
          <ng-container *ngTemplateOutlet="subtree; context: { node: child }">
          </ng-container>
        </ng-container>
      </div>
    </ng-template>

    <!-- ---------------------------------------------------------------------- -->
    <!-- ROOT SUB-TREES -->
    <!-- ---------------------------------------------------------------------- -->

    <div *ngFor="let rootNode of rootNodes">
      <ng-container *ngTemplateOutlet="subtree; context: { node: rootNode }">
      </ng-container>
    </div>
  `,
})
export class CategoryTreeAComponent implements OnInit, OnDestroy {
  constructor(public appService: AppService, public dataService: DataService) {}

  subscriptions: Array<Subscription> = [];

  /**
   * Last categoriesById received from DataService.
   * Used to create rootNodes and to look up by id during drag.
   */
  categoriesById: CategoriesById = new Map();

  /**
   * Last rootNodes created from last received categoriesById
   * Used to render the hierarchy and to find related nodes during drag.
   */
  rootNodes: CategoryNode[] = [];

  isChangePending = false;

  // all category elements identified by having the CdkDrag directive
  @ViewChildren(CdkDrag<CategoryId>) dragDirectives!: QueryList<
    CdkDrag<CategoryId>
  >;

  // ----------------------------------------------------------------------
  // Lifecycle
  // ----------------------------------------------------------------------

  ngOnInit(): void {
    this.subscriptions.push(
      this.dataService.categoriesById$.subscribe((categoriesById) => {
        console.log(`ngOnInit: categoriesById => next `);
        this.categoriesById = categoriesById;
        this.rootNodes = DataHelper.createNodesFromCategoryById(categoriesById);
        this.isChangePending = false;
      })
    );
  }

  ngOnDestroy(): void {
    this.subscriptions.forEach((s) => s.unsubscribe());
  }

  // ----------------------------------------------------------------------
  // Drag & Drop
  // ----------------------------------------------------------------------

  dragStarted(event: CdkDragStart<CategoryId>) {
    console.log(`dragStarted: ${event.source.data}`);
  }

  async dragMoved(event: CdkDragMove<CategoryId>) {
    const pointerPos = JSON.stringify(event.pointerPosition);
    console.log(`dragMoved: $mouse=${pointerPos} id:${event.source.data}`);

    if (this.isChangePending) {
      console.log(`...isChangePending. abort.`);
      return;
    }

    // position of dragged element
    const dragNative = event.source.element.nativeElement;
    const dragChildElement = dragNative.querySelector('div:first-child');
    const dragChildRect = dragChildElement!.getBoundingClientRect();
    const dragMidY = dragChildRect.top + dragChildRect.height / 2;

    // drag directive of div of sub-tree of "matched" category or null
    // matched means mid-y of dragged category element is inside
    // (category is first child of group div with drag directive)
    // (using drag directive because it has the data attached)
    // (is also null if the match is with itself)
    let matchDrag: CdkDrag<CategoryId> | null = null;

    for (const testDrag of this.dragDirectives) {
      const testNative = testDrag.element.nativeElement;
      const testChild = testNative.querySelector('div:first-child');
      const testRect = testChild!.getBoundingClientRect();

      const testId = testDrag.data;
      const testNode = DataHelper.findNodeById(this.rootNodes, testId);
      if (!testNode) break;

      const testName = testNode.categoryModel.name;
      const isInside = dragMidY >= testRect.top && dragMidY <= testRect.bottom;
      const isSelf = testNative === dragNative;

      // console.log(
      //   `...${testName.padEnd(14)} ${isSelf ? '*' : ' '} rect=${JSON.stringify(
      //     testRect
      //   )}`
      // );

      if (isInside && !isSelf) {
        matchDrag = testDrag;
        break;
      }
    }

    if (matchDrag !== null) {
      const sourceDrag = event.source;
      const targetDrag = matchDrag;

      const sourceId = sourceDrag.data;
      const targetId = targetDrag.data;

      const sourceNode = DataHelper.findNodeById(this.rootNodes, sourceId);
      const targetNode = DataHelper.findNodeById(this.rootNodes, targetId);

      if (sourceNode === null) {
        console.log(`sourceNode is null. abort.`);
        return;
      }
      if (targetNode === null) {
        console.log(`targetNode is null. abort.`);
        return;
      }

      const sourceName = sourceNode.categoryModel.name;
      const targetName = targetNode.categoryModel.name;

      const sourceSiblings = sourceNode.parent?.children ?? this.rootNodes;
      const targetSiblings = targetNode.parent?.children ?? this.rootNodes;

      const sourceIndex = sourceSiblings.indexOf(sourceNode);
      const targetIndex = targetSiblings.indexOf(targetNode);

      console.log(
        `...MATCH - from: ${sourceName} (${sourceIndex}) to:${targetName} (${targetIndex})`
      );

      sourceSiblings.splice(sourceIndex, 1);
      targetSiblings.splice(targetIndex, 0, sourceNode);
      this.isChangePending = true;

      if (sourceSiblings === targetSiblings) {
        // reordering siblings of same parent
        const updatedCategories =
          DataHelper.updateSiblingCategories(sourceSiblings);
        await this.dataService.updateCategories(updatedCategories);
      } else {
        // moving between parents
        const updatedCategories = [
          ...DataHelper.updateSiblingCategories(sourceSiblings),
          ...DataHelper.updateSiblingCategories(targetSiblings),
        ];
        await this.dataService.updateCategories(updatedCategories);
      }
    }

  }

  dragReleased(event: CdkDragRelease<CategoryModel>) {
    console.log(`dragReleased: ${event.source.data}`);
    // event.source.reset();
  }
}

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.23 17:17:06