鸿蒙新特性:Drag/Drop 拖拽 API 实战 — 构建项目看板的完整指南
引言
拖拽(Drag and Drop)是移动应用中最具直觉性的交互范式之一。从 iOS 主屏幕的图标拖拽排序,到 Trello 风格的任务卡片在列间拖拽移动,拖拽操作让用户能够以"物理世界"的方式操控数字对象——抓住、移动、放下。这种交互不需要文字说明,用户凭直觉就能理解。
然而,拖拽也是开发者最常回避的交互之一。原因很简单:它涉及多个生命周期事件(dragStart、dragEnter、dragLeave、drop、dragEnd)、需要在组件间传递数据、要实现流畅的视觉反馈、还要处理各种边界情况(拖到无效区域、中途取消、回到原位等)。一个看似简单的"把卡片从 A 列拖到 B 列"背后,是一套完整的事件协调逻辑。
HarmonyOS NEXT 的 ArkUI 框架内置了完整的拖拽系统。从 draggable(true) 的声明式启用,到 onDragStart/onDrop/onDragEnter/onDragLeave 的细粒度事件回调,再到 DragEvent 的数据传递能力——ArkUI 提供了一套完整的 API 让开发者实现从简单到复杂的拖拽交互。
本文将通过构建一个功能完整的"项目看板"(Kanban Board),深入讲解 ArkUI Drag/Drop API 的实战应用。看板包含三个列(待办、进行中、已完成),任务卡片可以在列间自由拖拽,每个列提供视觉高亮反馈,同时支持任务的添加和删除。
读完本文你将能够:
- 掌握
draggable()和四个核心拖拽事件的使用方式 - 实现跨容器的拖拽移动(卡片从一列拖到另一列)
- 设计不可变状态下的拖拽数据流(移动 = 删除源 + 添加到目标)
- 构建拖拽过程中的视觉反馈(高亮目标列、虚线边框)
- 理解横向滚动看板在手机端的布局策略
拖拽 API 全景图
HarmonyOS ArkUI 的拖拽 API 以组件属性方法的形式提供。任何组件都可以通过 .draggable(true) 变为可拖拽对象,任何容器都可以通过 .onDrop() 变为放置目标。
核心 API 列表
| API | 角色 | 触发时机 |
|---|---|---|
.draggable(boolean) |
声明组件可拖拽 | 组件创建时声明 |
.onDragStart(callback) |
拖拽源 | 用户长按并开始拖拽时 |
.onDragEnter(callback) |
放置目标 | 拖拽对象进入目标区域时 |
.onDragLeave(callback) |
放置目标 | 拖拽对象离开目标区域时 |
.onDrop(callback) |
放置目标 | 拖拽对象在目标区域释放时 |
.onDragEnd(callback) |
拖拽源 | 拖拽结束(无论成功与否)时 |
数据传递策略
在跨组件拖拽中,拖拽源和放置目标需要共享数据——“我拖的是哪个任务?从哪一列拖出来的?” ArkUI 的 DragEvent 提供了 setData() 和 getData() 方法进行数据传递。但在实际开发中,更简单可靠的方案是使用 @State 变量作为"数据桥梁":
@State dragTaskId: number = -1; // 正在拖拽的任务 ID
@State dragFromCol: number = -1; // 拖拽源列索引
@State dragOverCol: number = -1; // 当前悬停的列索引
// onDragStart 中设置
.onDragStart(() => {
this.dragTaskId = task.id;
this.dragFromCol = colIdx;
})
// onDrop 中读取
.onDrop(() => {
this.moveTask(this.dragTaskId, this.dragFromCol, targetColIdx);
this.dragTaskId = -1; // 重置
})
这种方案的优点是简单直观,不依赖 DragEvent 的数据序列化机制;缺点是拖拽状态成为组件的全局状态。对于本文的单页面看板应用来说,这个方案完全满足需求。
什么是 Kanban(看板)?
在看代码之前,先简要介绍 Kanban 及其数字化实现。
Kanban(看板)起源于丰田生产系统,是一种通过可视化工作流来管理工作进度的方法。核心思想很简单:将工作项按状态分为若干列(如"待办 → 进行中 → 已完成"),通过卡片在列间的移动来反映工作进展。
在软件开发中,Kanban 被广泛用于敏捷项目管理(Jira、Trello、Notion 都提供了看板视图)。它的数字化实现需要解决两个核心交互:
- 跨列移动:将一个任务从状态 A 移动到状态 B
- 列内排序:在同一列中调整任务的优先级顺序
本文聚焦于第一点——跨列移动,这是 Drag/Drop API 最经典的用例。
数据模型设计
看板的数据模型分为两层:TaskItem(任务卡片)和 KanbanColumn(看板列)。
TaskItem — 任务卡片
class TaskItem {
id: number;
title: string;
priority: string; // 'high', 'medium', 'low'
constructor(id: number, title: string, priority: string) {
this.id = id;
this.title = title;
this.priority = priority;
}
}
每个任务有三个核心属性:唯一标识 id(用于拖拽过程中的数据追踪)、标题 title、优先级 priority。优先级用字符串常量而非枚举,是因为 ArkTS 中枚举在某些场景下会有类型推断问题,字符串方案更稳健。
KanbanColumn — 看板列
class KanbanColumn {
title: string;
color: string;
tasks: TaskItem[];
constructor(title: string, color: string, tasks: TaskItem[]) {
this.title = title;
this.color = color;
this.tasks = tasks;
}
}
每个列有标题、主题色(用于列的视觉区分)、以及任务数组。三列分别使用紫色(#722ED1)、蓝色(#1677FF)、绿色(#52C41A)——这三种颜色在深色背景的卡片上形成清晰的视觉层次。
初始数据
@State columns: KanbanColumn[] = [
new KanbanColumn('待办', '#722ED1', [
new TaskItem(1, '用户登录页面开发', 'high'),
new TaskItem(2, 'API 接口文档编写', 'medium'),
new TaskItem(3, '数据库表结构设计', 'high'),
new TaskItem(9, '消息推送功能调研', 'low'),
]),
new KanbanColumn('进行中', '#1677FF', [
new TaskItem(4, '商品列表页布局', 'medium'),
new TaskItem(5, '购物车功能实现', 'high'),
]),
new KanbanColumn('已完成', '#52C41A', [
new TaskItem(6, '项目脚手架搭建', 'low'),
new TaskItem(7, '需求文档评审', 'medium'),
new TaskItem(8, 'UI 设计稿确认', 'low'),
]),
];
初始数据模拟了一个小型开发项目的工作流:待办列有 4 个任务(包括高优先级的登录页面和数据库设计),进行中列有 2 个任务,已完成列有 3 个任务。这种"左多右少"的分布反映了真实项目中"待办积压"的常态。


核心交互一:拖拽移动任务
启用拖拽
让任务卡片可拖拽只需一行代码:
Row() { /* 任务卡片内容 */ }
.draggable(true)
.onDragStart(() => {
this.dragTaskId = task.id;
this.dragFromCol = colIdx;
})
.draggable(true) 告诉框架"这个组件可以被拖拽"。用户长按任务卡片后,系统进入拖拽模式——卡片会略微放大并跟随手指移动,同时原位置留下一个半透明的"占位"效果。这一切由框架自动处理,开发者不需要手动管理拖拽预览的定位和渲染。
.onDragStart 在拖拽开始时触发。这里记录了被拖拽任务的 id 和来源列索引 colIdx。这两个值存储在 @State 变量中,供后续的 onDrop 回调读取。
接收放置
每个列作为一个放置目标,需要绑定 onDrop、onDragEnter、onDragLeave 三个回调:
Column() {
// 任务卡片列表...
}
.onDrop(() => {
if (this.dragTaskId >= 0 && this.dragFromCol >= 0) {
this.moveTask(this.dragTaskId, this.dragFromCol, colIdx);
}
this.dragTaskId = -1;
this.dragFromCol = -1;
this.dragOverCol = -1;
})
.onDragEnter(() => {
this.dragOverCol = colIdx;
})
.onDragLeave(() => {
this.dragOverCol = -1;
})
三个事件的职责划分:
- onDragEnter:拖拽对象进入本列时触发,设置
dragOverCol为当前列索引,触发视觉高亮 - onDragLeave:拖拽对象离开本列时触发,清除
dragOverCol,恢复正常样式 - onDrop:在本列释放拖拽对象时触发,执行实际的任务移动逻辑
onDrop 的最后三行是状态清理——将拖拽追踪变量重置为 -1,确保下次拖拽从一个干净的状态开始。
任务移动的核心逻辑
moveTask 方法实现了跨列移动的本质:从源列删除 → 添加到目标列。
moveTask(taskId: number, fromCol: number, toCol: number): void {
if (fromCol === toCol) {
return; // 同列拖拽,无操作
}
// 1. 在源列中查找任务
let sourceCol = this.columns[fromCol];
let taskIdx = -1;
let foundTask: TaskItem | null = null;
for (let i = 0; i < sourceCol.tasks.length; i++) {
if (sourceCol.tasks[i].id === taskId) {
taskIdx = i;
foundTask = sourceCol.tasks[i];
break;
}
}
if (foundTask === null || taskIdx === -1) {
return;
}
// 2. 从源列移除
let newSourceTasks = sourceCol.tasks.slice();
newSourceTasks.splice(taskIdx, 1);
// 3. 添加到目标列
let targetCol = this.columns[toCol];
let newTargetTasks = targetCol.tasks.slice();
newTargetTasks.push(new TaskItem(foundTask.id, foundTask.title, foundTask.priority));
// 4. 不可变更新 columns 数组
let newCols: KanbanColumn[] = [];
for (let i = 0; i < this.columns.length; i++) {
if (i === fromCol) {
newCols.push(new KanbanColumn(sourceCol.title, sourceCol.color, newSourceTasks));
} else if (i === toCol) {
newCols.push(new KanbanColumn(targetCol.title, targetCol.color, newTargetTasks));
} else {
newCols.push(new KanbanColumn(this.columns[i].title, this.columns[i].color, this.columns[i].tasks));
}
}
this.columns = newCols;
}
这段代码遵循了 ArkTS 不可变状态更新的标准模式:
- 使用
slice()创建数组的浅拷贝 - 在拷贝上执行修改(
splice、push) - 构建全新的
KanbanColumn和columns数组 - 将新数组赋值给
@State变量
直接修改 this.columns[fromCol].tasks.splice(taskIdx, 1) 不会触发 UI 更新——框架通过对象引用比较来判断状态变化,修改内部属性不改变引用。
同列拖拽(fromCol === toCol)直接返回,不执行任何操作。这是一个小的 UX 优化:如果用户拖起一张卡片又放回同一列,不应该有任何副作用。在某些看板实现中,同列拖拽用于调整卡片顺序,但那需要额外的逻辑,本文暂不覆盖。
视觉反馈
拖拽过程中的视觉反馈通过 dragOverCol 驱动:
getDropZoneBg(colIdx: number): string {
if (this.dragOverCol === colIdx) {
return '#1677FF12'; // 淡蓝色高亮
}
return '#F8F9FA'; // 正常背景
}
getDropZoneBorder(colIdx: number): BorderOptions {
if (this.dragOverCol === colIdx) {
return { width: 2, color: '#1677FF', style: BorderStyle.Dashed };
}
return { width: 1, color: '#F0F0F0' };
}
当拖拽对象悬停在某一列上时,该列的背景变为淡蓝色(#1677FF12),边框变为蓝色虚线。这两个变化共同产生"这里可以放下"的视觉暗示。
虚线边框(BorderStyle.Dashed)是一种经典的"容纳区域"视觉语言——在图形界面设计中,虚线通常表示"包围但未封闭",暗示这是一个可以接受外来对象的区域。实线则表示"封闭和固定",暗示内容属于该区域。
核心交互二:添加新任务
添加任务的入口是每个列标题栏右侧的 + 按钮。点击后弹出一个模态对话框:
Text('+')
.fontSize(16)
.fontColor('#888899')
.onClick(() => {
this.addTargetCol = colIdx;
this.newTaskTitle = '';
this.showAddDialog = true;
})
对话框使用 Stack + 条件渲染模式(这是本系列经过多个 Demo 验证后确定的最可靠方案):
if (this.showAddDialog) {
Column() { // 半透明遮罩层
Column() { // 对话框卡片
Text('新建任务')...
TextInput(...) // 标题输入
Row() {
Button('取消')...
Button('添加')... // 确认按钮
}
}
.width('78%')
.padding(24)
.borderRadius(16)
.backgroundColor('#FFFFFF')
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('#00000050') // 半透明遮罩
.onClick(() => { this.showAddDialog = false; }) // 点击遮罩关闭
}
添加任务的逻辑:
addTask(): void {
let title = this.newTaskTitle.trim();
if (title.length === 0) {
return; // 空标题不处理
}
let col = this.columns[this.addTargetCol];
let newTasks = col.tasks.slice();
newTasks.push(new TaskItem(this.nextId, title, 'medium'));
// 不可变更新
let newCols: KanbanColumn[] = [];
for (let i = 0; i < this.columns.length; i++) {
if (i === this.addTargetCol) {
newCols.push(new KanbanColumn(col.title, col.color, newTasks));
} else {
newCols.push(new KanbanColumn(this.columns[i].title, this.columns[i].color, this.columns[i].tasks));
}
}
this.columns = newCols;
this.nextId = this.nextId + 1;
this.newTaskTitle = '';
this.showAddDialog = false;
}
新任务的 ID 使用自增计数器 nextId(从 10 开始,避免与初始数据的 1-9 冲突),优先级默认为 'medium'。
核心交互三:删除任务
每个任务卡片的右上角有一个 ✕ 删除按钮:
Text('✕')
.fontSize(12)
.fontColor('#CCCCDD')
.padding({ left: 8, right: 2, top: 4, bottom: 4 })
.onClick(() => { this.deleteTask(colIdx, taskIdx); })
删除逻辑与添加对称——不可变地从列的任务数组中移除指定项:
deleteTask(colIdx: number, taskIdx: number): void {
let col = this.columns[colIdx];
let newTasks = col.tasks.slice();
newTasks.splice(taskIdx, 1);
let newCols: KanbanColumn[] = [];
for (let i = 0; i < this.columns.length; i++) {
if (i === colIdx) {
newCols.push(new KanbanColumn(col.title, col.color, newTasks));
} else {
newCols.push(new KanbanColumn(this.columns[i].title, this.columns[i].color, this.columns[i].tasks));
}
}
this.columns = newCols;
}
删除按钮使用了 #CCCCDD 的浅灰色,在视觉上弱化其存在感——避免"删除"成为卡片上最显眼的操作。但触摸区域通过 padding 放大到了合理的尺寸(约 32vp 宽),确保手指容易命中。
优先级指示系统
每张任务卡片底部有一个优先级指示器——彩色圆点 + 文字标签:
Row()
.width(6)
.height(6)
.borderRadius(3)
.backgroundColor(this.getPriorityColor(task.priority))
.margin({ right: 4 })
Text(this.getPriorityLabel(task.priority))
.fontSize(10)
.fontColor(this.getPriorityColor(task.priority))
优先级到颜色的映射:
| 优先级 | 颜色 | 色值 | 语义 |
|---|---|---|---|
| high | 红色 | #FF4D4F |
紧急/阻塞 |
| medium | 橙色 | #FA8C16 |
正常/关注 |
| low | 绿色 | #52C41A |
不急/可延后 |
这套配色遵循了交通信号灯的隐喻——红色 = 停(需要立即关注),绿色 = 行(可以放一放),橙色 = 黄灯(介于两者之间)。用户在扫视看板时,可以通过颜色快速定位需要优先处理的任务。
横向滚动布局
看板的三列布局在手机屏幕上通过横向滚动实现:
Scroll() {
Row() {
ForEach(this.columns, (col: KanbanColumn, colIdx: number) => {
Column() { /* 列内容 */ }
.width(260)
.height('100%')
...
})
}
.height('100%')
.padding(...)
}
.layoutWeight(1)
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
每列宽度固定为 260vp。以典型的 390vp 宽手机屏幕为例,第一列完整可见(260vp),第二列部分可见(约 130vp),第三列完全隐藏——这种"窥视"效果暗示用户有更多内容可以横向滚动。
为什么不用等宽的三列(每列约 120vp)让所有内容同时可见?因为 120vp 的列宽无法容纳有意义的任务卡片——标题文字会被截断为 5-6 个字,优先级标签会与删除按钮重叠。260vp 是"卡片可读性"和"列间导航便利性"之间的平衡点。
拖拽与横向滚动的 UX 挑战
有一个值得讨论的问题:如果目标列在屏幕外(比如从"待办"拖到"已完成",而"已完成"被遮挡),用户如何完成拖拽?
答案是在实际使用中,用户会先横向滚动到能看到目标列的位置,然后再开始拖拽。这个行为是直觉性的——拖拽是一个"眼见为实"的操作,用户不会拖到一个看不见的地方。
对于需要频繁跨列拖拽的专业用户来说,手机端的看板更适合采用"单列展示 + 下拉切换列"的布局,或在平板/折叠屏上使用三列并排。本文 Demo 选择了横向滚动方案,因为它在演示场景中最直观。
完整页面结构
Stack(根容器,用于对话框遮罩层)
└── Column
├── Header(标题 + 任务总数 + 操作提示)
├── Scroll(横向滚动)
│ └── Row
│ ├── Column:待办(紫色标识)
│ │ ├── 列标题 + 任务计数 + 添加按钮
│ │ ├── 放置区(Drop Zone)
│ │ │ ├── Task Card 1(高优先级 · draggable)
│ │ │ ├── Task Card 2(中优先级 · draggable)
│ │ │ └── ...
│ │ └── ...
│ ├── Column:进行中(蓝色标识)
│ └── Column:已完成(绿色标识)
└── 对话框遮罩(条件渲染)
└── 对话框卡片
├── 标题
├── TextInput
└── 取消 + 添加按钮
常见问题与解决方案
问题一:拖拽卡片到目标列后,卡片没有移动
原因:moveTask 中直接修改了 this.columns[fromCol].tasks(如使用 splice),@State 没有检测到变化。
解决:始终使用 slice() 创建新数组,用 new KanbanColumn(...) 创建新列对象,构建全新的 columns 数组后整体赋值。
问题二:拖拽后 DragOver 高亮没有清除
原因:onDrop 中忘了重置 dragOverCol 状态。
解决:在 onDrop 回调的末尾(以及在 onDragEnd 中)将 dragTaskId、dragFromCol、dragOverCol 全部重置为 -1。
问题三:快速拖动时视觉高亮不同步
原因:onDragEnter 和 onDragLeave 的触发时机与手指移动速度相关,快速滑动可能导致事件丢失或乱序。
解决:在 onDrop 中显式清除高亮状态,确保无论如何释放拖拽,高亮都会被重置。对于更复杂的场景,可以加入防抖逻辑。
问题四:横向滚动与拖拽手势冲突
原因:Scroll 组件和拖拽系统都需要响应滑动手势,可能产生冲突。
解决:ArkUI 的拖拽系统通过"长按触发"机制避免了与滚动手势的冲突——轻滑 = 滚动,长按 = 拖拽。两者的手势识别器通过不同的触发条件区分,一般不会混淆。
进阶扩展方向
1. 同列排序
本文实现的看板只支持跨列拖拽,同列拖拽直接返回。通过扩展 moveTask 的逻辑,可以实现同列内的排序——需要增加目标位置参数(toIdx),在目标列中精确插入而非追加到末尾。
2. 自定义拖拽预览
ArkUI 的 onDragStart 支持设置自定义拖拽预览图(PixelMap)。可以将卡片渲染为位图,在拖拽时显示一个带阴影的放大预览,增强"拿起"的物理感。
3. 撤销操作
拖拽是一种容易误操作的行为。可以通过 Toast 提示"已移动「XXX」到「进行中」",并提供 3 秒内的撤销按钮。
4. 数据持久化
使用 @ohos.data.preferences 或关系型数据库保存看板数据,让任务在应用重启后仍然保留。
5. 多设备适配
在平板或折叠屏展开态下,三列可以同时并排显示(每列宽度增加到 33%),充分利用更大的屏幕空间。通过 BreakpointSystem 或 display.isFoldable() 判断设备类型,动态切换布局。
总结
本文通过构建一个功能完整的"项目看板",深入讲解了 HarmonyOS ArkUI 中 Drag/Drop API 的实战应用。核心要点如下:
- 拖拽 API 体系:
.draggable()启用拖拽,onDragStart/onDragEnter/onDragLeave/onDrop构成完整的事件周期 - 状态桥接:使用
@State变量在拖拽源和放置目标间传递数据,比DragEvent.setData()更简单可靠 - 不可变移动:跨列移动 = 源列删除 + 目标列添加,全程使用
slice()创建新数组 - 视觉反馈:
onDragEnter修改目标列背景和边框(虚线),提供"可以放下"的直觉暗示 - 看板布局:260vp 列宽 + 横向滚动的折中方案,平衡卡片可读性和列间导航
- 辅助交互:添加任务(模态对话框)、删除任务(✕ 按钮)、优先级指示(彩色圆点)
拖拽交互是移动应用"专业感"的重要来源。一个流畅的拖拽体验让用户感觉应用"很顺手"、“反应很快”——反之,卡顿的拖拽、不准确的放置、缺失的视觉反馈会让用户觉得"这应用不行"。通过 ArkUI 提供的拖拽 API,开发者可以用相对简洁的代码实现高质量的拖拽体验。
在实际项目中,拖拽不仅适用于看板,还可以应用于文件管理器(拖拽移动文件)、图片排序(拖拽调整顺序)、仪表盘定制(拖拽添加小组件)、购物车(拖拽商品到不同分类)等场景。掌握了本文的基础模式,你就可以将这些拖拽交互应用到任何需要"抓住-移动-放下"直觉操作的界面中。
更多推荐

所有评论(0)