澄清:我的目标是在拖动工作时获得实时 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();
}
}
与 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();
}
}