原生 HTML5 Drag and Drop API 在移动端支持差、样式定制难,Angular CDK 的 DragDrop 模块用 Pointer Events 自己实现了完整的拖放体系——Material 无关、无样式侵入、可访问。它是 CDK 中使用频率最高的模块之一,本文从单列表排序讲到跨列表看板。
基础:单列表排序
最小可用组合是 cdkDrag 指令 + cdkDropList 容器:
<div class="list" cdkDropList (cdkDropListDropped)="onDrop($event)">
@for (task of tasks(); track task.id) {
<div class="item" cdkDrag>{{ task.title }}</div>
}
</div>
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
readonly tasks = signal<Task[]>([
{ id: 1, title: '需求评审' },
{ id: 2, title: '接口联调' },
{ id: 3, title: '回归测试' },
]);
onDrop(event: CdkDragDrop<Task[]>): void {
if (event.previousIndex === event.currentIndex) return;
this.tasks.update((list) => {
const next = [...list];
moveItemInArray(next, event.previousIndex, event.currentIndex);
return next;
});
}
cdkDropListDropped 在松手时发出 CDKDragDrop<T> 事件,它携带了重排所需的全部信息:
| 属性 | 含义 |
|---|---|
previousIndex / currentIndex | 拖动项在列表中的起止下标 |
container / previousContainer | 目标与来源 drop list |
item | 被拖拽的 CdkDrag 实例 |
isPointerOverContainer | 松手时指针是否在容器内(区分”放”与”丢”) |
distance | 仅自由拖拽时有值,位移距离 |
注意示例里 tasks.update 中先复制数组再 moveItemInArray——该工具函数原地修改数组,而信号更新应返回新引用才能触发 OnPush 渲染,这是信号时代最常见的坑。
跨列表拖放:connectedTo 与 transferArrayItem
看板(Kanban)是跨列表拖放的典型场景。关键是给每个 list 标记 connectedTo,让 CDK 知道”可以放进谁”:
<div class="board">
@for (col of columns(); track col.id) {
<div class="col"
cdkDropList
[cdkDropListData]="col.id"
[cdkDropListConnectedTo]="columnIds()"
(cdkDropListDropped)="onDrop($event)">
<h3>{{ col.title }}({{ col.tasks.length }})</h3>
@for (task of col.tasks; track task.id) {
<div class="card" cdkDrag>{{ task.title }}</div>
}
</div>
}
</div>
import { CdkDragDrop, moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop';
interface Task { id: number; title: string; }
onDrop(event: CdkDragDrop<number>): void {
this.columns.update((cols) => {
const next = cols.map((c) => ({ ...c, tasks: [...c.tasks] }));
// data 绑定的是列 id:拷贝后仍能可靠定位来源与目标
const from = next.find((c) => c.id === event.previousContainer.data)!;
const to = next.find((c) => c.id === event.container.data)!;
if (event.previousContainer === event.container) {
moveItemInArray(to.tasks, event.previousIndex, event.currentIndex);
} else {
transferArrayItem(
from.tasks,
to.tasks,
event.previousIndex,
event.currentIndex,
);
}
return next;
});
}
cdkDropListData 把业务数据绑定到列表实例上,事件的 container.data 就能取回它。connectedTo 接受 id 数组或相邻 list 实例;cdkDropListGroup 则提供另一种思路——把多个 list 包进一个 group,组内自动互通,省去手工连线。
<div cdkDropListGroup>
<div cdkDropList (cdkDropListDropped)="onDrop($event)">...</div>
<div cdkDropList (cdkDropListDropped)="onDrop($event)">...</div>
</div>
sortPredicate:限制可放入的位置
“已完成”列不允许插到”进行中”任务前面?用 cdkDropListSortPredicate 控制排序预览允许落在哪些下标:
<div cdkDropList
[cdkDropListSortPredicate]="sortPredicate"
(cdkDropListDropped)="onDrop($event)">
// 仅允许落在状态兼容的任务之间:返回 false 的下标不会被高亮为落点
sortPredicate(index: number, item: CdkDrag<Task>, list: CdkDropList<Task[]>): boolean {
const tasks = list.getSortedItems().map((d) => d.data);
return tasks.every((t, i) => i === index || t.status === item.data.status);
}
拖拽预览与占位符定制
拖动时 CDK 渲染两个临时元素:跟随指针的 preview 和留在原位的 placeholder。默认 preview 是元素的克隆,可用 *cdkDragPreview 模板替换:
<div class="card" cdkDrag [cdkDragData]="task">
{{ task.title }}
<span class="handle" cdkDragHandle>⋮⋮</span>
</div>
<!-- 全局预览模板:拖动时渲染,比原卡片更大更醒目 -->
<ng-template cdkDragPreview let-task="data">
<div class="card preview">{{ task.title }}</div>
</ng-template>
<!-- 占位符:留在列表中指示松手位置 -->
<ng-template cdkDragPlaceholder>
<div class="card placeholder"></div>
</ng-template>
两个模板指令都是容器级的:写在任意 cdkDropList 内(或列表外通过模板引用绑定),对其中所有 cdkDrag 生效。preview 模板还可以指定 matchSize 保持与原元素同尺寸。cdkDragHandle 把拖拽起点限定在把手元素——卡片主体放输入框、按钮时必备。
自由拖拽与约束
脱离列表的”桌面图标式”自由拖拽,只需要 cdkDrag 本身:
<div class="sticker" cdkDrag
[cdkDragFreeDragPosition]="stickerPos()"
(cdkDragEnded)="onDragEnded($event)"
(cdkDragMoved)="onMoved($event)">
便利贴
</div>
readonly stickerPos = signal({ x: 100, y: 80 });
onMoved(event: CdkDragMove): void {
// distance.x / distance.y 是相对起点的位移
}
onDragEnded(event: CdkDragDrop<unknown>): void {
// 结合起点与 event.distance 计算新坐标后写回信号
this.stickerPos.update((p) => ({ x: p.x + event.distance.x, y: p.y + event.distance.y }));
}
cdkDragFreeDragPosition 接受坐标对象,用信号驱动它还能实现”编程复位""吸附动画”等效果。约束相关的一组输入:
| 输入 | 作用 |
|---|---|
[cdkDragBoundary] | 限制在某个祖先元素(选择器或元素引用)范围内拖动 |
[cdkDragLockAxis] | 'x' 或 'y',锁定单轴,适合滑块、列宽调节 |
[cdkDragStartDelay] | 按住多少毫秒才进入拖拽(移动端设 100-500ms 避免与滚动冲突) |
[cdkDragDisabled] | 禁用单个项 |
[cdkDropListDisabled] | 禁用整个列表 |
拖放流程内幕
把一次完整拖放拆开看,有助于理解各事件的触发顺序:
sequenceDiagram
participant U as 用户
participant D as cdkDrag
participant L as cdkDropList
U->>D: pointerdown(超过 dragStartDelay)
D->>L: cdkDropListEnter
Note over D: 创建 preview(跟随指针)与 placeholder
D->>L: 排序预览(受 sortPredicate 约束)
D--xD: cdkDragMoved(高频)
U->>D: pointerup
D->>L: cdkDropListDropped(CDKDragDrop 全量信息)
D->>D: cdkDragEnded(无论是否成功放入都会触发)
实践提示:cdkDragMoved 每帧触发多次,回调里只读坐标,别做重活;持久化顺序放在 cdkDropListDropped;需要在”拖出但没放下”时恢复状态用 cdkDragEnded。
可访问性
CDK 为拖放内置了等价键盘操作:聚焦拖拽项后按空格拾起,方向键移动,再按空格放下——指令自动补齐了 aria-roledescription 等属性。要让它真正可用,请保留这些默认属性,并在视觉上为 .cdk-drag-preview、.cdk-drag-placeholder、.cdk-drop-list-dragging 提供明显样式(如占位符虚线框)。自绘 UI 时用 .cdk-drag-animating 与 no-transition 类避免列表重排时的闪烁。
常见坑
- 忘记
[cdkDropListData]:跨列表场景下事件里拿不到数据;单列表内联数据时可省略 - moveItemInArray 后信号没更新:原地修改数组不改引用,
update里必须先浅拷贝 - 预览样式丢失:preview 渲染在 body 下,脱离了组件样式作用域,样式需全局或用
::ng-deep谨慎处理 - 移动端与滚动打架:给
cdkDragStartDelay设个非零值,或仅在 handle 上启动拖拽 - @for 没有 track:拖拽后重排数组,缺 track 表达式会导致整列表 DOM 重建,preview 动画异常