鸿蒙跨设备拖拽开发指南
一、概述
1.1 什么是跨设备拖拽
跨设备拖拽(Distributed Drag)是HarmonyOS提供的一项强大的分布式协同能力,允许用户在两台平板或2in1设备间共享一套键鼠,通过拖拽操作一步将设备A的素材(图片、文本、文件等)拖拽到设备B,实现跨设备快速创作和协同办公。
1.2 核心特性
- 键鼠共享:两台设备共享一套键盘和鼠标,鼠标可跨屏移动
- 无缝拖拽:直接将内容从一台设备拖拽到另一台设备
- 数据类型丰富:支持文本、图片、文件等多种数据类型
- 框架级支持:多个系统组件默认支持跨设备拖拽能力
- 自动同步:系统自动完成数据传输和同步
1.3 典型应用场景
| 场景 | 描述 | 示例 |
|---|---|---|
| 文件共享 | 将文件管理器中的图片拖拽到另一设备的备忘录 | 从平板A的相册拖拽照片到平板B的文档中 |
| 文本协同 | 将一个设备的文本内容拖拽到另一设备 | 从平板A的备忘录拖拽文字到平板B的编辑器 |
| 素材创作 | 跨设备组合素材进行创作 | 从平板A拖拽设计元素到平板B的创作应用 |
| 键鼠协同 | 使用A设备键鼠操作B设备应用 | 用平板A的键盘输入内容到平板B的应用中 |
二、前置条件
2.1 硬件要求
- 设备类型:HarmonyOS平板或2in1设备(不支持手机)
- 设备数量:2台设备
- 输入设备:支持键鼠操作
2.2 系统要求
- 系统版本:HarmonyOS NEXT Developer Preview0 及以上
- 账号要求:双端设备需登录同一华为账号
- 网络要求:
- 双端打开Wi-Fi和蓝牙
- 建议接入同一局域网
2.3 功能开关
- 键鼠穿越开关:需在设置中启用键鼠穿越功能
- 路径:设置 → 连接与共享 → 键鼠穿越
2.4 约束限制
- ❌ 应用预置资源文件不支持跨设备拖拽
- ❌ 仅支持平板和2in1设备,不支持手机和其他设备类型
- ⚠️ 拖拽数据需控制大小,避免传输延迟
三、支持拖拽的组件
3.1 默认支持拖出的组件
以下组件默认支持作为拖拽源(可拖出内容):
| 组件 | 拖拽内容 | draggable默认值 | 说明 |
|---|---|---|---|
| Text | 文本内容 | true | 支持文本拖拽 |
| Image | 图片数据 | true | 支持图片拖拽 |
| TextInput | 输入文本 | true | 支持输入框文本拖拽 |
| TextArea | 多行文本 | true | 支持多行文本拖拽 |
| Search | 搜索文本 | 可配置 | 支持搜索框文本拖拽 |
| RichEditor | 富文本内容 | true | 支持富文本拖拽 |
| Hyperlink | 链接内容 | true | 支持超链接拖拽 |
3.2 默认支持拖入的组件
以下组件默认支持作为拖拽目标(可接收内容):
| 组件 | 接收内容类型 | 说明 |
|---|---|---|
| Search | 文本 | 可接收文本拖入 |
| TextInput | 文本 | 可接收文本拖入 |
| TextArea | 文本 | 可接收文本拖入 |
| Video | 视频文件 | 可接收视频文件拖入 |
3.3 自定义组件拖拽
对于其他组件,需要:
- 手动设置
draggable(true)属性 - 实现
onDragStart等拖拽事件接口
// 自定义组件启用拖拽
Column()
.draggable(true) // 启用拖拽能力
.onDragStart(() => {
// 实现拖拽开始逻辑
})
四、核心API详解
4.1 七大核心拖拽API
// 1. 拖拽开始
onDragStart(event: (event: DragEvent, extraParams?: string) => CustomBuilder | DragItemInfo)
// 2. 拖拽进入目标区域
onDragEnter(event: (event: DragEvent, extraParams?: string) => void)
// 3. 拖拽在目标区域内移动
onDragMove(event: (event: DragEvent, extraParams?: string) => void)
// 4. 拖拽离开目标区域
onDragLeave(event: (event: DragEvent, extraParams?: string) => void)
// 5. 拖拽释放(放下)
onDrop(event: (event: DragEvent, extraParams?: string) => void)
// 6. 拖拽结束
onDragEnd(event: (event: DragEvent, extraParams?: string) => void)
// 7. 拖拽预处理(拖拽发起前)
onPreDrag(event: (preDragStatus: PreDragStatus) => void)
4.2 onDragStart - 拖拽开始
触发条件:
- 长按时间 ≥ 500ms
- 手指移动距离 ≥ 10vp
返回值:
CustomBuilder:自定义背板图(拖拽跟手图)DragItemInfo:包含背板图和更多拖拽信息
重要特性:
- 对于默认支持拖出的组件,开发者设置的
onDragStart优先级更高 - 返回自定义背板图则不使用系统默认背板图
- 设置拖拽数据则不使用系统默认数据
- ⚠️ 文本类组件的选中文本拖拽不支持背板图自定义
示例:
@Builder
customDragBuilder() {
Column() {
Text('拖拽中...')
.fontSize(16)
.fontColor(Color.White)
}
.width(100)
.height(100)
.backgroundColor('#4CAF50')
.borderRadius(8)
}
Image($r('app.media.photo'))
.width(200)
.height(200)
.draggable(true)
.onDragStart((event: DragEvent, extraParams: string) => {
// 设置拖拽数据
let unifiedData = new unifiedDataChannel.UnifiedData();
let image = new unifiedDataChannel.Image();
image.imageUri = 'file://path/to/image.png';
unifiedData.addRecord(image);
event.setData(unifiedData);
// 返回自定义背板图
return this.customDragBuilder;
})
4.3 onDragEnter - 进入目标区域
当拖拽内容进入释放目标组件范围时触发,通常用于:
- 改变目标组件外观(如高亮边框)
- 显示可放置的视觉反馈
- 准备接收数据
Column()
.onDragEnter((event: DragEvent, extraParams: string) => {
console.log('拖拽内容进入目标区域');
// 改变边框颜色提示用户
this.borderColor = Color.Green;
})
4.4 onDragMove - 在目标区域内移动
当拖拽内容在释放目标组件范围内移动时触发,可用于:
- 实时更新视觉反馈
- 显示拖拽位置指示器
- 计算精确落点
Column()
.onDragMove((event: DragEvent, extraParams: string) => {
// 获取拖拽位置
let rect = event.getPreviewRect();
console.log(`拖拽位置: x=${rect.x}, y=${rect.y}`);
})
4.5 onDragLeave - 离开目标区域
当拖拽内容从释放目标组件范围移出时触发,用于:
- 恢复目标组件原始外观
- 取消视觉反馈
Column()
.onDragLeave((event: DragEvent, extraParams: string) => {
console.log('拖拽内容离开目标区域');
// 恢复边框颜色
this.borderColor = Color.Gray;
})
4.6 onDrop - 释放拖拽内容
当拖拽内容在释放目标组件上方释放时触发,是最关键的API:
- 获取拖拽数据
- 处理数据并更新UI
- 设置接收结果(成功/失败)
Column()
.onDrop((event: DragEvent, extraParams: string) => {
console.log('拖拽内容已释放');
// 1. 获取拖拽数据
let unifiedData = event.getData();
let records = unifiedData.getRecords();
// 2. 处理数据
if (records.length > 0) {
let record = records[0];
if (record.getType() === unifiedDataChannel.UnifiedDataType.TEXT) {
let textData = record as unifiedDataChannel.PlainText;
this.receivedText = textData.textContent;
// 3. 设置接收成功
event.setResult(DragResult.DRAG_SUCCESSFUL);
} else if (record.getType() === unifiedDataChannel.UnifiedDataType.IMAGE) {
let imageData = record as unifiedDataChannel.Image;
this.receivedImageUri = imageData.imageUri;
event.setResult(DragResult.DRAG_SUCCESSFUL);
}
} else {
// 接收失败
event.setResult(DragResult.DRAG_FAILED);
}
// 恢复UI状态
this.borderColor = Color.Gray;
})
4.7 onDragEnd - 拖拽结束
绑定此事件的组件拖拽结束后触发(源端):
- 获取拖拽结果(成功/失败)
- 执行清理操作
- 根据结果决定是否删除原数据(剪切模式)
Image($r('app.media.photo'))
.draggable(true)
.onDragEnd((event: DragEvent, extraParams: string) => {
let result = event.getResult();
if (result === DragResult.DRAG_SUCCESSFUL) {
console.log('拖拽成功');
// 检查是否为剪切模式
if (event.dragBehavior === DragBehavior.MOVE) {
// 剪切模式:删除源数据
this.deleteSourceImage();
}
} else {
console.log('拖拽失败或取消');
}
})
4.8 onPreDrag - 拖拽预处理
在拖拽发起前的不同阶段触发,用于:
- 预加载数据
- 准备拖拽资源
- 提前进行权限检查
Image($r('app.media.photo'))
.onPreDrag((preDragStatus: PreDragStatus) => {
if (preDragStatus === PreDragStatus.ACTION_DETECTING_STATUS) {
console.log('正在检测拖拽动作');
// 预加载图片数据
this.preloadImageData();
}
})
五、关键类与数据结构
5.1 DragEvent 类
DragEvent 是拖拽事件的核心类,包含拖拽相关的所有信息和操作方法。
class DragEvent {
// ========== 属性 ==========
/**
* 是否禁用系统默认落位动效
* true: 禁用系统动效(需自行实现)
* false: 使用系统默认动效
*/
useCustomDropAnimation: boolean;
/**
* 拖拽行为模式(切换复制/剪贴模式)
* COPY: 复制模式(显示"+"角标)
* MOVE: 剪切模式(无角标或显示移动标识)
*/
dragBehavior: DragBehavior;
// ========== 方法 ==========
/**
* 设置拖拽数据
* @param unifiedData 统一数据格式
*/
setData(unifiedData: UnifiedData): void
/**
* 获取拖拽数据
* @returns 统一数据格式
*/
getData(): UnifiedData
/**
* 获取数据摘要(不包含完整数据,仅元信息)
* @returns 数据摘要
*/
getSummary(): Summary
/**
* 设置拖拽结果(在onDrop中调用)
* @param dragResult 拖拽结果状态
*/
setResult(dragResult: DragResult): void
/**
* 获取拖拽结果(在onDragEnd中调用)
* @returns 拖拽结果状态
*/
getResult(): DragResult
/**
* 获取拖拽跟手图的位置和尺寸
* @returns 矩形位置信息
*/
getPreviewRect(): Rectangle
}
5.2 DragResult 枚举
拖拽结果状态枚举:
enum DragResult {
/**
* 拖拽成功
*/
DRAG_SUCCESSFUL = 0,
/**
* 拖拽失败
*/
DRAG_FAILED = 1,
/**
* 拖拽取消
*/
DRAG_CANCELED = 2,
/**
* 拖拽在当前应用内
*/
DROP_IN_APP = 3,
/**
* 拖拽到应用外
*/
DROP_OUTSIDE_APP = 4
}
5.3 DragBehavior 枚举
拖拽行为模式:
enum DragBehavior {
/**
* 复制模式(Ctrl+拖拽)
* 显示"+"角标,目标端复制数据,源端保留原数据
*/
COPY = 0,
/**
* 移动模式(默认拖拽)
* 目标端接收数据,源端可删除原数据
*/
MOVE = 1
}
5.4 UnifiedData 统一数据格式
用于封装拖拽传输的数据,支持多种数据类型:
import { unifiedDataChannel } from '@kit.ArkData';
// 创建统一数据对象
let unifiedData = new unifiedDataChannel.UnifiedData();
// 添加文本数据
let plainText = new unifiedDataChannel.PlainText();
plainText.textContent = '这是拖拽的文本内容';
unifiedData.addRecord(plainText);
// 添加图片数据
let image = new unifiedDataChannel.Image();
image.imageUri = 'file://path/to/image.png';
unifiedData.addRecord(image);
// 添加文件数据
let file = new unifiedDataChannel.File();
file.uri = 'file://path/to/document.pdf';
file.name = 'document.pdf';
unifiedData.addRecord(file);
支持的数据类型:
PlainText:纯文本Hyperlink:超链接HTML:HTML内容Image:图片Video:视频Audio:音频File:文件SystemDefinedRecord:系统定义的记录
5.5 Summary 数据摘要
数据摘要结构,包含数据的元信息但不包含完整数据:
class Summary {
// 数据类型总数
totalCount: number;
// 各类型数据的数量映射
// 例如:{ "text": 2, "image": 1 }
dataTypeCount: Record<string, number>;
}
// 使用示例
Column()
.onDragEnter((event: DragEvent, extraParams: string) => {
let summary = event.getSummary();
console.log(`拖拽数据包含 ${summary.totalCount} 个项目`);
console.log(`数据类型分布: ${JSON.stringify(summary.dataTypeCount)}`);
// 根据数据类型决定是否接受
if (summary.dataTypeCount['image'] > 0) {
this.canAccept = true;
}
})
5.6 Rectangle 矩形位置
表示拖拽跟手图的位置和尺寸:
interface Rectangle {
x: number; // X坐标
y: number; // Y坐标
width: number; // 宽度
height: number; // 高度
}
5.7 DragItemInfo 拖拽项信息
比 CustomBuilder 提供更多配置选项:
interface DragItemInfo {
// 拖拽项的像素图(背板图)
pixelMap: PixelMap;
// 自定义构建器(可选)
builder?: CustomBuilder;
// 额外信息(JSON字符串)
extraInfo?: string;
}
// 使用示例
.onDragStart((event: DragEvent, extraParams: string) => {
let dragItemInfo: DragItemInfo = {
pixelMap: this.createPreviewPixelMap(),
extraInfo: JSON.stringify({
itemId: '12345',
itemType: 'image'
})
};
return dragItemInfo;
})
5.8 extraParams 额外参数
extraParams 是一个 JSON 字符串,用于传递组件拖拽中的额外信息:
.onDrop((event: DragEvent, extraParams: string) => {
// 解析额外参数
if (extraParams) {
let params = JSON.parse(extraParams);
console.log(`组件ID: ${params.componentId}`);
console.log(`拖拽类型: ${params.dragType}`);
}
})
六、跨设备拖拽流程
6.1 整体交互流程
sequenceDiagram
participant 用户
participant 设备A(源端)
participant 键鼠穿越服务
participant 设备B(目标端)
用户->>设备A(源端): 长按组件(≥500ms)
设备A(源端)->>设备A(源端): 触发onDragStart
设备A(源端)->>设备A(源端): 准备拖拽数据(UnifiedData)
设备A(源端)->>设备A(源端): 显示拖拽背板图
用户->>设备A(源端): 移动鼠标(≥10vp)
设备A(源端)->>键鼠穿越服务: 鼠标移动事件
用户->>键鼠穿越服务: 鼠标跨屏移动
键鼠穿越服务->>设备B(目标端): 转发鼠标事件
设备B(目标端)->>设备B(目标端): 鼠标光标显示在设备B
用户->>设备B(目标端): 鼠标进入目标组件
设备B(目标端)->>设备B(目标端): 触发onDragEnter
设备B(目标端)->>设备B(目标端): 显示可接收视觉反馈
用户->>设备B(目标端): 鼠标在目标组件内移动
设备B(目标端)->>设备B(目标端): 触发onDragMove
设备B(目标端)->>设备B(目标端): 更新视觉反馈
alt 用户松开鼠标(放下)
用户->>设备B(目标端): 松开鼠标按键
设备B(目标端)->>设备B(目标端): 触发onDrop
设备B(目标端)->>设备B(目标端): 接收UnifiedData数据
设备B(目标端)->>设备B(目标端): 处理并更新UI
设备B(目标端)->>设备B(目标端): event.setResult(SUCCESSFUL)
设备B(目标端)->>设备A(源端): 返回拖拽结果
设备A(源端)->>设备A(源端): 触发onDragEnd
设备A(源端)->>设备A(源端): 根据DragBehavior决定是否删除源数据
设备A(源端)->>用户: 拖拽完成
else 用户移出目标组件
用户->>设备B(目标端): 鼠标移出组件区域
设备B(目标端)->>设备B(目标端): 触发onDragLeave
设备B(目标端)->>设备B(目标端): 取消视觉反馈
else 用户取消拖拽
用户->>设备B(目标端): 按ESC或拖拽到无效区域
设备B(目标端)->>设备A(源端): 返回DRAG_CANCELED
设备A(源端)->>设备A(源端): 触发onDragEnd
设备A(源端)->>用户: 拖拽取消
end
6.2 生命周期详解
stateDiagram-v2
[*] --> 就绪状态: 组件已设置draggable(true)
就绪状态 --> 预拖拽状态: 用户长按组件
预拖拽状态 --> 就绪状态: 长按时间<500ms或移动<10vp
预拖拽状态 --> 拖拽开始: 长按≥500ms且移动≥10vp
拖拽开始: onDragStart被调用
拖拽开始: 准备UnifiedData
拖拽开始: 显示背板图
拖拽开始 --> 拖拽中: 开始移动
拖拽中 --> 进入目标: 鼠标进入目标组件
进入目标: onDragEnter被调用
进入目标: 显示接收反馈
进入目标 --> 目标内移动: 在目标内移动
目标内移动: onDragMove持续调用
目标内移动: 更新视觉反馈
目标内移动 --> 目标内移动: 继续移动
目标内移动 --> 离开目标: 移出目标区域
离开目标: onDragLeave被调用
离开目标: 取消反馈
离开目标 --> 拖拽中: 继续拖拽
目标内移动 --> 释放: 松开鼠标
释放: onDrop被调用
释放: 处理数据
释放: setResult()
释放 --> 拖拽结束: 数据处理完成
拖拽中 --> 拖拽结束: 拖拽取消或失败
拖拽结束: onDragEnd被调用
拖拽结束: 获取结果
拖拽结束: 清理操作
拖拽结束 --> [*]
七、完整开发示例
7.1 图片跨设备拖拽示例
场景说明
实现从设备A的相册将图片拖拽到设备B的图片编辑器。
源端代码(设备A - 相册应用)
import { unifiedDataChannel } from '@kit.ArkData';
import { hilog } from '@kit.PerformanceAnalysisKit';
const TAG = '[PhotoGallery]';
const DOMAIN = 0xFF00;
@Entry
@Component
struct PhotoGalleryPage {
@State photoList: string[] = [
'file://data/photo1.jpg',
'file://data/photo2.jpg',
'file://data/photo3.jpg'
];
@State selectedPhotoIndex: number = -1;
@Builder
dragPreviewBuilder() {
Column() {
Image(this.photoList[this.selectedPhotoIndex])
.width(120)
.height(120)
.borderRadius(8)
.objectFit(ImageFit.Cover)
.opacity(0.8)
Text('拖拽图片中...')
.fontSize(12)
.fontColor(Color.White)
.backgroundColor('#4CAF50')
.padding(4)
.borderRadius(4)
.margin({ top: 8 })
}
}
build() {
Column() {
Text('我的相册')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.margin(20)
Grid() {
ForEach(this.photoList, (photoUri: string, index: number) => {
GridItem() {
Image(photoUri)
.width('100%')
.aspectRatio(1)
.objectFit(ImageFit.Cover)
.borderRadius(8)
.draggable(true) // 启用拖拽
// ========== 拖拽开始 ==========
.onDragStart((event: DragEvent, extraParams: string) => {
hilog.info(DOMAIN, TAG, `开始拖拽图片: ${photoUri}`);
// 记录选中的图片索引
this.selectedPhotoIndex = index;
// 1. 创建统一数据对象
let unifiedData = new unifiedDataChannel.UnifiedData();
// 2. 创建图片数据记录
let imageData = new unifiedDataChannel.Image();
imageData.imageUri = photoUri;
// 3. 添加额外的元信息
let details = {
fileName: `photo${index + 1}.jpg`,
fileSize: 1024 * 500, // 500KB
mimeType: 'image/jpeg',
timestamp: Date.now()
};
imageData.details = details;
// 4. 将图片数据添加到统一数据对象
unifiedData.addRecord(imageData);
// 5. 设置拖拽数据
event.setData(unifiedData);
hilog.info(DOMAIN, TAG, `拖拽数据已设置`);
// 6. 返回自定义背板图
return this.dragPreviewBuilder;
})
// ========== 拖拽结束 ==========
.onDragEnd((event: DragEvent, extraParams: string) => {
let result = event.getResult();
if (result === DragResult.DRAG_SUCCESSFUL) {
hilog.info(DOMAIN, TAG, `拖拽成功`);
// 检查拖拽行为模式
if (event.dragBehavior === DragBehavior.MOVE) {
// 剪切模式:删除源图片
hilog.info(DOMAIN, TAG, `剪切模式,删除源图片`);
this.photoList.splice(index, 1);
} else {
// 复制模式:保留源图片
hilog.info(DOMAIN, TAG, `复制模式,保留源图片`);
}
// 显示成功提示
this.showToast('图片已成功拖拽到目标设备');
} else if (result === DragResult.DRAG_FAILED) {
hilog.warn(DOMAIN, TAG, `拖拽失败`);
this.showToast('拖拽失败,请重试');
} else if (result === DragResult.DRAG_CANCELED) {
hilog.info(DOMAIN, TAG, `拖拽已取消`);
}
// 重置选中索引
this.selectedPhotoIndex = -1;
})
}
})
}
.columnsTemplate('1fr 1fr 1fr')
.rowsGap(10)
.columnsGap(10)
.padding(20)
}
.width('100%')
.height('100%')
.backgroundColor('#f5f5f5')
}
private showToast(message: string) {
// 显示Toast提示
hilog.info(DOMAIN, TAG, message);
}
}
目标端代码(设备B - 图片编辑器应用)
import { unifiedDataChannel } from '@kit.ArkData';
import { hilog } from '@kit.PerformanceAnalysisKit';
const TAG = '[PhotoEditor]';
const DOMAIN = 0xFF00;
@Entry
@Component
struct PhotoEditorPage {
@State receivedImageUri: string = '';
@State isDropZoneActive: boolean = false;
@State dropZoneBorderColor: ResourceColor = '#CCCCCC';
@State dropZoneText: string = '将图片拖拽到此处';
build() {
Column() {
Text('图片编辑器')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.margin(20)
// ========== 拖拽目标区域 ==========
Column() {
if (this.receivedImageUri) {
// 显示接收到的图片
Image(this.receivedImageUri)
.width('100%')
.height('100%')
.objectFit(ImageFit.Contain)
} else {
// 空状态提示
Column() {
Image($r('app.media.ic_upload'))
.width(80)
.height(80)
.fillColor('#999999')
Text(this.dropZoneText)
.fontSize(16)
.fontColor('#666666')
.margin({ top: 16 })
}
}
}
.width('90%')
.height(400)
.border({
width: 3,
color: this.dropZoneBorderColor,
style: BorderStyle.Dashed
})
.borderRadius(12)
.backgroundColor(this.isDropZoneActive ? '#E8F5E9' : '#FAFAFA')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
// ========== 拖拽进入 ==========
.onDragEnter((event: DragEvent, extraParams: string) => {
hilog.info(DOMAIN, TAG, '拖拽内容进入放置区域');
// 1. 获取数据摘要,判断数据类型
let summary = event.getSummary();
hilog.info(DOMAIN, TAG, `数据摘要: ${JSON.stringify(summary)}`);
// 2. 检查是否包含图片数据
if (summary.dataTypeCount && summary.dataTypeCount['image'] > 0) {
// 可以接收图片
this.isDropZoneActive = true;
this.dropZoneBorderColor = '#4CAF50';
this.dropZoneText = '释放鼠标以添加图片';
} else {
// 不支持的数据类型
this.dropZoneBorderColor = '#F44336';
this.dropZoneText = '仅支持图片格式';
}
})
// ========== 拖拽移动 ==========
.onDragMove((event: DragEvent, extraParams: string) => {
// 获取拖拽位置(可用于显示位置指示器)
let rect = event.getPreviewRect();
hilog.info(DOMAIN, TAG, `拖拽位置: (${rect.x}, ${rect.y})`);
})
// ========== 拖拽离开 ==========
.onDragLeave((event: DragEvent, extraParams: string) => {
hilog.info(DOMAIN, TAG, '拖拽内容离开放置区域');
// 恢复默认状态
this.isDropZoneActive = false;
this.dropZoneBorderColor = '#CCCCCC';
this.dropZoneText = '将图片拖拽到此处';
})
// ========== 释放拖拽 ==========
.onDrop((event: DragEvent, extraParams: string) => {
hilog.info(DOMAIN, TAG, '拖拽内容已释放,开始处理');
try {
// 1. 获取拖拽数据
let unifiedData = event.getData();
let records = unifiedData.getRecords();
hilog.info(DOMAIN, TAG, `接收到 ${records.length} 条数据记录`);
if (records.length > 0) {
let record = records[0];
// 2. 检查数据类型
if (record.getType() === unifiedDataChannel.UnifiedDataType.IMAGE) {
// 3. 解析图片数据
let imageData = record as unifiedDataChannel.Image;
this.receivedImageUri = imageData.imageUri;
hilog.info(DOMAIN, TAG, `接收到图片: ${this.receivedImageUri}`);
// 4. 获取图片元信息
if (imageData.details) {
let details = imageData.details as Record<string, Object>;
hilog.info(DOMAIN, TAG, `图片详情: ${JSON.stringify(details)}`);
}
// 5. 设置接收成功
event.setResult(DragResult.DRAG_SUCCESSFUL);
// 6. 更新UI状态
this.dropZoneText = '图片已添加';
// 7. 显示成功提示
this.showToast('图片接收成功');
} else {
hilog.warn(DOMAIN, TAG, `不支持的数据类型: ${record.getType()}`);
event.setResult(DragResult.DRAG_FAILED);
}
} else {
hilog.warn(DOMAIN, TAG, '没有接收到数据记录');
event.setResult(DragResult.DRAG_FAILED);
}
} catch (error) {
hilog.error(DOMAIN, TAG, `处理拖拽数据失败: ${JSON.stringify(error)}`);
event.setResult(DragResult.DRAG_FAILED);
this.showToast('接收图片失败');
}
// 8. 恢复默认状态
this.isDropZoneActive = false;
this.dropZoneBorderColor = '#CCCCCC';
})
// 清空按钮
if (this.receivedImageUri) {
Button('清空')
.margin({ top: 20 })
.onClick(() => {
this.receivedImageUri = '';
this.dropZoneText = '将图片拖拽到此处';
})
}
}
.width('100%')
.height('100%')
.backgroundColor('#f5f5f5')
.padding(20)
}
private showToast(message: string) {
hilog.info(DOMAIN, TAG, message);
}
}
7.2 文本跨设备拖拽示例
场景说明
从设备A的备忘录拖拽文本到设备B的文本编辑器。
源端代码(设备A - 备忘录)
import { unifiedDataChannel } from '@kit.ArkData';
import { hilog } from '@kit.PerformanceAnalysisKit';
const TAG = '[Memo]';
const DOMAIN = 0xFF00;
@Entry
@Component
struct MemoPage {
@State noteContent: string = '这是一条重要的备忘录内容,可以拖拽到其他设备进行编辑。';
@Builder
textDragPreview() {
Text(this.noteContent.substring(0, 20) + '...')
.fontSize(14)
.fontColor(Color.White)
.padding(12)
.backgroundColor('#2196F3')
.borderRadius(8)
.maxLines(1)
}
build() {
Column() {
Text('我的备忘录')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.margin(20)
// TextArea默认支持拖拽,draggable默认为true
TextArea({ text: this.noteContent })
.width('90%')
.height(200)
.fontSize(16)
.backgroundColor(Color.White)
.borderRadius(8)
.padding(12)
.onChange((value: string) => {
this.noteContent = value;
})
// 自定义拖拽行为(可选,TextArea默认已支持)
.onDragStart((event: DragEvent, extraParams: string) => {
hilog.info(DOMAIN, TAG, '开始拖拽文本');
// 获取选中的文本(如果有)
// 注意:TextArea默认会处理选中文本,这里演示自定义数据
let dragText = this.noteContent;
// 创建文本数据
let unifiedData = new unifiedDataChannel.UnifiedData();
let plainText = new unifiedDataChannel.PlainText();
plainText.textContent = dragText;
plainText.abstract = dragText.substring(0, 50); // 摘要
unifiedData.addRecord(plainText);
event.setData(unifiedData);
hilog.info(DOMAIN, TAG, `拖拽文本长度: ${dragText.length}`);
// 返回自定义背板图
return this.textDragPreview;
})
.onDragEnd((event: DragEvent, extraParams: string) => {
let result = event.getResult();
if (result === DragResult.DRAG_SUCCESSFUL) {
hilog.info(DOMAIN, TAG, '文本拖拽成功');
if (event.dragBehavior === DragBehavior.MOVE) {
// 剪切模式:清空源文本
hilog.info(DOMAIN, TAG, '剪切模式,清空源文本');
// this.noteContent = ''; // 根据需求决定是否清空
}
}
})
Text('提示:长按文本后拖拽到其他设备')
.fontSize(12)
.fontColor('#999999')
.margin({ top: 10 })
}
.width('100%')
.height('100%')
.backgroundColor('#f5f5f5')
}
}
目标端代码(设备B - 文本编辑器)
import { unifiedDataChannel } from '@kit.ArkData';
import { hilog } from '@kit.PerformanceAnalysisKit';
const TAG = '[TextEditor]';
const DOMAIN = 0xFF00;
@Entry
@Component
struct TextEditorPage {
@State editorContent: string = '';
@State borderColor: ResourceColor = '#DDDDDD';
build() {
Column() {
Text('文本编辑器')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.margin(20)
// TextArea默认支持接收文本拖入
TextArea({ text: this.editorContent })
.width('90%')
.height(300)
.fontSize(16)
.backgroundColor(Color.White)
.border({
width: 2,
color: this.borderColor
})
.borderRadius(8)
.padding(12)
.onChange((value: string) => {
this.editorContent = value;
})
// 自定义拖入行为(可选)
.onDragEnter((event: DragEvent, extraParams: string) => {
hilog.info(DOMAIN, TAG, '文本进入编辑区域');
let summary = event.getSummary();
if (summary.dataTypeCount && summary.dataTypeCount['text'] > 0) {
this.borderColor = '#4CAF50';
} else {
this.borderColor = '#F44336';
}
})
.onDragLeave((event: DragEvent, extraParams: string) => {
hilog.info(DOMAIN, TAG, '文本离开编辑区域');
this.borderColor = '#DDDDDD';
})
.onDrop((event: DragEvent, extraParams: string) => {
hilog.info(DOMAIN, TAG, '文本已释放');
try {
let unifiedData = event.getData();
let records = unifiedData.getRecords();
if (records.length > 0) {
let record = records[0];
if (record.getType() === unifiedDataChannel.UnifiedDataType.PLAIN_TEXT) {
let textData = record as unifiedDataChannel.PlainText;
// 追加文本到编辑器
if (this.editorContent) {
this.editorContent += '\n\n' + textData.textContent;
} else {
this.editorContent = textData.textContent;
}
hilog.info(DOMAIN, TAG, `接收文本: ${textData.textContent.substring(0, 50)}...`);
event.setResult(DragResult.DRAG_SUCCESSFUL);
} else {
event.setResult(DragResult.DRAG_FAILED);
}
}
} catch (error) {
hilog.error(DOMAIN, TAG, `处理文本失败: ${JSON.stringify(error)}`);
event.setResult(DragResult.DRAG_FAILED);
}
this.borderColor = '#DDDDDD';
})
Text(`字数: ${this.editorContent.length}`)
.fontSize(14)
.fontColor('#666666')
.margin({ top: 10 })
}
.width('100%')
.height('100%')
.backgroundColor('#f5f5f5')
}
}
7.3 文件跨设备拖拽示例
源端代码(设备A - 文件管理器)
import { unifiedDataChannel } from '@kit.ArkData';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { fileIo } from '@kit.CoreFileKit';
const TAG = '[FileManager]';
const DOMAIN = 0xFF00;
interface FileItem {
name: string;
uri: string;
size: number;
type: string;
}
@Entry
@Component
struct FileManagerPage {
@State fileList: FileItem[] = [
{ name: 'document.pdf', uri: 'file://data/document.pdf', size: 2048000, type: 'application/pdf' },
{ name: 'report.docx', uri: 'file://data/report.docx', size: 512000, type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' },
{ name: 'data.xlsx', uri: 'file://data/data.xlsx', size: 1024000, type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }
];
@Builder
fileDragPreview(fileItem: FileItem) {
Row() {
Image(this.getFileIcon(fileItem.type))
.width(40)
.height(40)
Column() {
Text(fileItem.name)
.fontSize(14)
.fontColor(Color.White)
.maxLines(1)
Text(this.formatFileSize(fileItem.size))
.fontSize(12)
.fontColor('#CCCCCC')
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 8 })
}
.padding(12)
.backgroundColor('#607D8B')
.borderRadius(8)
}
build() {
Column() {
Text('文件管理器')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.margin(20)
List() {
ForEach(this.fileList, (file: FileItem, index: number) => {
ListItem() {
Row() {
Image(this.getFileIcon(file.type))
.width(40)
.height(40)
Column() {
Text(file.name)
.fontSize(16)
.fontWeight(FontWeight.Medium)
Text(this.formatFileSize(file.size))
.fontSize(12)
.fontColor('#999999')
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 12 })
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(Color.White)
.borderRadius(8)
.draggable(true)
.onDragStart((event: DragEvent, extraParams: string) => {
hilog.info(DOMAIN, TAG, `开始拖拽文件: ${file.name}`);
// 创建文件数据
let unifiedData = new unifiedDataChannel.UnifiedData();
let fileData = new unifiedDataChannel.File();
fileData.uri = file.uri;
fileData.name = file.name;
fileData.details = {
size: file.size,
mimeType: file.type,
modifiedTime: Date.now()
};
unifiedData.addRecord(fileData);
event.setData(unifiedData);
return this.fileDragPreview(file);
})
.onDragEnd((event: DragEvent, extraParams: string) => {
if (event.getResult() === DragResult.DRAG_SUCCESSFUL) {
hilog.info(DOMAIN, TAG, `文件 ${file.name} 拖拽成功`);
if (event.dragBehavior === DragBehavior.MOVE) {
// 剪切模式:从列表移除
this.fileList.splice(index, 1);
}
}
})
}
.margin({ bottom: 8 })
})
}
.width('90%')
.layoutWeight(1)
}
.width('100%')
.height('100%')
.backgroundColor('#f5f5f5')
}
private getFileIcon(mimeType: string): Resource {
if (mimeType.includes('pdf')) {
return $r('app.media.ic_pdf');
} else if (mimeType.includes('word')) {
return $r('app.media.ic_word');
} else if (mimeType.includes('excel') || mimeType.includes('spreadsheet')) {
return $r('app.media.ic_excel');
} else {
return $r('app.media.ic_file');
}
}
private formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
}
目标端代码(设备B - 文档查看器)
import { unifiedDataChannel } from '@kit.ArkData';
import { hilog } from '@kit.PerformanceAnalysisKit';
const TAG = '[DocumentViewer]';
const DOMAIN = 0xFF00;
@Entry
@Component
struct DocumentViewerPage {
@State receivedFiles: Array<{ name: string, uri: string, size: number }> = [];
@State dropZoneBorderColor: ResourceColor = '#DDDDDD';
build() {
Column() {
Text('文档查看器')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.margin(20)
// 拖拽接收区域
Column() {
if (this.receivedFiles.length > 0) {
List() {
ForEach(this.receivedFiles, (file: { name: string, uri: string, size: number }) => {
ListItem() {
Row() {
Image($r('app.media.ic_file'))
.width(40)
.height(40)
Column() {
Text(file.name)
.fontSize(16)
Text(`${(file.size / 1024).toFixed(1)} KB`)
.fontSize(12)
.fontColor('#999999')
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 12 })
}
.width('100%')
.padding(12)
}
})
}
} else {
Column() {
Image($r('app.media.ic_folder'))
.width(80)
.height(80)
.fillColor('#999999')
Text('拖拽文件到此处')
.fontSize(16)
.fontColor('#666666')
.margin({ top: 16 })
}
}
}
.width('90%')
.height(400)
.border({
width: 2,
color: this.dropZoneBorderColor,
style: BorderStyle.Dashed
})
.borderRadius(12)
.backgroundColor('#FAFAFA')
.justifyContent(FlexAlign.Center)
.onDragEnter((event: DragEvent, extraParams: string) => {
let summary = event.getSummary();
if (summary.dataTypeCount && summary.dataTypeCount['file'] > 0) {
this.dropZoneBorderColor = '#4CAF50';
}
})
.onDragLeave((event: DragEvent, extraParams: string) => {
this.dropZoneBorderColor = '#DDDDDD';
})
.onDrop((event: DragEvent, extraParams: string) => {
try {
let unifiedData = event.getData();
let records = unifiedData.getRecords();
for (let record of records) {
if (record.getType() === unifiedDataChannel.UnifiedDataType.FILE) {
let fileData = record as unifiedDataChannel.File;
this.receivedFiles.push({
name: fileData.name || 'unknown',
uri: fileData.uri,
size: (fileData.details as Record<string, number>)?.size || 0
});
hilog.info(DOMAIN, TAG, `接收文件: ${fileData.name}`);
}
}
event.setResult(DragResult.DRAG_SUCCESSFUL);
} catch (error) {
hilog.error(DOMAIN, TAG, `处理文件失败: ${JSON.stringify(error)}`);
event.setResult(DragResult.DRAG_FAILED);
}
this.dropZoneBorderColor = '#DDDDDD';
})
}
.width('100%')
.height('100%')
.backgroundColor('#f5f5f5')
}
}
八、高级特性
8.1 自定义拖拽动效
禁用系统默认动效,实现自定义落位动画:
Column()
.onDrop((event: DragEvent, extraParams: string) => {
// 禁用系统默认落位动效
event.useCustomDropAnimation = true;
// 处理数据
let unifiedData = event.getData();
// ... 数据处理逻辑
// 自定义落位动画
animateTo({
duration: 300,
curve: Curve.EaseInOut
}, () => {
this.itemOpacity = 1;
this.itemScale = 1;
});
event.setResult(DragResult.DRAG_SUCCESSFUL);
})
8.2 切换复制/剪切模式
用户可通过键盘控制拖拽行为:
.onDragStart((event: DragEvent, extraParams: string) => {
// 设置拖拽数据
let unifiedData = new unifiedDataChannel.UnifiedData();
// ... 添加数据
event.setData(unifiedData);
// 用户按住Ctrl键时为复制模式,否则为移动模式
// 系统会自动根据按键设置dragBehavior
// 开发者无需手动设置
return this.dragPreviewBuilder;
})
.onDragEnd((event: DragEvent, extraParams: string) => {
if (event.getResult() === DragResult.DRAG_SUCCESSFUL) {
// 根据dragBehavior决定是否删除源数据
if (event.dragBehavior === DragBehavior.COPY) {
// 复制模式:保留源数据
console.log('复制模式,保留原数据');
} else if (event.dragBehavior === DragBehavior.MOVE) {
// 移动模式:删除源数据
console.log('移动模式,删除原数据');
this.deleteSourceData();
}
}
})
用户操作:
- 普通拖拽:移动模式(MOVE),目标端接收后源端可删除
- Ctrl + 拖拽:复制模式(COPY),显示"+"角标,源端保留数据
8.3 多数据类型拖拽
同时拖拽多种类型的数据:
.onDragStart((event: DragEvent, extraParams: string) => {
let unifiedData = new unifiedDataChannel.UnifiedData();
// 添加文本数据
let plainText = new unifiedDataChannel.PlainText();
plainText.textContent = '产品介绍文字';
unifiedData.addRecord(plainText);
// 添加图片数据
let image = new unifiedDataChannel.Image();
image.imageUri = 'file://path/to/product.jpg';
unifiedData.addRecord(image);
// 添加链接数据
let hyperlink = new unifiedDataChannel.Hyperlink();
hyperlink.url = 'https://example.com/product';
hyperlink.description = '产品详情链接';
unifiedData.addRecord(hyperlink);
event.setData(unifiedData);
return this.dragPreviewBuilder;
})
// 目标端根据需要选择接收哪种类型
.onDrop((event: DragEvent, extraParams: string) => {
let unifiedData = event.getData();
let records = unifiedData.getRecords();
for (let record of records) {
let type = record.getType();
switch (type) {
case unifiedDataChannel.UnifiedDataType.PLAIN_TEXT:
let textData = record as unifiedDataChannel.PlainText;
this.handleText(textData.textContent);
break;
case unifiedDataChannel.UnifiedDataType.IMAGE:
let imageData = record as unifiedDataChannel.Image;
this.handleImage(imageData.imageUri);
break;
case unifiedDataChannel.UnifiedDataType.HYPERLINK:
let linkData = record as unifiedDataChannel.Hyperlink;
this.handleLink(linkData.url);
break;
}
}
event.setResult(DragResult.DRAG_SUCCESSFUL);
})
8.4 条件接收拖拽
根据业务逻辑判断是否接收:
Column()
.onDragEnter((event: DragEvent, extraParams: string) => {
let summary = event.getSummary();
// 检查文件数量限制
if (summary.totalCount > 5) {
this.showError('最多只能拖入5个文件');
this.canAccept = false;
return;
}
// 检查文件类型
if (summary.dataTypeCount['image']) {
this.canAccept = true;
this.dropZoneBorderColor = Color.Green;
} else {
this.canAccept = false;
this.dropZoneBorderColor = Color.Red;
this.showError('仅支持图片格式');
}
})
.onDrop((event: DragEvent, extraParams: string) => {
if (!this.canAccept) {
event.setResult(DragResult.DRAG_FAILED);
return;
}
// 继续处理...
event.setResult(DragResult.DRAG_SUCCESSFUL);
})
8.5 使用DragItemInfo增强拖拽信息
import { image } from '@kit.ImageKit';
.onDragStart(async (event: DragEvent, extraParams: string) => {
// 创建拖拽预览的PixelMap
let pixelMap = await this.createDragPreviewPixelMap();
// 使用DragItemInfo提供更多信息
let dragItemInfo: DragItemInfo = {
pixelMap: pixelMap,
extraInfo: JSON.stringify({
itemId: '12345',
itemType: 'product',
category: 'electronics',
price: 999.99
})
};
// 设置拖拽数据
let unifiedData = new unifiedDataChannel.UnifiedData();
// ... 添加数据
event.setData(unifiedData);
return dragItemInfo;
})
// 目标端解析extraInfo
.onDrop((event: DragEvent, extraParams: string) => {
if (extraParams) {
let extra = JSON.parse(extraParams);
console.log(`商品ID: ${extra.itemId}`);
console.log(`商品类别: ${extra.category}`);
console.log(`价格: ${extra.price}`);
}
// 处理数据...
})
private async createDragPreviewPixelMap(): Promise<image.PixelMap> {
// 创建拖拽预览图的PixelMap
// 实现细节...
return pixelMap;
}
九、常见问题与解决方案
9.1 拖拽无法触发
问题:长按组件后无法触发拖拽。
可能原因及解决方案:
-
draggable属性未设置
// ❌ 错误 Column().onDragStart(() => {}) // ✅ 正确 Column() .draggable(true) // 必须设置 .onDragStart(() => {}) -
长按时间或移动距离不足
- 长按时间需要 ≥ 500ms
- 手指移动距离需要 ≥ 10vp
解决方案:确保长按足够时间且有明显移动
-
设备不支持
- 仅支持平板和2in1设备
- 系统版本需HarmonyOS NEXT Developer Preview0及以上
-
键鼠穿越未启用
- 检查设置 → 连接与共享 → 键鼠穿越是否开启
9.2 跨设备拖拽失败
问题:拖拽到另一台设备时无法识别或接收失败。
解决方案:
-
检查网络连接
// 确保两台设备: // - 登录同一华为账号 // - 打开Wi-Fi和蓝牙 // - 接入同一局域网 -
检查数据格式
// 确保使用UnifiedData格式 let unifiedData = new unifiedDataChannel.UnifiedData(); let textData = new unifiedDataChannel.PlainText(); textData.textContent = 'content'; unifiedData.addRecord(textData); event.setData(unifiedData); -
检查目标端是否正确实现onDrop
Column() .onDrop((event: DragEvent, extraParams: string) => { // 必须调用setResult event.setResult(DragResult.DRAG_SUCCESSFUL); })
9.3 数据接收不完整
问题:目标端接收到的数据不完整或为空。
解决方案:
-
检查数据设置时机
// ❌ 错误:数据设置在return之后 .onDragStart((event: DragEvent, extraParams: string) => { return this.dragPreviewBuilder; // 这行代码不会执行 event.setData(unifiedData); }) // ✅ 正确:数据设置在return之前 .onDragStart((event: DragEvent, extraParams: string) => { event.setData(unifiedData); return this.dragPreviewBuilder; }) -
检查数据类型匹配
// 源端 let image = new unifiedDataChannel.Image(); image.imageUri = 'file://path/to/image.jpg'; unifiedData.addRecord(image); // 目标端:使用正确的类型检查 if (record.getType() === unifiedDataChannel.UnifiedDataType.IMAGE) { let imageData = record as unifiedDataChannel.Image; // 正确处理 } -
处理异步数据
.onDragStart(async (event: DragEvent, extraParams: string) => { // 如果数据需要异步加载 let data = await this.loadDragData(); let unifiedData = new unifiedDataChannel.UnifiedData(); // ... 设置数据 event.setData(unifiedData); return this.dragPreviewBuilder; })
9.4 拖拽卡顿或延迟
问题:拖拽过程中出现卡顿或明显延迟。
解决方案:
-
控制拖拽数据大小
// 避免传输大文件数据 // 对于大文件,仅传输URI引用 let fileData = new unifiedDataChannel.File(); fileData.uri = 'file://path/to/large_file.mp4'; fileData.name = 'video.mp4'; // 不要将整个文件内容放入数据中 -
优化背板图生成
@Builder lightweightDragPreview() { // 使用简单的UI组件,避免复杂布局 Text('拖拽中') .fontSize(14) .padding(8) .backgroundColor('#4CAF50') .borderRadius(4) } // 而不是: // - 复杂的Column/Row嵌套 // - 大量Image组件 // - 动画效果 -
减少onDragMove中的操作
.onDragMove((event: DragEvent, extraParams: string) => { // ❌ 避免频繁的UI更新 // this.updateComplexUI(); // ✅ 仅做必要的轻量级操作 let rect = event.getPreviewRect(); this.dragX = rect.x; this.dragY = rect.y; })
9.5 背板图不显示
问题:拖拽时自定义的背板图不显示。
解决方案:
-
检查Builder语法
// ✅ 正确的Builder定义 @Builder dragPreviewBuilder() { Column() { Text('拖拽中') } } // ✅ 正确的返回方式 .onDragStart((event: DragEvent, extraParams: string) => { return this.dragPreviewBuilder; // 不要加括号 }) -
文本选中拖拽的限制
// ⚠️ 注意:TextInput、TextArea等文本组件 // 的选中文本拖拽不支持自定义背板图 // 系统会使用默认的文本拖拽效果 -
检查Builder UI是否有效
@Builder dragPreviewBuilder() { // 确保有可见内容 Column() { Text('拖拽中') .fontSize(16) // 确保字体大小可见 .fontColor(Color.White) // 确保颜色对比度 } .width(100) // 确保有尺寸 .height(100) .backgroundColor('#4CAF50') // 确保有背景色 }
9.6 剪切模式下源数据未删除
问题:移动模式拖拽成功后,源端数据仍然存在。
解决方案:
.onDragEnd((event: DragEvent, extraParams: string) => {
// 1. 检查拖拽是否成功
if (event.getResult() !== DragResult.DRAG_SUCCESSFUL) {
return;
}
// 2. 检查拖拽行为模式
if (event.dragBehavior === DragBehavior.MOVE) {
// 3. 删除源数据
console.log('剪切模式:删除源数据');
this.deleteSourceData();
} else {
console.log('复制模式:保留源数据');
}
})
private deleteSourceData() {
// 实现删除逻辑
// 例如:从数组中移除、删除文件等
}
十、性能优化建议
10.1 数据传输优化
// ❌ 低效:传输完整文件内容
let fileData = new unifiedDataChannel.File();
fileData.fileContent = largeFileBuffer; // 几MB的数据
// ✅ 高效:仅传输文件URI
let fileData = new unifiedDataChannel.File();
fileData.uri = 'file://path/to/large_file.pdf';
fileData.name = 'document.pdf';
fileData.details = {
size: 5242880, // 5MB
mimeType: 'application/pdf'
};
10.2 背板图优化
// ❌ 低效:复杂的背板图
@Builder
heavyDragPreview() {
Column() {
ForEach(this.items, (item) => { // 大量循环
Image(item.image) // 多个图片
.width(50)
.height(50)
})
}
.width(300)
.height(300)
.shadow(...) // 复杂效果
.blur(10)
}
// ✅ 高效:简化的背板图
@Builder
lightDragPreview() {
Row() {
Image(this.selectedItem.icon)
.width(40)
.height(40)
Text(this.selectedItem.name)
.fontSize(14)
.margin({ left: 8 })
}
.padding(8)
.backgroundColor('#4CAF50')
.borderRadius(4)
}
10.3 事件处理优化
// 使用防抖减少频繁操作
private dragMoveThrottle: number = 0;
.onDragMove((event: DragEvent, extraParams: string) => {
let now = Date.now();
if (now - this.dragMoveThrottle < 16) { // 约60fps
return;
}
this.dragMoveThrottle = now;
// 执行操作
let rect = event.getPreviewRect();
this.updatePosition(rect.x, rect.y);
})
10.4 内存管理
.onDragEnd((event: DragEvent, extraParams: string) => {
// 清理拖拽相关的临时资源
this.clearDragPreviewCache();
this.dragData = null;
this.selectedItems = [];
// 释放PixelMap等大对象
if (this.previewPixelMap) {
this.previewPixelMap.release();
this.previewPixelMap = null;
}
})
十一、测试与调试
11.1 日志输出
import { hilog } from '@kit.PerformanceAnalysisKit';
const TAG = '[DragTest]';
const DOMAIN = 0xFF00;
// 源端日志
.onDragStart((event: DragEvent, extraParams: string) => {
hilog.info(DOMAIN, TAG, '[源端] 拖拽开始');
hilog.info(DOMAIN, TAG, `[源端] extraParams: ${extraParams}`);
let unifiedData = new unifiedDataChannel.UnifiedData();
// ... 设置数据
hilog.info(DOMAIN, TAG, `[源端] 数据已设置,记录数: ${unifiedData.getRecords().length}`);
event.setData(unifiedData);
return this.dragPreviewBuilder;
})
.onDragEnd((event: DragEvent, extraParams: string) => {
let result = event.getResult();
let behavior = event.dragBehavior;
hilog.info(DOMAIN, TAG, `[源端] 拖拽结束`);
hilog.info(DOMAIN, TAG, `[源端] 结果: ${result}`);
hilog.info(DOMAIN, TAG, `[源端] 行为: ${behavior === DragBehavior.COPY ? '复制' : '移动'}`);
})
// 目标端日志
.onDragEnter((event: DragEvent, extraParams: string) => {
let summary = event.getSummary();
hilog.info(DOMAIN, TAG, '[目标端] 拖拽进入');
hilog.info(DOMAIN, TAG, `[目标端] 数据摘要: ${JSON.stringify(summary)}`);
})
.onDrop((event: DragEvent, extraParams: string) => {
hilog.info(DOMAIN, TAG, '[目标端] 拖拽释放');
try {
let unifiedData = event.getData();
let records = unifiedData.getRecords();
hilog.info(DOMAIN, TAG, `[目标端] 接收到 ${records.length} 条记录`);
records.forEach((record, index) => {
hilog.info(DOMAIN, TAG, `[目标端] 记录${index}: 类型=${record.getType()}`);
});
event.setResult(DragResult.DRAG_SUCCESSFUL);
hilog.info(DOMAIN, TAG, '[目标端] 处理成功');
} catch (error) {
hilog.error(DOMAIN, TAG, `[目标端] 处理失败: ${JSON.stringify(error)}`);
event.setResult(DragResult.DRAG_FAILED);
}
})
11.2 测试清单
基础功能测试
- 单设备内拖拽测试
- 跨设备拖拽测试
- 文本拖拽测试
- 图片拖拽测试
- 文件拖拽测试
- 多数据类型拖拽测试
交互测试
- 长按500ms触发测试
- 移动10vp触发测试
- 拖拽进入/离开视觉反馈测试
- 释放时数据接收测试
- 拖拽取消测试(ESC键)
行为模式测试
- 复制模式测试(Ctrl+拖拽)
- 移动模式测试(普通拖拽)
- 源数据保留/删除测试
兼容性测试
- 不同版本应用间拖拽测试
- 不同数据格式兼容性测试
- 大数据量传输测试
异常场景测试
- 网络断开时拖拽测试
- 目标端拒绝接收测试
- 数据格式错误处理测试
- 内存不足场景测试
性能测试
- 拖拽响应时间测试(<100ms)
- 数据传输时间测试
- 背板图渲染性能测试
- 内存占用测试
十二、最佳实践总结
12.1 开发建议
-
优先使用系统默认能力
- Text、Image、TextInput等组件已默认支持拖拽
- 除非有特殊需求,否则无需自定义onDragStart
-
合理设计数据结构
- 使用UnifiedData统一数据格式
- 为数据添加详细的元信息(details)
- 大文件仅传输URI,不传输完整内容
-
提供良好的视觉反馈
- 拖拽进入时高亮目标区域
- 拖拽离开时恢复默认状态
- 使用清晰的背板图提示拖拽内容
-
处理所有可能的结果
- 在onDragEnd中检查所有可能的DragResult
- 根据dragBehavior正确处理源数据
- 提供用户友好的错误提示
-
注重性能优化
- 简化背板图UI结构
- 控制拖拽数据大小
- 避免在onDragMove中执行重操作
12.2 用户体验建议
-
提供明确的拖拽提示
- 使用图标或文字提示可拖拽元素
- 在目标区域显示"拖拽到此处"等提示
- 用边框、阴影等视觉元素突出可接收区域
-
支持拖拽取消
- 用户按ESC键时正确处理取消逻辑
- 拖拽到无效区域时提供反馈
-
提供操作结果反馈
- 拖拽成功后显示Toast或动画提示
- 拖拽失败时说明失败原因
- 复制/移动模式有明确的视觉区分
十三、总结
跨设备拖拽是HarmonyOS提供的强大分布式协同能力,通过键鼠穿越和统一数据格式,实现了设备间的无缝内容传输。
核心要点回顾:
-
前置条件
- 平板或2in1设备
- 同一华为账号
- Wi-Fi + 蓝牙 + 同一局域网
- 启用键鼠穿越
-
核心API
onDragStart:准备数据和背板图onDragEnter/Move/Leave:视觉反馈onDrop:接收和处理数据onDragEnd:获取结果和清理
-
数据格式
- 使用UnifiedData统一格式
- 支持文本、图片、文件等多种类型
- 大数据传输URI,小数据传输内容
-
拖拽行为
- 移动模式(MOVE):默认,源端可删除
- 复制模式(COPY):Ctrl+拖拽,源端保留
-
性能优化
- 简化背板图
- 控制数据大小
- 优化事件处理
- 及时释放资源