CHARLIE SAYS

查理如是说
DATE 2026-08-24
THEME
SERIES / ANGULAR / P-205 · Angular 高级教程

CDK 22+ 教程 42:Drag and Drop 拖拽

原生 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 动画异常

系列导航

← 算法 042:大数据处理:双层桶划分 目录 算法 043:大数据处理:Bitmap & Bloom Filter →
← 返回文章列表