HarmonyOS NEXT 开发实战:DragDrop 拖拽放置布局完全指南
本文将深入解析 HarmonyOS NEXT 中 DragDrop 拖拽放置布局的实现原理,涵盖 24 个核心 API,配合完整的实战代码,帮助开发者从零掌握拖拽交互的开发技巧。
项目演示




目录
- DragDrop 技术概述
- 开发环境准备
- 核心装饰器 API 详解
- UI 布局组件 API
- 拖拽事件系统 API
- 数据交互 API
- 样式与资源 API
- 完整实战项目
- 双端适配策略
- 进阶技巧与性能优化
- 常见问题解答
- 总结与展望
1. DragDrop 技术概述
1.1 什么是 DragDrop?
DragDrop(拖拽放置)是现代移动应用和 Web 应用中广泛使用的交互模式。它允许用户通过长按或拖拽手势,将一个元素从源位置移动到目标位置,并触发相应的业务逻辑。
在 HarmonyOS NEXT 中,DragDrop 提供了一套完整的 API 体系,支持跨设备的统一交互体验。
1.2 应用场景
| 场景 | 说明 |
|---|---|
| 应用分类整理 | 将应用图标拖拽到文件夹中 |
| 购物车操作 | 从商品列表拖拽添加到购物车 |
| 任务管理 | 在看板视图中拖拽切换任务状态 |
| 图片排序 | 拖拽调整图片展示顺序 |
| 文件管理 | 拖拽文件到目标文件夹 |
1.3 技术架构
┌─────────────────────────────────────────────────┐
│ 用户手势层 │
│ 长按触发 → 拖拽移动 → 释放 │
└─────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────┐
│ DragDrop API 层 │
│ .draggable() .onDragStart() .onDragEnd() │
│ .onDragEnter() .onDragMove() .onDragLeave() │
│ .onDrop() │
└─────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────┐
│ 数据传输层 │
│ UnifiedData → UnifiedRecord → PlainText │
└─────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────┐
│ UI 更新层 │
│ @State 响应式驱动 UI 自动刷新 │
└─────────────────────────────────────────────────┘
1.4 核心概念
拖拽源(Drag Source):用户长按启动拖拽的组件。
放置目标(Drop Target):接收拖拽数据的目标组件区域。
拖拽预览(Drag Preview):跟随用户手指移动的视觉反馈组件。
拖拽数据(Drag Data):通过 UnifiedData 在源和目标之间传递的数据对象。
2. 开发环境准备
2.1 前置条件
| 项目 | 要求 |
|---|---|
| 操作系统 | Windows 10/11 或 macOS 12+ |
| 开发工具 | DevEco Studio 4.0 Release+ |
| HarmonyOS SDK | API 12+ |
| 设备 | 真机或模拟器(推荐触屏设备) |
2.2 创建项目
打开 DevEco Studio,选择「Create Project」 → 「Application」 → 「Empty Ability」。
2.3 项目结构
MyApplication71/
├── entry/
│ ├── src/
│ │ └── main/
│ │ ├── ets/
│ │ │ ├── entryability/
│ │ │ │ └── EntryAbility.ets
│ │ │ └── pages/
│ │ │ └── Index.ets # 主页面
│ │ └── resources/
│ │ ├── base/
│ │ │ ├── element/
│ │ │ │ ├── color.json # 颜色资源
│ │ │ │ └── string.json # 字符串资源
│ │ │ └── media/
│ │ │ └── icon.png
│ │ └── dark/ # 深色主题
│ │ └── element/
│ │ └── color.json
│ └── module.json5 # 模块配置
└── build-profile.json5
3. 核心装饰器 API 详解
装饰器是 ArkTS 的核心特性,用于声明组件的类型和行为。本节将解析 DragDrop 开发中必须掌握的 8 个装饰器 API。
3.1 @Entry — 入口组件装饰器
功能说明:标记应用的入口页面组件,每个 Ability 必须包含且仅包含一个 @Entry 装饰的组件。
语法格式:
@Entry
@Component
struct Index {
// 组件实现
}
使用场景:
- 应用主页面
- 独立功能页面
- 需要路由跳转的目标页面
注意事项:
@Entry必须与@Component配合使用- 组件内必须实现
build()方法 - 组件内可以使用
@State声明状态变量
3.2 @Component — 组件装饰器
功能说明:定义一个可复用的 UI 组件,组件可以拥有自己的状态和生命周期。
语法格式:
@Component
struct MyComponent {
@State count: number = 0;
build() {
Column() {
Text(`计数: ${this.count}`)
Button('增加')
.onClick(() => {
this.count++;
})
}
}
}
组件的特性:
- 封装性:组件内部状态对外不可见
- 复用性:可在多个页面中引用
- 独立性:拥有独立的生命周期
3.3 @State — 状态管理装饰器
功能说明:声明组件内部的响应式状态变量,当状态改变时,ArkUI 会自动重新渲染关联的组件。
语法格式:
@State variableName: Type = initialValue;
在 DragDrop 中的应用:
@Component
struct DragDropExample {
// 拖拽状态:空闲/拖拽中/完成
@State dragStatus: string = 'idle';
// 目标区域背景色(响应式改变)
@State targetBgColor: string = '#FFF3E0';
// 已放置物品列表
@State droppedItems: string[] = [];
// 当前选中/拖拽的物品
@State currentItem: DragItemData | null = null;
}
状态更新触发 UI 刷新的流程:
状态改变 → ArkUI 检测 → 标记脏组件 → 下一帧重新渲染
最佳实践:
- 状态变量初始值必须指定
- 避免在
build()方法中直接修改状态 - 复杂对象建议使用
@Observed+@ObjectLink
3.4 @Builder — UI 构建器装饰器
功能说明:定义可复用的 UI 片段,支持参数传递,类似于函数式组件。
语法格式:
@Builder
BuilderName(params: ParamType) {
// UI 描述
}
在 DragDrop 中的应用场景:
// 拖拽预览构建器
@Builder
DragPreviewBuilder(item: DragItemData) {
Row() {
Text(item.displayName)
.fontSize(18)
.fontColor(Color.White)
.fontWeight(FontWeight.Bold)
}
.width(120)
.height(60)
.justifyContent(FlexAlign.Center)
.backgroundColor($r('app.color.title_background'))
.borderRadius(12)
.shadow({
radius: 8,
color: '#40000000',
offsetX: 0,
offsetY: 4
})
}
// 空目标区域构建器
@Builder
EmptyTargetBuilder() {
Column() {
Text($r('app.string.drop_hint'))
.fontSize(16)
.fontColor($r('app.color.hint_text'))
.textAlign(TextAlign.Center)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
调用方式:
// 在其他 Builder 中调用
@Builder
MainBuilder() {
Column() {
// 调用带参数的 Builder
this.DragPreviewBuilder(currentItem);
// 调用无参数的 Builder
if (droppedItems.length === 0) {
this.EmptyTargetBuilder();
}
}
}
注意事项:
- Builder 内不能使用
return语句 - Builder 内不能使用箭头函数
- 参数传递遵循值语义
3.5 struct — 结构体定义
功能说明:定义自定义类型,用于组织和封装相关数据。在 DragDrop 中常用于定义数据模型。
语法格式:
interface DragItemData {
id: string;
name: Resource;
displayName: string;
icon?: Resource;
}
完整示例:
// 拖拽物品数据结构
interface DragItemData {
id: string;
name: Resource;
displayName: string;
category: string;
sortOrder: number;
}
// 拖拽事件处理返回类型
interface DropResult {
success: boolean;
message: string;
droppedItem?: DragItemData;
}
3.6 import — 模块导入
功能说明:导入其他模块的导出内容,包括类型定义、工具类、常量等。
在 DragDrop 开发中常用的导入:
// 数据交互相关
import { unifiedDataChannel, uniformDataStruct, uniformTypeDescriptor } from '@kit.ArkData';
// UI 提示组件
import { promptAction } from '@kit.ArkUI';
// 错误处理
import { BusinessError } from '@kit.BasicServicesKit';
导入说明:
@kit.ArkData:提供统一数据通道 API@kit.ArkUI:提供 UI 组件和交互 API@kit.BasicServicesKit:提供基础服务 API
3.7 私有成员修饰符
功能说明:控制类/结构体成员的访问权限。
@Component
struct DragDropExample {
// 公开成员:外部可访问
public componentTitle: string = '拖拽示例';
// 私有成员:仅内部可访问
private dragItems: DragItemData[] = [
{ id: '1', name: $r('app.string.item_1'), displayName: '物品一' }
];
// 受保护成员:子类可访问
protected defaultItem: DragItemData = {
id: 'default', name: $r('app.string.default'), displayName: '默认'
};
}
3.8 @ObjectLink — 对象状态同步
功能说明:在嵌套组件间同步对象状态,用于复杂数据场景。
// 定义可观察对象
@Observed
class DragModel {
items: DragItemData[] = [];
isDragging: boolean = false;
}
// 使用 @ObjectLink 同步
@Component
struct ChildComponent {
@ObjectLink model: DragModel;
build() {
Text(`拖拽状态: ${this.model.isDragging}`)
}
}
4. UI 布局组件 API
DragDrop 的 UI 结构通常包含源区域和目标区域,本节介绍构建这种布局所需的核心组件 API。
4.1 Column — 垂直布局容器
功能说明:将子组件按垂直方向依次排列。
基本用法:
Column() {
Text('第一行')
Text('第二行')
Text('第三行')
}
.width('100%')
.height(300)
在 DragDrop 中的应用:
// 源区域容器
Column({ space: 12 }) {
// 标题
Text('可拖拽物品')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.margin({ bottom: 8 })
// 物品列表
ForEach(dragItems, (item: DragItemData) => {
this.DragItemRow(item);
}, (item: DragItemData) => item.id)
}
.width('45%')
.padding(16)
.backgroundColor($r('app.color.source_background'))
.borderRadius(12)
常用属性:
justifyContent:主轴对齐方式alignItems:交叉轴对齐方式space:子组件间距
4.2 Row — 水平布局容器
功能说明:将子组件按水平方向依次排列。
基本用法:
Row() {
Button('左')
Button('中')
Button('右')
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
在 DragDrop 中的应用:
// 主布局:左右分栏
Row() {
// 左侧源区域
Column() { /* ... */ }
.width('45%')
// 右侧目标区域
Column() { /* ... */ }
.layoutWeight(1) // 占剩余空间
}
.width('100%')
.height('100%')
.padding(16)
常用属性:
width:宽度(支持百分比)height:高度(支持百分比)layoutWeight:权重,用于弹性布局padding:内边距margin:外边距
4.3 Text — 文本组件
功能说明:展示纯文本内容,支持资源引用。
基本用法:
Text('Hello World') // 硬编码字符串
Text($r('app.string.title')) // 资源引用
Text('动态内容: ' + count) // 字符串拼接
常用属性:
Text('示例文本')
.fontSize(16) // 字体大小
.fontColor($r('app.color.text')) // 字体颜色
.fontWeight(FontWeight.Bold) // 字体粗细
.fontFamily('HarmonyOS Sans') // 字体族
.fontStyle(FontStyle.Italic) // 字体样式
.lineHeight(24) // 行高
.maxLines(2) // 最大行数
.textOverflow({ overflow: TextOverflow.Ellipsis }) // 溢出处理
.textAlign(TextAlign.Center) // 对齐方式
在 DragDrop 中的应用:
// 状态提示文本
Text(dragStatusText)
.fontSize(14)
.fontColor(getStatusColor())
.fontWeight(FontWeight.Medium)
.padding({ left: 16, right: 16, top: 8, bottom: 8 })
.backgroundColor(getStatusBgColor())
.borderRadius(20)
// 拖拽物品名称
Text(item.displayName)
.fontSize(16)
.fontColor($r('app.color.item_text'))
.fontWeight(FontWeight.Medium)
4.4 Button — 按钮组件
功能说明:可交互的按钮,支持点击事件。
基本用法:
Button('点击我')
.onClick(() => {
console.log('Button clicked');
})
// 使用资源引用
Button($r('app.string.clear_button'))
在 DragDrop 中的应用:
// 清空按钮(仅在有已放置物品时显示)
if (droppedItems.length > 0) {
Button($r('app.string.clear_button'))
.width('60%')
.height(48)
.fontSize(16)
.fontColor($r('app.color.button_text'))
.backgroundColor($r('app.color.button_bg'))
.borderRadius(24)
.margin({ top: 16, bottom: 24 })
.onClick(() => {
this.clearDroppedItems();
})
}
4.5 ForEach — 列表渲染
功能说明:遍历数组并渲染列表组件,自动处理更新和复用。
语法格式:
ForEach(
array, // 数据源数组
(item, index) => { // 渲染函数
// 返回组件
},
(item, index) => { // 键生成函数(可选,推荐)
// 返回唯一标识
}
)
在 DragDrop 中的完整用法:
ForEach(this.dragItems, (item: DragItemData) => {
Row() {
Text(item.name)
.fontSize(18)
.fontColor($r('app.color.item_text'))
if (this.selectedItem !== null && this.selectedItem.id === item.id) {
Text(' ✓')
.fontSize(16)
.fontColor($r('app.color.success'))
}
}
.width('100%')
.height(50)
.justifyContent(FlexAlign.Center)
.backgroundColor(this.getItemBgColor(item))
.borderRadius(8)
.draggable(true)
.onDragStart((event: DragEvent) => {
return this.handleDragStart(item, event);
})
.onDragEnd((event: DragEvent) => {
this.handleDragEnd(event);
})
.onClick(() => {
this.selectItem(item);
})
}, (item: DragItemData) => item.id) // 唯一键
注意事项:
- 键生成函数必须返回唯一标识
- 避免使用数组索引作为键(会导致复用问题)
- 数组变化时自动刷新 UI
4.6 Flex — 弹性布局
功能说明:类似于 CSS Flexbox 的弹性布局容器。
基本用法:
Flex({ wrap: FlexWrap.Wrap }) {
// 子组件自动换行
Text('A')
Text('B')
Text('C')
}
.width('100%')
在 DragDrop 中的应用:
// 已放置物品标签展示
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.droppedItems, (item: string, index: number) => {
Text(item)
.fontSize(14)
.fontColor($r('app.color.item_text'))
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.margin({ right: 8, bottom: 8 })
.backgroundColor($r('app.color.tag_bg'))
.borderRadius(16)
}, (item: string, index: number) => index.toString())
}
.width('100%')
4.7 layoutWeight — 布局权重
功能说明:在父容器剩余空间中按比例分配空间。
用法:
Row() {
Column() { /* 内容 */ }
.width('40%') // 固定 40%
Column() { /* 内容 */ }
.layoutWeight(1) // 占剩余 60%
Column() { /* 内容 */ }
.layoutWeight(2) // 如果有剩余,再按 2:1 分配
}
.width('100%')
在 DragDrop 中的应用:
Row() {
// 左侧源区域:固定 45%
Column() { /* 物品列表 */ }
.width('45%')
// 右侧目标区域:剩余空间
Column() { /* 放置区域 */ }
.layoutWeight(1)
}
.width('100%')
.height('100%')
4.8 padding / margin — 内边距与外边距
功能说明:控制组件内部和外部的间距。
基本用法:
.padding(16) // 四周统一 16
.padding({ left: 16, right: 16 }) // 左右各 16
.padding({ top: 8, bottom: 16 }) // 上下不同
.padding({ left: 8, right: 8, top: 16, bottom: 16 })
.margin(16) // 四周统一
.margin({ top: 8 }) // 仅上方
5. 拖拽事件系统 API
这是 DragDrop 的核心 API 集合,共 8 个事件和方法。
5.1 .draggable() — 启用拖拽能力
功能说明:标记组件为可拖拽元素,启用后用户长按可启动拖拽。
语法格式:
.draggable(enabled: boolean)
基本用法:
Row() {
Text('拖拽我')
}
.draggable(true) // 启用拖拽
完整示例:
Row() {
Text(item.displayName)
.fontSize(18)
.fontColor($r('app.color.item_text'))
}
.width('100%')
.height(50)
.justifyContent(FlexAlign.Center)
.backgroundColor($r('app.color.item_bg'))
.borderRadius(8)
// ===== 核心:启用拖拽 =====
.draggable(true)
// 配合事件监听
.onDragStart((event: DragEvent) => {
return this.handleDragStart(item, event);
})
.onDragEnd((event: DragEvent) => {
this.handleDragEnd(event);
})
注意事项:
true启用拖拽,false禁用- 可在运行时动态切换
- 必须配合
onDragStart使用才有完整功能
5.2 .onDragStart() — 拖拽开始事件
功能说明:用户长按启动拖拽时触发,用于初始化拖拽状态、设置数据、返回预览。
语法格式:
.onDragStart(callback: (event: DragEvent) => DragItemInfo)
参数说明:
event:拖拽事件对象,包含坐标、时间戳等信息- 返回值
DragItemInfo:包含预览组件和额外信息
完整实现:
handleDragStart(item: DragItemData, event: DragEvent): DragItemInfo {
// 1. 更新状态
this.currentDragItem = item;
this.dragStatus = $r('app.string.dragging');
// 2. 设置拖拽数据(跨组件传递)
try {
// 创建数据对象
let plainText: uniformDataStruct.PlainText = {
uniformDataType: 'general.plain-text',
textContent: item.displayName
};
// 创建记录
let record = new unifiedDataChannel.UnifiedRecord(
uniformTypeDescriptor.UnifiedDataType.PLAIN_TEXT,
plainText
);
// 创建统一数据
let unifiedData = new unifiedDataChannel.UnifiedData(record);
// 设置到事件中
event.setData(unifiedData);
} catch (e) {
let error: BusinessError = e as BusinessError;
console.error('setData failed: ' + error.message);
}
// 3. 返回预览组件和额外信息
return {
builder: this.DragPreviewBuilder(item), // 预览 UI
extraInfo: item.id // 额外数据
};
}
返回值类型 DragItemInfo:
interface DragItemInfo {
builder: CustomBuilder; // 预览组件构建器
extraInfo?: string; // 额外信息字符串
}
5.3 .onDragEnd() — 拖拽结束事件
功能说明:用户释放手指/鼠标时触发,无论成功放置还是取消都会触发。
语法格式:
.onDragEnd(callback: (event: DragEvent) => void)
完整实现:
handleDragEnd(event: DragEvent): void {
// 获取拖拽结果
let result = event.getResult();
// 根据结果处理
if (result === DragResult.DRAG_SUCCESSFUL) {
// 成功放置
this.dragStatus = $r('app.string.drop_success');
promptAction.showToast({ message: '放置成功!' });
} else if (result === DragResult.DRAG_FAILED) {
// 放置失败
this.dragStatus = $r('app.string.drop_failed');
promptAction.showToast({ message: '放置失败' });
} else {
// 用户取消(拖拽到有效区域外释放)
this.dragStatus = $r('app.string.idle');
}
// 清理状态
this.currentDragItem = null;
this.isInTargetArea = false;
// 恢复目标区域样式
this.targetBgColor = $r('app.color.target_bg');
this.targetBorderColor = $r('app.color.border');
}
DragResult 枚举值:
| 值 | 说明 |
|---|---|
DRAG_SUCCESSFUL |
成功放置到有效目标区域 |
DRAG_FAILED |
放置到无效区域或出错 |
| 其他 | 用户取消操作 |
5.4 .onDragEnter() — 进入目标区域事件
功能说明:拖拽元素首次进入目标区域时触发,用于视觉反馈。
语法格式:
.onDragEnter(callback: (event: DragEvent) => void)
完整实现:
handleDragEnter(event: DragEvent): void {
// 更新进入状态
this.isInTargetArea = true;
this.dragStatus = $r('app.string.enter_target');
// 视觉反馈:高亮目标区域
this.targetBgColor = $r('app.color.target_hover');
this.targetBorderColor = $r('app.color.border_active');
// 震动反馈(可选)
// vibrator.vibrate(VibratorEffect.SHORT);
}
视觉反馈效果:
[正常状态] [进入状态]
┌──────────┐ ╔══════════╗
│ │ → ║ ▓▓▓▓▓▓▓▓ ║
│ 放置区域 │ ║ ▓ 高亮 ▓ ║
│ │ ║ ▓▓▓▓▓▓▓▓ ║
└──────────┘ ╚══════════╝
灰色边框 橙色边框+深色背景
5.5 .onDragLeave() — 离开目标区域事件
功能说明:拖拽元素离开目标区域时触发,用于恢复视觉状态。
语法格式:
.onDragLeave(callback: (event: DragEvent) => void)
完整实现:
handleDragLeave(event: DragEvent): void {
// 更新离开状态
this.isInTargetArea = false;
this.dragStatus = $r('app.string.leave_target');
// 恢复目标区域样式
this.targetBgColor = $r('app.color.target_bg');
this.targetBorderColor = $r('app.color.border');
}
5.6 .onDragMove() — 在目标区域内移动
功能说明:拖拽过程中在目标区域内移动时持续触发,用于实时更新。
语法格式:
.onDragMove(callback: (event: DragEvent) => void)
基本实现:
handleDragMove(event: DragEvent): void {
// 允许放置
event.setResult(DragResult.DROP_ENABLED);
// 可选:获取当前坐标
let x = event.getX();
let y = event.getY();
console.log(`Drag position: (${x}, ${y})`);
}
高级用法 — 动态调整放置区域:
handleDragMove(event: DragEvent): void {
event.setResult(DragResult.DROP_ENABLED);
// 根据拖拽位置调整 UI
let y = event.getY();
if (y < 100) {
// 上半区域:显示 "置顶" 提示
this.hintText = '放置到顶部';
} else {
// 下半区域:显示 "到底部" 提示
this.hintText = '放置到末尾';
}
}
5.7 .onDrop() — 释放到目标区域(核心)
功能说明:用户在目标区域释放时触发,执行实际的数据处理逻辑。
语法格式:
.onDrop(callback: (event: DragEvent) => void)
完整实现:
handleDrop(event: DragEvent): void {
try {
// 1. 获取拖拽数据
let data = event.getData();
if (!data) {
console.error('No drag data available');
event.setResult(DragResult.DRAG_FAILED);
return;
}
// 2. 解析数据
let records = data.getRecords();
if (!records || records.length === 0) {
console.error('No records in data');
event.setResult(DragResult.DRAG_FAILED);
return;
}
// 3. 获取当前拖拽的物品
let itemName: string = '';
if (this.currentDragItem !== null) {
itemName = this.currentDragItem.displayName;
}
// 4. 执行业务逻辑:添加到已放置列表
if (itemName !== '') {
this.droppedItems.push(itemName);
// 5. 设置拖拽结果
event.setResult(DragResult.DRAG_SUCCESSFUL);
// 6. 反馈提示
promptAction.showToast({
message: '已添加: ' + itemName,
duration: 1500
});
} else {
event.setResult(DragResult.DRAG_FAILED);
}
} catch (e) {
let error: BusinessError = e as BusinessError;
console.error('Drop error: code=' + error.code + ', msg=' + error.message);
event.setResult(DragResult.DRAG_FAILED);
}
// 7. 清理状态
this.isInTargetArea = false;
this.targetBgColor = $r('app.color.target_bg');
this.targetBorderColor = $r('app.color.border');
}
数据解析流程:
UnifiedData → getRecords() → UnifiedRecord[]
↓
UnifiedRecord → 按类型获取内容
↓
PlainText → textContent(实际数据)
5.8 .onClick() — 点击事件(备选方案)
功能说明:处理点击事件,作为电脑端无法长按拖拽时的备选交互方式。
语法格式:
.onClick(callback: () => void)
在 DragDrop 备选方案中的应用:
// 点击源物品 → 选中状态
.onClick(() => {
if (this.selectedItem !== null && this.selectedItem.id === item.id) {
// 取消选中
this.selectedItem = null;
this.isSelectMode = false;
this.dragStatus = $r('app.string.idle');
this.targetBgColor = $r('app.color.target_bg');
} else {
// 选中物品
this.selectedItem = item;
this.isSelectMode = true;
this.dragStatus = $r('app.string.selected');
this.targetBgColor = $r('app.color.target_hover');
promptAction.showToast({
message: '已选中: ' + item.displayName,
duration: 1500
});
}
})
// 点击目标区域 → 执行放置
.onClick(() => {
if (this.selectedItem !== null) {
this.droppedItems.push(this.selectedItem.displayName);
this.dragStatus = $r('app.string.drop_success');
promptAction.showToast({
message: '已添加: ' + this.selectedItem.displayName,
duration: 1500
});
// 重置状态
this.selectedItem = null;
this.isSelectMode = false;
this.targetBgColor = $r('app.color.target_bg');
}
})
6. 数据交互 API
6.1 UnifiedData — 统一数据对象
功能说明:跨组件拖拽时传递数据的容器类。
创建方式:
// 方式一:从 Record 创建
let record = new unifiedDataChannel.UnifiedRecord(
uniformTypeDescriptor.UnifiedDataType.PLAIN_TEXT,
plainTextContent
);
let unifiedData = new unifiedDataChannel.UnifiedData(record);
// 方式二:从多个 Record 创建
let records = [
new unifiedDataChannel.UnifiedRecord(
uniformTypeDescriptor.UnifiedDataType.PLAIN_TEXT,
textContent
),
new unifiedDataChannel.UnifiedRecord(
uniformTypeDescriptor.UnifiedDataType.IMAGE,
imageContent
)
];
let unifiedData = new unifiedDataChannel.UnifiedData(records);
6.2 UnifiedRecord — 数据记录
功能说明:单一数据项的封装,包含类型和内容。
支持的数据类型:
| 类型 | 常量值 | 内容格式 |
|---|---|---|
| 纯文本 | PLAIN_TEXT |
PlainText 对象 |
| HTML | HTML |
字符串 |
| URI | URI |
字符串数组 |
| 图片 | IMAGE |
文件描述符 |
| 文件 | FILE |
文件描述符 |
代码示例:
// 创建纯文本记录
let plainText: uniformDataStruct.PlainText = {
uniformDataType: 'general.plain-text',
textContent: 'Hello DragDrop'
};
let record = new unifiedDataChannel.UnifiedRecord(
uniformTypeDescriptor.UnifiedDataType.PLAIN_TEXT,
plainText
);
// 获取记录内容
let retrievedRecord = unifiedData.getRecords()[0];
let content = retrievedRecord.getPlainText();
6.3 event.setData() — 设置拖拽数据
功能说明:在 onDragStart 中将数据绑定到拖拽事件。
语法格式:
event.setData(data: UnifiedData): void
完整流程:
.onDragStart((event: DragEvent) => {
// 1. 创建数据
let plainText: uniformDataStruct.PlainText = {
uniformDataType: 'general.plain-text',
textContent: item.displayName
};
let record = new unifiedDataChannel.UnifiedRecord(
uniformTypeDescriptor.UnifiedDataType.PLAIN_TEXT,
plainText
);
let unifiedData = new unifiedDataChannel.UnifiedData(record);
// 2. 绑定到事件
event.setData(unifiedData);
// 3. 返回预览
return {
builder: this.DragPreviewBuilder(item)
};
})
6.4 event.getData() — 获取拖拽数据
功能说明:在 onDrop 中获取拖拽传递过来的数据。
语法格式:
event.getData(): UnifiedData
完整流程:
.onDrop((event: DragEvent) => {
// 1. 获取数据
let data = event.getData();
if (data) {
// 2. 解析记录
let records = data.getRecords();
// 3. 读取内容
for (let i = 0; i < records.length; i++) {
let content = records[i].getPlainText();
console.log(`Record ${i}: ${content}`);
}
// 4. 处理业务
// ...
}
})
7. 样式与资源 API
7.1 $r() — 资源引用
功能说明:引用 resources 目录下的字符串、颜色、图片等资源。
语法格式:
$r('app.type.name')
常用资源类型:
// 字符串资源
$r('app.string.title')
// 颜色资源
$r('app.color.background')
// 图片资源
$r('app.media.icon')
在 DragDrop 中的应用:
// 引用字符串
Text($r('app.string.drag_title'))
.fontSize(24)
// 引用颜色
.backgroundColor($r('app.color.source_background'))
// 动态资源(根据状态切换)
.backgroundColor(
isDragging ?
$r('app.color.dragging_bg') :
$r('app.color.normal_bg')
)
7.2 promptAction.showToast() — 提示反馈
功能说明:显示轻量级提示消息,操作成功/失败时使用。
语法格式:
promptAction.showToast(options: PromptOptions)
基本用法:
promptAction.showToast({
message: '操作成功',
duration: 1500 // 显示时长(毫秒)
})
在 DragDrop 中的应用:
// 成功放置
promptAction.showToast({
message: '已添加: ' + itemName,
duration: 1500
})
// 选中提示
promptAction.showToast({
message: '已选中: ' + item.displayName,
duration: 1500
})
// 清空提示
promptAction.showToast({
message: '已清空所有物品',
duration: 1500
})
7.3 .shadow() — 阴影效果
功能说明:为组件添加阴影,增强视觉层次。
语法格式:
.shadow(options: ShadowOptions)
参数说明:
{
radius: number, // 阴影模糊半径
color: string, // 阴影颜色
offsetX: number, // 水平偏移
offsetY: number // 垂直偏移
}
在 DragDrop 中的应用:
// 拖拽预览阴影
Row() { /* ... */ }
.shadow({
radius: 12,
color: '#40000000', // 半透明黑色
offsetX: 0,
offsetY: 6
})
// 卡片阴影
Column() { /* ... */ }
.shadow({
radius: 8,
color: '#20000000',
offsetX: 2,
offsetY: 2
})
7.4 .border() — 边框样式
功能说明:设置组件边框的颜色、宽度、样式。
语法格式:
.border(options: BorderOptions)
完整示例:
// 基础边框
.border({
width: 1,
color: $r('app.color.border')
})
// 虚线边框(目标区域)
.border({
width: 2,
color: isActive ? $r('app.color.border_active') : $r('app.color.border'),
style: BorderStyle.Dashed
})
// 动态边框(选中物品)
.border({
width: selectedItem ? 2 : 1,
color: selectedItem ? $r('app.color.border_active') : $r('app.color.border')
})
8. 完整实战项目
8.1 项目概述
功能:水果分类拖拽应用,支持将水果从列表拖拽到购物篮。
双端适配:
- 手机/平板:长按拖拽
- 电脑预览:点击选中 → 点击放置
8.2 资源文件
color.json
{
"color": [
{ "name": "page_background", "value": "#F5F5F5" },
{ "name": "source_background", "value": "#E3F2FD" },
{ "name": "target_background", "value": "#FFF3E0" },
{ "name": "target_hover", "value": "#FFCC80" },
{ "name": "item_background", "value": "#FFFFFF" },
{ "name": "item_text", "value": "#333333" },
{ "name": "title_text", "value": "#1976D2" },
{ "name": "status_text", "value": "#666666" },
{ "name": "status_success", "value": "#4CAF50" },
{ "name": "status_error", "value": "#F44336" },
{ "name": "border", "value": "#BDBDBD" },
{ "name": "border_active", "value": "#FF9800" },
{ "name": "button_background", "value": "#2196F3" },
{ "name": "button_text", "value": "#FFFFFF" }
]
}
string.json
{
"string": [
{ "name": "app_name", "value": "购物篮" },
{ "name": "drag_title", "value": "水果分类" },
{ "name": "drag_hint", "value": "长按拖拽或点击选中后放置" },
{ "name": "source_title", "value": "可选水果" },
{ "name": "target_title", "value": "我的购物篮" },
{ "name": "drop_hint", "value": "将水果拖到这里\n或点击选择后再点击这里" },
{ "name": "status_idle", "value": "等待操作..." },
{ "name": "status_dragging", "value": "拖拽中..." },
{ "name": "status_enter", "value": "进入放置区域" },
{ "name": "status_leave", "value": "离开放置区域" },
{ "name": "status_drop", "value": "添加成功!" },
{ "name": "status_cancel", "value": "操作取消" },
{ "name": "status_selected", "value": "已选中,点击购物篮放置" },
{ "name": "item_apple", "value": "苹果 🍎" },
{ "name": "item_banana", "value": "香蕉 🍌" },
{ "name": "item_grape", "value": "葡萄 🍇" },
{ "name": "item_orange", "value": "橙子 🍊" },
{ "name": "item_watermelon", "value": "西瓜 🍉" },
{ "name": "item_strawberry", "value": "草莓 🍓" },
{ "name": "item_mango", "value": "芒果 🥭" },
{ "name": "clear_button", "value": "清空购物篮" }
]
}
8.3 主页面代码
// ============================================
// HarmonyOS DragDrop 完整实战
// ============================================
import { unifiedDataChannel, uniformDataStruct, uniformTypeDescriptor } from '@kit.ArkData';
import { promptAction } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';
// 数据模型
interface DragItemData {
id: string;
name: Resource;
displayName: string;
}
@Entry
@Component
struct Index {
// ===== 状态变量 =====
@State dragStatus: Resource = $r('app.string.status_idle');
@State targetBgColor: Resource = $r('app.color.target_background');
@State targetBorderColor: Resource = $r('app.color.border');
@State isInTargetArea: boolean = false;
@State droppedItems: string[] = [];
@State currentDragItem: DragItemData | null = null;
@State selectedItem: DragItemData | null = null;
@State isSelectMode: boolean = false;
// ===== 数据源 =====
private dragItems: DragItemData[] = [
{ id: 'apple', name: $r('app.string.item_apple'), displayName: '苹果 🍎' },
{ id: 'banana', name: $r('app.string.item_banana'), displayName: '香蕉 🍌' },
{ id: 'grape', name: $r('app.string.item_grape'), displayName: '葡萄 🍇' },
{ id: 'orange', name: $r('app.string.item_orange'), displayName: '橙子 🍊' },
{ id: 'watermelon', name: $r('app.string.item_watermelon'), displayName: '西瓜 🍉' },
{ id: 'strawberry', name: $r('app.string.item_strawberry'), displayName: '草莓 🍓' },
{ id: 'mango', name: $r('app.string.item_mango'), displayName: '芒果 🥭' }
];
// ===== 构建器 =====
// 拖拽预览 UI
@Builder
DragPreviewBuilder(item: DragItemData) {
Row() {
Text(item.name)
.fontSize(18)
.fontColor(Color.White)
.fontWeight(FontWeight.Bold)
}
.width(110)
.height(56)
.justifyContent(FlexAlign.Center)
.backgroundColor($r('app.color.title_text'))
.borderRadius(12)
.shadow({ radius: 8, color: '#40000000', offsetX: 0, offsetY: 4 })
}
// 空购物篮提示
@Builder
EmptyTargetBuilder() {
Column() {
Text($r('app.string.drop_hint'))
.fontSize(15)
.fontColor($r('app.color.status_text'))
.textAlign(TextAlign.Center)
.lineHeight(24)
if (this.selectedItem !== null) {
Text('✓ 已选中水果')
.fontSize(14)
.fontColor($r('app.color.status_success'))
.margin({ top: 12 })
}
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
// 已放物品列表
@Builder
DroppedItemsBuilder() {
Column() {
Text(`购物篮 (${this.droppedItems.length})`)
.fontSize(16)
.fontColor($r('app.color.title_text'))
.fontWeight(FontWeight.Bold)
.margin({ bottom: 12 })
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.droppedItems, (item: string, index: number) => {
Text(item)
.fontSize(14)
.fontColor($r('app.color.item_text'))
.padding({ left: 14, right: 14, top: 8, bottom: 8 })
.margin({ right: 8, bottom: 8 })
.backgroundColor($r('app.color.source_background'))
.borderRadius(20)
}, (item: string, index: number) => index.toString())
}
.width('100%')
}
.width('100%')
.padding(16)
}
// ===== 拖拽事件处理 =====
handleDragStart(item: DragItemData, event: DragEvent): DragItemInfo {
this.currentDragItem = item;
this.dragStatus = $r('app.string.status_dragging');
this.selectedItem = null;
try {
let plainText: uniformDataStruct.PlainText = {
uniformDataType: 'general.plain-text',
textContent: item.displayName
};
let record = new unifiedDataChannel.UnifiedRecord(
uniformTypeDescriptor.UnifiedDataType.PLAIN_TEXT,
plainText
);
let unifiedData = new unifiedDataChannel.UnifiedData(record);
event.setData(unifiedData);
} catch (e) {
console.error('setData error: ' + (e as BusinessError).message);
}
return {
builder: this.DragPreviewBuilder(item),
extraInfo: item.id
};
}
handleDragEnd(event: DragEvent): void {
let result = event.getResult();
if (result === DragResult.DRAG_SUCCESSFUL) {
this.dragStatus = $r('app.string.status_drop');
} else if (result === DragResult.DRAG_FAILED) {
this.dragStatus = $r('app.string.status_cancel');
} else {
this.dragStatus = $r('app.string.status_idle');
}
this.currentDragItem = null;
this.isInTargetArea = false;
this.targetBgColor = $r('app.color.target_background');
this.targetBorderColor = $r('app.color.border');
}
handleDragEnter(event: DragEvent): void {
this.isInTargetArea = true;
this.dragStatus = $r('app.string.status_enter');
this.targetBgColor = $r('app.color.target_hover');
this.targetBorderColor = $r('app.color.border_active');
}
handleDragLeave(event: DragEvent): void {
this.isInTargetArea = false;
this.dragStatus = $r('app.string.status_leave');
this.targetBgColor = $r('app.color.target_background');
this.targetBorderColor = $r('app.color.border');
}
handleDragMove(event: DragEvent): void {
event.setResult(DragResult.DROP_ENABLED);
}
handleDrop(event: DragEvent): void {
try {
let data = event.getData();
if (data) {
let records = data.getRecords();
if (records && records.length > 0) {
let itemName: string = '';
if (this.currentDragItem !== null) {
itemName = this.currentDragItem.displayName;
}
if (itemName !== '') {
this.droppedItems.push(itemName);
event.setResult(DragResult.DRAG_SUCCESSFUL);
promptAction.showToast({ message: '添加: ' + itemName });
} else {
event.setResult(DragResult.DRAG_FAILED);
}
}
}
} catch (e) {
console.error('Drop error: ' + (e as BusinessError).message);
event.setResult(DragResult.DRAG_FAILED);
}
this.isInTargetArea = false;
this.targetBgColor = $r('app.color.target_background');
this.targetBorderColor = $r('app.color.border');
}
// ===== 点击模式处理 =====
selectItem(item: DragItemData): void {
if (this.selectedItem !== null && this.selectedItem.id === item.id) {
this.selectedItem = null;
this.isSelectMode = false;
this.dragStatus = $r('app.string.status_idle');
this.targetBgColor = $r('app.color.target_background');
this.targetBorderColor = $r('app.color.border');
return;
}
this.selectedItem = item;
this.isSelectMode = true;
this.dragStatus = $r('app.string.status_selected');
this.targetBgColor = $r('app.color.target_hover');
this.targetBorderColor = $r('app.color.border_active');
promptAction.showToast({ message: '选中: ' + item.displayName });
}
placeSelectedItem(): void {
if (this.selectedItem !== null) {
this.droppedItems.push(this.selectedItem.displayName);
this.dragStatus = $r('app.string.status_drop');
promptAction.showToast({ message: '添加: ' + this.selectedItem.displayName });
this.selectedItem = null;
this.isSelectMode = false;
this.targetBgColor = $r('app.color.target_background');
this.targetBorderColor = $r('app.color.border');
}
}
clearDroppedItems(): void {
this.droppedItems = [];
this.dragStatus = $r('app.string.status_idle');
this.selectedItem = null;
this.isSelectMode = false;
promptAction.showToast({ message: '已清空购物篮' });
}
// ===== 辅助方法 =====
getItemBgColor(item: DragItemData): Resource {
if (this.selectedItem !== null && this.selectedItem.id === item.id) {
return $r('app.color.target_hover');
}
return $r('app.color.item_background');
}
getStatusColor(): Resource {
if (this.dragStatus === $r('app.string.status_drop')) {
return $r('app.color.status_success');
}
return $r('app.color.status_text');
}
// ===== UI 构建 =====
build() {
Column() {
// 标题区域
Column() {
Text($r('app.string.drag_title'))
.fontSize(26)
.fontColor($r('app.color.title_text'))
.fontWeight(FontWeight.Bold)
Text($r('app.string.drag_hint'))
.fontSize(13)
.fontColor($r('app.color.status_text'))
.margin({ top: 6 })
}
.width('100%')
.padding({ top: 32, bottom: 20 })
// 状态指示
Row() {
Text(this.dragStatus)
.fontSize(14)
.fontColor(this.getStatusColor())
.padding({ left: 16, right: 16, top: 8, bottom: 8 })
.backgroundColor('#10000000')
.borderRadius(20)
}
.width('100%')
.justifyContent(FlexAlign.Center)
.margin({ bottom: 16 })
// 主区域
Row() {
// 左侧:可选水果列表
Column() {
Text($r('app.string.source_title'))
.fontSize(17)
.fontColor($r('app.color.title_text'))
.fontWeight(FontWeight.Bold)
.margin({ bottom: 12 })
Scroll() {
Column({ space: 10 }) {
ForEach(this.dragItems, (item: DragItemData) => {
Row() {
Text(item.name)
.fontSize(18)
.fontColor($r('app.color.item_text'))
if (this.selectedItem !== null && this.selectedItem.id === item.id) {
Text(' ✓')
.fontSize(16)
.fontColor($r('app.color.status_success'))
.fontWeight(FontWeight.Bold)
}
}
.width('100%')
.height(52)
.justifyContent(FlexAlign.Center)
.backgroundColor(this.getItemBgColor(item))
.borderRadius(10)
.border({
width: this.selectedItem !== null && this.selectedItem.id === item.id ? 2 : 1,
color: this.selectedItem !== null && this.selectedItem.id === item.id ?
$r('app.color.border_active') : $r('app.color.border')
})
// 手机端:长按拖拽
.draggable(true)
.onDragStart((event: DragEvent) => {
return this.handleDragStart(item, event);
})
.onDragEnd((event: DragEvent) => {
this.handleDragEnd(event);
})
// 电脑端:点击选中
.onClick(() => {
this.selectItem(item);
})
}, (item: DragItemData) => item.id)
}
}
.width('100%')
.scrollBar(BarState.Off)
}
.width('45%')
.padding(14)
.backgroundColor($r('app.color.source_background'))
.borderRadius(12)
// 右侧:购物篮
Column() {
Text($r('app.string.target_title'))
.fontSize(17)
.fontColor($r('app.color.title_text'))
.fontWeight(FontWeight.Bold)
.margin({ bottom: 12 })
Column() {
if (this.droppedItems.length === 0) {
this.EmptyTargetBuilder();
} else {
this.DroppedItemsBuilder();
}
}
.width('100%')
.layoutWeight(1)
.backgroundColor(this.targetBgColor)
.borderRadius(10)
.border({
width: 2,
color: this.targetBorderColor,
style: BorderStyle.Dashed
})
// 电脑端:点击放置
.onClick(() => {
this.placeSelectedItem();
})
}
.layoutWeight(1)
.padding(14)
.backgroundColor($r('app.color.target_background'))
.borderRadius(12)
// 手机端:接收拖拽
.onDragEnter((event: DragEvent) => {
this.handleDragEnter(event);
})
.onDragLeave((event: DragEvent) => {
this.handleDragLeave(event);
})
.onDragMove((event: DragEvent) => {
this.handleDragMove(event);
})
.onDrop((event: DragEvent) => {
this.handleDrop(event);
})
}
.width('100%')
.layoutWeight(1)
.padding({ left: 16, right: 16 })
// 清空按钮
if (this.droppedItems.length > 0) {
Button($r('app.string.clear_button'))
.width('60%')
.height(48)
.fontSize(16)
.fontColor($r('app.color.button_text'))
.backgroundColor($r('app.color.button_background'))
.borderRadius(24)
.margin({ top: 16, bottom: 24 })
.onClick(() => {
this.clearDroppedItems();
})
}
}
.width('100%')
.height('100%')
.backgroundColor($r('app.color.page_background'))
}
}
9. 双端适配策略
9.1 问题背景
在开发过程中发现:
- 手机/平板:支持长按拖拽(原生 DragDrop 体验)
- 电脑预览器:不支持长按,拖拽功能不可用
9.2 解决方案
采用双模式并存策略:
| 设备 | 交互方式 | 实现原理 |
|---|---|---|
| 手机 | 长按 → 拖拽 → 释放 | .draggable() + .onDragStart/End |
| 电脑 | 点击选中 → 点击放置 | .onClick() 状态管理 |
9.3 状态管理
// 拖拽模式状态
@State currentDragItem: DragItemData | null = null;
// 点击模式状态
@State selectedItem: DragItemData | null = null;
@State isSelectMode: boolean = false;
9.4 模式切换逻辑
// onDragStart 时清除点击状态
handleDragStart(item: DragItemData, event: DragEvent): DragItemInfo {
this.currentDragItem = item;
this.selectedItem = null; // ← 清除点击模式
// ...
}
// onClick 时清除拖拽状态
selectItem(item: DragItemData): void {
this.selectedItem = item;
this.isSelectMode = true;
this.currentDragItem = null; // ← 清除拖拽模式
// ...
}
10. 进阶技巧与性能优化
10.1 多个目标区域
Row() {
// 目标区域 A
Column() { /* ... */ }
.onDrop((event: DragEvent) => {
this.handleDropToA(event);
})
// 目标区域 B
Column() { /* ... */ }
.onDrop((event: DragEvent) => {
this.handleDropToB(event);
})
}
10.2 拖拽排序
handleDrop(event: DragEvent, targetIndex: number): void {
// 从源区域获取被拖元素
let draggedItem = this.getDraggedItem(event);
// 重新插入到目标位置
this.items.splice(targetIndex, 0, draggedItem);
}
10.3 性能优化建议
| 优化项 | 建议 |
|---|---|
| 减少状态更新 | 批量更新多个状态,避免逐次更新 |
| 使用条件渲染 | 仅在必要时渲染复杂组件 |
| 列表虚拟化 | 长列表使用 LazyForEach |
| Builder 复用 | 相同 UI 使用同一 Builder |
10.4 错误处理模式
handleDrop(event: DragEvent): void {
try {
// 业务逻辑
} catch (e) {
// 统一错误处理
let error = e as BusinessError;
console.error('Drop failed: ' + error.message);
promptAction.showToast({ message: '操作失败,请重试' });
event.setResult(DragResult.DRAG_FAILED);
} finally {
// 清理状态
this.resetDragState();
}
}
11. 常见问题解答
Q1: .draggable(true) 不生效怎么办?
A: 请检查以下几点:
- 确认组件是可交互的(非纯展示组件)
- 同时实现
.onDragStart回调 - 不要在 Builder 返回值上使用,直接在组件上链式调用
// ✅ 正确
Row() { /* ... */ }
.draggable(true)
.onDragStart(handler)
// ❌ 错误
MyBuilder() // 假设这是个 Builder
.draggable(true)
Q2: 电脑端无法拖拽?
A: 电脑预览器通常不支持长按手势,建议实现点击模式作为备选方案(详见第 9 节)。
Q3: setData 失败怎么办?
A:
- 检查导入是否完整:
import { unifiedDataChannel, uniformDataStruct, uniformTypeDescriptor } from '@kit.ArkData' - 确认数据类型正确(推荐从 PLAIN_TEXT 开始)
- 使用 try-catch 包裹并打印错误信息
Q4: 如何传递自定义对象?
A: 将对象序列化为 JSON 字符串:
let plainText: uniformDataStruct.PlainText = {
uniformDataType: 'general.plain-text',
textContent: JSON.stringify(customObject)
};
Q5: 拖拽预览如何自定义?
A: 通过 onDragStart 返回的 Builder 完全自定义,支持任意 UI 组件:
return {
builder: this.CustomPreviewBuilder(item)
};
@Builder
CustomPreviewBuilder(item: DragItemData) {
Stack() {
Image(item.icon).width(60).height(60)
Text(item.name).fontSize(12)
}
.width(80)
.height(80)
.backgroundColor(Color.White)
.borderRadius(8)
.shadow({ radius: 8, color: '#30000000', offsetY: 4 })
}
12. 总结与展望
12.1 核心知识点回顾
| 模块 | 关键 API | 数量 |
|---|---|---|
| 装饰器 | @Entry, @Component, @State, @Builder, import, struct, private, @ObjectLink | 8 |
| 布局 | Column, Row, Text, Button, ForEach, Flex, layoutWeight, padding/margin | 8 |
| 拖拽 | .draggable(), .onDragStart/End/Enter/Leave/Move/Drop/onClick | 8 |
12.2 开发流程总结
1. 创建项目 → 2. 定义数据模型 → 3. 搭建布局
→ 4. 启用拖拽能力 → 5. 实现事件处理 → 6. 数据传递
→ 7. 视觉反馈 → 8. 双端适配 → 9. 测试优化
12.3 学习建议
- 从基础开始:先实现简单的拖拽放置,再逐步添加复杂度
- 关注状态管理:DragDrop 的核心是状态的正确流转
- 重视用户体验:添加适当的动画、震动、声音反馈
- 适配多设备:从一开始就考虑不同屏幕
更多推荐


所有评论(0)