鸿蒙跨设备拖拽开发指南

59 阅读12分钟

鸿蒙跨设备拖拽开发指南

一、概述

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 自定义组件拖拽

对于其他组件,需要:

  1. 手动设置 draggable(true) 属性
  2. 实现 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 拖拽无法触发

问题:长按组件后无法触发拖拽。

可能原因及解决方案:

  1. draggable属性未设置

    // ❌ 错误
    Column().onDragStart(() => {})
    
    // ✅ 正确
    Column()
      .draggable(true)  // 必须设置
      .onDragStart(() => {})
    
  2. 长按时间或移动距离不足

    • 长按时间需要 ≥ 500ms
    • 手指移动距离需要 ≥ 10vp

    解决方案:确保长按足够时间且有明显移动

  3. 设备不支持

    • 仅支持平板和2in1设备
    • 系统版本需HarmonyOS NEXT Developer Preview0及以上
  4. 键鼠穿越未启用

    • 检查设置 → 连接与共享 → 键鼠穿越是否开启

9.2 跨设备拖拽失败

问题:拖拽到另一台设备时无法识别或接收失败。

解决方案:

  1. 检查网络连接

    // 确保两台设备:
    // - 登录同一华为账号
    // - 打开Wi-Fi和蓝牙
    // - 接入同一局域网
    
  2. 检查数据格式

    // 确保使用UnifiedData格式
    let unifiedData = new unifiedDataChannel.UnifiedData();
    let textData = new unifiedDataChannel.PlainText();
    textData.textContent = 'content';
    unifiedData.addRecord(textData);
    event.setData(unifiedData);
    
  3. 检查目标端是否正确实现onDrop

    Column()
      .onDrop((event: DragEvent, extraParams: string) => {
        // 必须调用setResult
        event.setResult(DragResult.DRAG_SUCCESSFUL);
      })
    

9.3 数据接收不完整

问题:目标端接收到的数据不完整或为空。

解决方案:

  1. 检查数据设置时机

    // ❌ 错误:数据设置在return之后
    .onDragStart((event: DragEvent, extraParams: string) => {
      return this.dragPreviewBuilder;
      // 这行代码不会执行
      event.setData(unifiedData);
    })
    
    // ✅ 正确:数据设置在return之前
    .onDragStart((event: DragEvent, extraParams: string) => {
      event.setData(unifiedData);
      return this.dragPreviewBuilder;
    })
    
  2. 检查数据类型匹配

    // 源端
    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;
      // 正确处理
    }
    
  3. 处理异步数据

    .onDragStart(async (event: DragEvent, extraParams: string) => {
      // 如果数据需要异步加载
      let data = await this.loadDragData();
    
      let unifiedData = new unifiedDataChannel.UnifiedData();
      // ... 设置数据
      event.setData(unifiedData);
    
      return this.dragPreviewBuilder;
    })
    

9.4 拖拽卡顿或延迟

问题:拖拽过程中出现卡顿或明显延迟。

解决方案:

  1. 控制拖拽数据大小

    // 避免传输大文件数据
    // 对于大文件,仅传输URI引用
    let fileData = new unifiedDataChannel.File();
    fileData.uri = 'file://path/to/large_file.mp4';
    fileData.name = 'video.mp4';
    // 不要将整个文件内容放入数据中
    
  2. 优化背板图生成

    @Builder
    lightweightDragPreview() {
      // 使用简单的UI组件,避免复杂布局
      Text('拖拽中')
        .fontSize(14)
        .padding(8)
        .backgroundColor('#4CAF50')
        .borderRadius(4)
    }
    
    // 而不是:
    // - 复杂的Column/Row嵌套
    // - 大量Image组件
    // - 动画效果
    
  3. 减少onDragMove中的操作

    .onDragMove((event: DragEvent, extraParams: string) => {
      // ❌ 避免频繁的UI更新
      // this.updateComplexUI();
    
      // ✅ 仅做必要的轻量级操作
      let rect = event.getPreviewRect();
      this.dragX = rect.x;
      this.dragY = rect.y;
    })
    

9.5 背板图不显示

问题:拖拽时自定义的背板图不显示。

解决方案:

  1. 检查Builder语法

    // ✅ 正确的Builder定义
    @Builder
    dragPreviewBuilder() {
      Column() {
        Text('拖拽中')
      }
    }
    
    // ✅ 正确的返回方式
    .onDragStart((event: DragEvent, extraParams: string) => {
      return this.dragPreviewBuilder;  // 不要加括号
    })
    
  2. 文本选中拖拽的限制

    // ⚠️ 注意:TextInput、TextArea等文本组件
    // 的选中文本拖拽不支持自定义背板图
    // 系统会使用默认的文本拖拽效果
    
  3. 检查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 开发建议

  1. 优先使用系统默认能力

    • Text、Image、TextInput等组件已默认支持拖拽
    • 除非有特殊需求,否则无需自定义onDragStart
  2. 合理设计数据结构

    • 使用UnifiedData统一数据格式
    • 为数据添加详细的元信息(details)
    • 大文件仅传输URI,不传输完整内容
  3. 提供良好的视觉反馈

    • 拖拽进入时高亮目标区域
    • 拖拽离开时恢复默认状态
    • 使用清晰的背板图提示拖拽内容
  4. 处理所有可能的结果

    • 在onDragEnd中检查所有可能的DragResult
    • 根据dragBehavior正确处理源数据
    • 提供用户友好的错误提示
  5. 注重性能优化

    • 简化背板图UI结构
    • 控制拖拽数据大小
    • 避免在onDragMove中执行重操作

12.2 用户体验建议

  1. 提供明确的拖拽提示

    • 使用图标或文字提示可拖拽元素
    • 在目标区域显示"拖拽到此处"等提示
    • 用边框、阴影等视觉元素突出可接收区域
  2. 支持拖拽取消

    • 用户按ESC键时正确处理取消逻辑
    • 拖拽到无效区域时提供反馈
  3. 提供操作结果反馈

    • 拖拽成功后显示Toast或动画提示
    • 拖拽失败时说明失败原因
    • 复制/移动模式有明确的视觉区分

十三、总结

跨设备拖拽是HarmonyOS提供的强大分布式协同能力,通过键鼠穿越和统一数据格式,实现了设备间的无缝内容传输。

核心要点回顾:

  1. 前置条件

    • 平板或2in1设备
    • 同一华为账号
    • Wi-Fi + 蓝牙 + 同一局域网
    • 启用键鼠穿越
  2. 核心API

    • onDragStart:准备数据和背板图
    • onDragEnter/Move/Leave:视觉反馈
    • onDrop:接收和处理数据
    • onDragEnd:获取结果和清理
  3. 数据格式

    • 使用UnifiedData统一格式
    • 支持文本、图片、文件等多种类型
    • 大数据传输URI,小数据传输内容
  4. 拖拽行为

    • 移动模式(MOVE):默认,源端可删除
    • 复制模式(COPY):Ctrl+拖拽,源端保留
  5. 性能优化

    • 简化背板图
    • 控制数据大小
    • 优化事件处理
    • 及时释放资源