引言

拖拽(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 都提供了看板视图)。它的数字化实现需要解决两个核心交互:

  1. 跨列移动:将一个任务从状态 A 移动到状态 B
  2. 列内排序:在同一列中调整任务的优先级顺序

本文聚焦于第一点——跨列移动,这是 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 回调读取。

接收放置

每个列作为一个放置目标,需要绑定 onDroponDragEnteronDragLeave 三个回调:

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 不可变状态更新的标准模式:

  1. 使用 slice() 创建数组的浅拷贝
  2. 在拷贝上执行修改(splicepush
  3. 构建全新的 KanbanColumncolumns 数组
  4. 将新数组赋值给 @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 中)将 dragTaskIddragFromColdragOverCol 全部重置为 -1

问题三:快速拖动时视觉高亮不同步

原因onDragEnteronDragLeave 的触发时机与手指移动速度相关,快速滑动可能导致事件丢失或乱序。

解决:在 onDrop 中显式清除高亮状态,确保无论如何释放拖拽,高亮都会被重置。对于更复杂的场景,可以加入防抖逻辑。

问题四:横向滚动与拖拽手势冲突

原因:Scroll 组件和拖拽系统都需要响应滑动手势,可能产生冲突。

解决:ArkUI 的拖拽系统通过"长按触发"机制避免了与滚动手势的冲突——轻滑 = 滚动,长按 = 拖拽。两者的手势识别器通过不同的触发条件区分,一般不会混淆。

进阶扩展方向

1. 同列排序

本文实现的看板只支持跨列拖拽,同列拖拽直接返回。通过扩展 moveTask 的逻辑,可以实现同列内的排序——需要增加目标位置参数(toIdx),在目标列中精确插入而非追加到末尾。

2. 自定义拖拽预览

ArkUI 的 onDragStart 支持设置自定义拖拽预览图(PixelMap)。可以将卡片渲染为位图,在拖拽时显示一个带阴影的放大预览,增强"拿起"的物理感。

3. 撤销操作

拖拽是一种容易误操作的行为。可以通过 Toast 提示"已移动「XXX」到「进行中」",并提供 3 秒内的撤销按钮。

4. 数据持久化

使用 @ohos.data.preferences 或关系型数据库保存看板数据,让任务在应用重启后仍然保留。

5. 多设备适配

在平板或折叠屏展开态下,三列可以同时并排显示(每列宽度增加到 33%),充分利用更大的屏幕空间。通过 BreakpointSystemdisplay.isFoldable() 判断设备类型,动态切换布局。

总结

本文通过构建一个功能完整的"项目看板",深入讲解了 HarmonyOS ArkUI 中 Drag/Drop API 的实战应用。核心要点如下:

  1. 拖拽 API 体系.draggable() 启用拖拽,onDragStart / onDragEnter / onDragLeave / onDrop 构成完整的事件周期
  2. 状态桥接:使用 @State 变量在拖拽源和放置目标间传递数据,比 DragEvent.setData() 更简单可靠
  3. 不可变移动:跨列移动 = 源列删除 + 目标列添加,全程使用 slice() 创建新数组
  4. 视觉反馈onDragEnter 修改目标列背景和边框(虚线),提供"可以放下"的直觉暗示
  5. 看板布局:260vp 列宽 + 横向滚动的折中方案,平衡卡片可读性和列间导航
  6. 辅助交互:添加任务(模态对话框)、删除任务(✕ 按钮)、优先级指示(彩色圆点)

拖拽交互是移动应用"专业感"的重要来源。一个流畅的拖拽体验让用户感觉应用"很顺手"、“反应很快”——反之,卡顿的拖拽、不准确的放置、缺失的视觉反馈会让用户觉得"这应用不行"。通过 ArkUI 提供的拖拽 API,开发者可以用相对简洁的代码实现高质量的拖拽体验。

在实际项目中,拖拽不仅适用于看板,还可以应用于文件管理器(拖拽移动文件)、图片排序(拖拽调整顺序)、仪表盘定制(拖拽添加小组件)、购物车(拖拽商品到不同分类)等场景。掌握了本文的基础模式,你就可以将这些拖拽交互应用到任何需要"抓住-移动-放下"直觉操作的界面中。


Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐