首次数据更改和重新渲染后如何在 Angular/CDK 树中继续“实时”拖动

问题描述 投票:0回答:1

更新: Stackblitz 演示

澄清:我的目标是在拖动工作时获得实时 UI 更新。技术方法可以完全不同(我在这里的方法很可能是死胡同)。我不在乎解决方案是否是通过常规 HTML+CSS 实现的,只要它不是维护良好的外来包)

我的方法的问题是,在第一次更改底层数据并重新渲染后,不再调用“cdkDragMoved”回调,但我希望拖动继续并继续测试并可能一次又一次更新,直到鼠标-release 又名拖动结束(此时不再需要更新数据,因为拖动时已经发生了这种情况)。我猜 CdkDrag 在重新渲染期间被破坏并重建?

问题是:如何实现所需的效果,在持续拖动期间不断“实时更新”,直到用户释放鼠标按钮?


我有一棵基于节点层次结构数据的类别树,并通过 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();
  }
}

angular angular-cdk angular-cdk-drag-drop
1个回答
-1
投票

与 CdkDragEnd 一起使用: https://stackblitz.com/edit/stackblitz-starters-euganx?file=src%2Ftree.component.ts

import {
  Component,
  ViewChildren,
  QueryList,
  OnInit,
  OnDestroy,
} from '@angular/core';

import {
  CdkDrag,
  CdkDragMove,
  CdkDragEnd,
  CdkDragRelease,
  CdkDragStart,
} from '@angular/cdk/drag-drop';

import { DragDropModule } from '@angular/cdk/drag-drop';

import { Subscription } from 'rxjs';

import { CategoryModel } from './details/category-model';
import { CategoryNode } from './details/category-node';
import { CategoriesById, CategoryId } from './details/misc-types';
import { DataHelper } from './details/data-helper';
import { DataService } from './details/data-service';
import { CommonModule, NgStyle } from '@angular/common';

@Component({
  selector: 'app-tree',
  standalone: true,
  imports: [CommonModule, DragDropModule, NgStyle],
  template: `
    <!-- ---------------------------------------------------------------------- -->
    <!-- RECURSIVE SUB-TREE TEMPLATE -->
    <!-- ---------------------------------------------------------------------- -->

    <ng-template #subtree let-node="node">
      <div
        cdkDrag
        [cdkDragData]="node.categoryModel.id"
        (cdkDragStarted)="dragStarted($event)"
        (cdkDragEnded)="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 AppTree implements OnInit, OnDestroy {
  constructor(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[] = [];

  /**
   * To exit early in dragMoved while data change is pending.
   */
  isChangePending = false;

  // to access drag data and element bounding boxes
  @ViewChildren(CdkDrag<CategoryId>) dragDirectives!: QueryList<
    CdkDrag<CategoryId>
  >;

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

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

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

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

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

  async dragMoved(event: CdkDragEnd<CategoryId>) {
    const pointerPos = JSON.stringify(event.source.getFreeDragPosition());
    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 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 the same parent
        console.log(`reordering siblings`);

        const updatedCategories =
          DataHelper.updateSiblingCategories(sourceSiblings);
        await this.dataService.updateCategories(updatedCategories);
        console.log(`updatedCategories: ${updatedCategories}`);
      } else {
        // moving between parents
        console.log(`moving between parents`);
        const updatedCategories = [
          ...DataHelper.updateSiblingCategories(sourceSiblings),
          ...DataHelper.updateSiblingCategories(targetSiblings),
        ];
        console.log(`updatedCategories: ${updatedCategories}`);
        await this.dataService.updateCategories(updatedCategories);
      }
    }

    // console.log(
    //   `...dragRect=${JSON.stringify(dragChildRect)} - matchDrag: ${matchDrag}`
    // );
  }

  dragReleased(event: CdkDragRelease<CategoryModel>) {
    console.log(`dragReleased: ${event.source.data}`);
    // event.source.reset();
  }
}
© www.soinside.com 2019 - 2024. All rights reserved.