angularangular-cdkangular-cdk-drag-drop

How to continue "live" dragging in Angular/CDK Tree after first data change & re-rendering


Update: Stackblitz Demo

Clarification: My goal here is to get the live UI update WHILE dragging to work. Technical approach can by completely different (my approach here might well be a dead end). I don't care if the solution is by foot with regular HTML+CSS as long as it is not an exotic package that is not well maintained)

THE PROBLEM with my approach here is that the "cdkDragMoved" callback is not called any more after changing the underlying data and re-rendering for the first time but i want the drag to continue and continue testing and possibly updating again and again until mouse-release aka drag-end (at which point no data needs to be updated anymore because this has already happened while dragging). I guess the CdkDrag is destroyed and rebuild during the re-rendering?

THE QUESTION is: How do i achieve the desired effect where i can continuously "live-update" during the ongoing drag until the user releases the mouse-button?


I have a tree of categories based on data of a hierarchy of nodes and rendered recursively via an ng-template that represents a sub-tree of one category and all its child sub-trees. I am building the drag and drop using CDK. In the "cdkDragMoved" callback i am checking the position against all category bounding boxes and in case of a match that is not self i am updating the underlying data. Updating the data runs the subscription code that is set-up in ngOnInit which updates the local data and triggers the re-render.


// ...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();
  }
}


Solution

  • I think you're overcomplicating stuff a bit. All you need is the CdkDropList.

    Here's some demo I made in the past (code)

    Since you want to try nested droplists, this demo seems to do the trick... (Credits to Serge Kolchin)