打破边界:深入解析HarmonyOS统一拖拽实现

72 阅读13分钟

在人机交互的演进史中,“拖拽”(Drag & Drop)始终占据着重要的一席之地。它不仅是一种直观的手势操作——用户只需轻轻按住并移动,即可完成数据的传输——更是一种打破应用、设备边界的哲学。

在HarmonyOS生态中,拖拽被赋予了全新的生命力。通过统一数据管理框架(Unified Data Management Framework, UDMF),鸿蒙不仅实现了应用内的流畅交互,更打通了跨应用、跨窗口甚至跨设备的“任督二脉”。从简单的文字复制,到复杂的AI识图、多端协同,统一拖拽正在重塑用户的操作体验。

这篇文章会把“统一拖拽”拆成可落地的工程套路:先把UDMF的数据模型讲透,再按“发起拖拽—拖拽中—释放拖拽”的完整链路,给出一组可以直接复用的端到端代码示例(包含关键API、参数配置、错误处理、跨设备协同要点),最后补上常见故障的排查清单,帮助你把“能拖起来”推进到“上线可用、跨端可用”。


一、 技术基石:UDMF与标准化通路

拖拽的核心在于“数据流动”。为了让不同应用、不同设备都能“听懂”彼此传输的数据,HarmonyOS引入了UDMF

简单来说,UDMF就像是数据传输的“通用翻译官”。

  • UnifiedData(统一数据对象):封装了拖拽过程中的所有数据。
  • UnifiedRecord(数据记录):数据单元,比如一张图片、一段HTML。
  • Entry(同一内容的多种表达):UnifiedRecord允许在一条记录里携带同一内容的不同表现形式。你可以先用构造函数创建一个主Entry,再通过addEntry()追加其他类型的Entry。比如对同一段文字,同时放入plain-texttext/html,接收方按需选择最合适的格式解析和展示。

这一点非常关键:统一拖拽不是“把一段数据塞过去”那么简单,而是把数据包装成标准结构,让不同能力的接收方都能从中“取到自己能吃的那一口”。这也是图文混排、多端协同、跨应用交换数据能稳定工作的底层原因。

拖拽三部曲

任何拖拽交互都离不开这三个标准动作:

  1. 发起(Start):拖出方在组件上开启拖拽能力(默认可拖出的组件除外),并在onDragStart()里把业务数据封装成UnifiedData,再通过event.setData()挂到拖拽事件上。
  2. 过程(During):系统默认使用组件截图作为拖拽背板图;你也可以在onDragStart()返回DragItemInfo自定义背板,或通过onDragMove()实时读取拖拽坐标等信息。
  3. 释放(Drop):落入方用allowDrop()声明可接收的数据类型,在onDrop()里通过event.getData()解包UnifiedData,读取UnifiedRecord集合并刷新UI;对“文件类数据”推荐使用startDataLoading()完成安全拷贝与进度监听。

需要特别记住两条“工程级”规则:

  • allowDrop()只是“类型声明”,真正能否落位取决于onDrop()是否正确解析数据并更新UI。
  • 只有注册了onDrop(),拖拽在组件范围内移动时才会触发onDragMove()

二、 场景实战:从入门到精通

纸上得来终觉浅,我们通过几个高频场景来看看代码是如何落地的。

image.png

0. 发起拖拽:默认拖出与自定义拖出怎么选

鸿蒙并不是“所有组件都必须手写拖拽”。一些组件默认支持拖出能力,会走系统默认的拖出响应:

  • Search组件默认拖拽内容是选中的文字;
  • Hyperlink组件默认拖拽内容是超链接地址。

当默认行为能满足需求时,直接用默认拖出即可;当你需要携带自定义数据(例如富文本多Record、图片水印后的FileUri、业务字段等),就应该在onDragStart()里封装UnifiedData,并通过event.setData()设置拖拽数据。

对于非默认可拖出的组件或自定义组件,还需要显式开启拖拽能力:

Image($rawfile('river.png'))
  .draggable(true)

下面给出一个“自定义起拖”的骨架:在onDragStart()里构造UnifiedData,并用try/catch兜底错误;这种写法在跨应用、跨设备场景里同样适用,因为系统能力吃的是UDMF标准结构。

RichEditor({ controller: this.sourceController })
  .onDragStart((event: DragEvent) => {
    try {
      const selection = this.sourceController.getSelection()
      this.buildUnifiedRecords(selection)
      event.setData(this.unifiedData)
    } catch (error) {
      const err = error as BusinessError
      hilog.error(0x0000, TAG, `${err.code} ${err.message}`)
    }
  })

1. 先把“最小可用”跑通:纯文本拖出与拖入

很多拖拽问题的根源不是“UDMF不好用”,而是第一版就把文件、图片、富文本、跨端全堆上去,导致链路一旦断点就难以定位。建议从纯文本起步:只要你能稳定拿到UnifiedData、遍历records、按type读取Entry、刷新UI,就等于打通了拖拽基本功。

下面是一个极简但“工程可用”的落入示例:组件声明支持TEXTPLAIN_TEXT,在onDrop里读取并赋值;同时给出错误处理与落位结果设置。

Column() {
  Text(this.dropContent)
}
.allowDrop([
  uniformTypeDescriptor.UniformDataType.TEXT,
  uniformTypeDescriptor.UniformDataType.PLAIN_TEXT
])
.onDrop((event: DragEvent) => {
  try {
    const dragData = event.getData() as unifiedDataChannel.UnifiedData
    const records = dragData.getRecords()
    for (const record of records) {
      const types = record.getTypes()
      if (!types.includes(uniformTypeDescriptor.UniformDataType.PLAIN_TEXT)) {
        continue
      }
      const plainTextUds = record.getEntry(
        uniformTypeDescriptor.UniformDataType.PLAIN_TEXT
      ) as uniformDataStruct.PlainText
      this.dropContent = plainTextUds.textContent
      event.setResult(DragResult.DRAG_SUCCESSFUL)
      return
    }
  } catch (error) {
    const err = error as BusinessError
    hilog.error(0x0000, TAG, `onDrop error, code: ${err.code}, message: ${err.message}`)
  }
})

如果你遇到“文字无法长按选中后再起拖”,记得为文字组件配置copyOptions,否则长按选中逻辑不会触发,自然也就起不来拖拽。

2. 视觉魔法(上):自定义拖拽背板图(优先PixelMap)

系统默认会把组件截图作为拖拽背板图。如果你想让背板展示自定义内容(比如“正在拖拽:xxx”),可以在onDragStart()返回DragItemInfo。其中pixelMapbuilder都能实现效果,但builder需要离线渲染,存在性能开销与时延,工程上更推荐提前准备pixelMap

下面这个示例包含三个要点:

  1. 使用@Builder声明背板UI;
  2. 通过getComponentSnapshot().createFromBuilder()离线渲染成pixelMap
  3. onPreDrag()提前生成,避免真正拖拽时“现做现等”。
@Builder
pixelMapBuilder() {
  Column() {
    Text($r('app.string.background_content'))
      .fontSize('16fp')
      .margin({ left: '16vp', right: '16vp', top: '8vp', bottom: '8vp' })
  }
  .backgroundColor($r('sys.color.comp_background_primary'))
  .borderRadius(16)
}

private pixelMap?: image.PixelMap

private getComponentSnapshot(): void {
  this.getUIContext().getComponentSnapshot().createFromBuilder(() => {
    this.pixelMapBuilder()
  }, (error: Error, pixmap: image.PixelMap) => {
    if (error) {
      hilog.error(0x0000, TAG, `createFromBuilder failed: ${error.message}`)
      return
    }
    this.pixelMap = pixmap
  })
}

private preDragChange(status: PreDragStatus): void {
  if (status === PreDragStatus.ACTION_DETECTING_STATUS) {
    this.getComponentSnapshot()
  }
}

Image($r('app.media.mount'))
  .onPreDrag((status: PreDragStatus) => {
    this.preDragChange(status)
  })
  .onDragStart(() => {
    const dragItemInfo: DragItemInfo = {
      pixelMap: this.pixelMap,
      builder: () => {
        this.pixelMapBuilder()
      }
    }
    return dragItemInfo
  })

如果你的业务只需要自定义背板而不需要Builder实时构建,尽量只返回pixelMap,进一步减少离线渲染带来的不确定性。

2.1 拖拽过程监听:实时获取拖拽坐标

自定义背板只是“看起来更像自家产品”,但拖拽过程中的实时信息也很有用,例如你想做“拖拽经过某个区域高亮”“拖拽到边缘自动滚动”等交互,就需要用onDragMove()读取坐标。注意,只有注册了onDrop()onDragMove()才会在组件范围内移动时触发。

Column() {
  Text(this.dropContent)
}
.allowDrop([uniformTypeDescriptor.UniformDataType.IMAGE])
.onDrop((event?: DragEvent) => {
  try {
    const dragData = event?.getData() as unifiedDataChannel.UnifiedData
    if (!dragData) {
      return
    }
    event?.setResult(DragResult.DRAG_SUCCESSFUL)
  } catch (error) {
    const err = error as BusinessError
    hilog.error(0x0000, TAG, `${err.code} ${err.message}`)
  }
})
.onDragMove((event: DragEvent) => {
  hilog.info(0x0000, TAG, `displayX=${event.getDisplayX()}, displayY=${event.getDisplayY()}`)
  hilog.info(0x0000, TAG, `windowX=${event.getWindowX()}, windowY=${event.getWindowY()}`)
})

3. 视觉魔法(下):拖拽图片增加水印(绘制 + 打包 + FileUri)

给拖拽图像加水印,常见用途是溯源与标识,例如“拖拽时间”“来源页面”“用户标记”。实现思路是:起拖时把原图解码成PixelMap,利用系统绘制能力drawing在其上绘制水印,然后把结果打包成文件并构造FileUri作为拖拽数据。

关键点有三个:

  • onDragStart()里构造水印图,确保拖拽数据可读;
  • 绘制前检查系统能力(例如SystemCapability.Graphics.Drawing);
  • 将水印图写入应用沙箱路径,生成有效uri,再放入UDMF。
Image($rawfile('river.png'))
  .draggable(true)
  .onDragStart((event: DragEvent) => {
    try {
      const resourceMgr = this.context!.resourceManager
      const rawFileDescriptor = resourceMgr.getRawFdSync('river.png')
      const imageSourceApi = image.createImageSource(rawFileDescriptor)
      const pixelMap = imageSourceApi.createPixelMapSync()

      const watermark = this.getTimeWatermark(systemDateTime.getTime(false))
      const markPixelMap = this.addWaterMark(watermark, pixelMap)

      const packOpts: image.PackingOption = { format: 'image/png', quality: 20 }
      const file = fs.openSync(`${this.context!.filesDir}/watermark.png`, fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE)
      const imagePackerApi = image.createImagePacker()
      imagePackerApi.packToFile(markPixelMap, file.fd, packOpts)
      imagePackerApi.release()

      const imgData: uniformDataStruct.FileUri = {
        uniformDataType: 'general.file-uri',
        oriUri: fileUri.getUriFromPath(`${this.context!.filesDir}/watermark.png`),
        fileType: 'general.image'
      }
      const unifiedRecord = new unifiedDataChannel.UnifiedRecord(
        uniformTypeDescriptor.UniformDataType.FILE_URI,
        imgData
      )
      const unifiedData = new unifiedDataChannel.UnifiedData(unifiedRecord)
      event.setData(unifiedData)
      fs.closeSync(file.fd)
    } catch (error) {
      const err = error as BusinessError
      hilog.error(0x0000, TAG, `${err.code} ${err.message}`)
    }
  })
addWaterMark(watermark: string, pixelMap: image.PixelMap) {
  try {
    if (!canIUse('SystemCapability.Graphics.Drawing')) {
      hilog.error(0x0000, TAG, 'drawing not supported')
      return pixelMap
    }

    const imageInfo = pixelMap.getImageInfoSync().size
    const imageWidth = imageInfo.width
    const imageHeight = imageInfo.height
    const imageScale = imageWidth / display.getDefaultDisplaySync().width
    const canvas = new drawing.Canvas(pixelMap)
    const pen = new drawing.Pen()
    const brush = new drawing.Brush()

    pen.setColor({ alpha: 102, red: 255, green: 255, blue: 255 })
    brush.setColor({ alpha: 102, red: 255, green: 255, blue: 255 })

    const font = new drawing.Font()
    font.setSize(48 * imageScale)
    const textWidth = font.measureText(watermark, drawing.TextEncoding.TEXT_ENCODING_UTF8)
    const textBlob = drawing.TextBlob.makeFromString(watermark, font, drawing.TextEncoding.TEXT_ENCODING_UTF8)

    canvas.attachBrush(brush)
    canvas.attachPen(pen)
    canvas.drawTextBlob(textBlob, imageWidth - 24 * imageScale - textWidth, imageHeight - 32 * imageScale)
    canvas.detachBrush()
    canvas.detachPen()
  } catch (error) {
    const err = error as BusinessError
    hilog.error(0x0000, TAG, `addWaterMark failed: ${err.message}`)
  }
  return pixelMap
}

4. AI赋能:拖图识字

想象一下,你把一张图片拖进一个只能输入文字的输入框,系统自动把图片里的字提取出来了。这不是科幻,是鸿蒙的AI识别能力。

  • 实现逻辑
    1. 接收方在onDrop中拿到图片的PixelMap
    2. 调用系统textRecognition接口进行OCR识别。
    3. 将识别出的文本赋值给输入框。 这打破了“图片”与“文字”的格式壁垒,极大地提升了效率。

下面给出一个更完整的实现片段:从UnifiedData中读取FILE_URI,判断其fileType属于图片,然后将视觉信息转换为可识别的PixelMap,最后调用textRecognition.recognizeText()

Column() {
  Text(this.textContent)
}
.allowDrop([uniformTypeDescriptor.UniformDataType.IMAGE])
.onDrop(async (event?: DragEvent) => {
  try {
    const dragData = (event as DragEvent).getData() as unifiedDataChannel.UnifiedData
    if (dragData === undefined) {
      hilog.info(0x0000, TAG, 'onDrop undefined data')
      return
    }
    const records = dragData.getRecords()

    for (let i = 0; i < records.length; i++) {
      const types = records[i].getTypes()
      if (!types.includes(uniformTypeDescriptor.UniformDataType.FILE_URI)) {
        continue
      }
      const fileUriUds = records[i].getEntry(
        uniformTypeDescriptor.UniformDataType.FILE_URI
      ) as uniformDataStruct.FileUri

      const typeDescriptor = uniformTypeDescriptor.getTypeDescriptor(fileUriUds.fileType)
      if (!typeDescriptor.belongsTo(uniformTypeDescriptor.UniformDataType.IMAGE)) {
        continue
      }

      const resourceReg = new RegExp('resource')
      if (!resourceReg.test(fileUriUds.oriUri)) {
        continue
      }
      const numberReg = new RegExp('[0-9]+')
      const idArray = fileUriUds.oriUri.match(numberReg)
      if (idArray === null) {
        continue
      }
      const id = idArray[0]

      const drawableDescriptor = this.context.getHostContext()!.resourceManager.getDrawableDescriptor(Number(id), 0, 1)
      const pixelMapInit = drawableDescriptor.getPixelMap() as image.PixelMap
      const imageHeight = pixelMapInit.getImageInfoSync().size.height
      const imageWidth = pixelMapInit.getImageInfoSync().size.width
      const readBuffer = new ArrayBuffer(imageHeight * imageWidth * 4)
      pixelMapInit.readPixelsToBufferSync(readBuffer)

      const opts: image.InitializationOptions = {
        editable: true,
        size: { height: imageHeight, width: imageWidth },
        srcPixelFormat: pixelMapInit.getImageInfoSync().pixelFormat,
        pixelFormat: 3,
        alphaType: pixelMapInit.getImageInfoSync().alphaType,
        scaleMode: 0
      }
      const pixelMap = image.createPixelMapSync(readBuffer, opts)

      const visionInfo: textRecognition.VisionInfo = { pixelMap: pixelMap }
      const data = await textRecognition.recognizeText(visionInfo)
      this.textContent = data.value
      event?.setResult(DragResult.DRAG_SUCCESSFUL)
      return
    }
  } catch (error) {
    const err = error as BusinessError
    hilog.error(0x0000, TAG, `onDrop error, code: ${err.code}, message: ${err.message}`)
  }
})

实践中要注意:模拟器不支持textRecognition,这类功能建议直接用真机调试,否则你很容易把“环境限制”误判成“代码Bug”。

5. 文件传输:在线与本地的博弈

文件拖拽是最硬核的需求,分为“在线”和“本地”两种情况:

  • 在线文件:拖拽的其实是一个URL。接收方拿到URL后,可以选择直接展示(如网络图片),或者调用request.downloadFile下载到本地。
  • 本地文件:涉及到沙箱安全。
    • 发送方:构造FileUri类型的UDMF数据。
    • 接收方强烈建议使用startDataLoading()接口。这个接口不仅能获取数据,还能通过设置destUri,让系统自动帮你把文件Copy到你的应用沙箱中,省去了繁琐的文件流读写操作。

3.1 在线图片拖拽:URL展示 + 业务侧选择性下载

在线资源拖拽的核心是:拖拽数据中携带的是可访问链接,下载逻辑由落入方按业务决定。如果你希望拖出方下载后再拖拽,需要把“下载”与“拖拽”拆开处理,确保起拖前资源已就绪,避免拖出失败。

首先在module.json5里声明网络权限(这是在线图片场景的前置条件):

"requestPermissions": [
  {
    "name": "ohos.permission.INTERNET",
    "reason": "$string:internet_reason",
    "usedScene": {
      "abilities": ["EntryAbility"],
      "when": "inuse"
    }
  }
]

拖出方Image只需要开启draggable(true),绑定在线图片资源即可:

Image('https://www-file.huawei.com/-/media/corp2020/home/banner/12/pura-x-1.jpg')
  .draggable(true)

落入方在onDrop()中拿到FILE_URI,把oriUri绑定到目标Image上刷新UI;若业务需要落地到本地,则调用request.downloadFile()把URL下载到沙箱目录。

Column() {
  Image(this.targetImage)
}
.onDrop((event?: DragEvent) => {
  try {
    const dragData = event?.getData() as unifiedDataChannel.UnifiedData
    if (!dragData) {
      return
    }
    const records = dragData.getRecords()
    for (let i = 0; i < records.length; i++) {
      const types = records[i].getTypes()
      if (!types.includes(uniformTypeDescriptor.UniformDataType.FILE_URI)) {
        continue
      }
      const fileUriUds = records[i].getEntry(
        uniformTypeDescriptor.UniformDataType.FILE_URI
      ) as uniformDataStruct.FileUri

      const typeDescriptor = uniformTypeDescriptor.getTypeDescriptor(fileUriUds.fileType)
      if (!typeDescriptor.belongsTo(uniformTypeDescriptor.UniformDataType.IMAGE)) {
        continue
      }

      this.targetImage = fileUriUds.oriUri

      request.downloadFile(this.context, {
        url: fileUriUds.oriUri,
        filePath: this.filesDir + '/test.png'
      }).then(() => {
        const file = fileIo.openSync(this.filesDir + '/test.png', fileIo.OpenMode.READ_WRITE)
        const arrayBuffer = new ArrayBuffer(1024)
        const readLen = fileIo.readSync(file.fd, arrayBuffer)
        buffer.from(arrayBuffer, 0, readLen)
        fileIo.closeSync(file)
      }).catch((error: BusinessError) => {
        hilog.error(0x0000, TAG, `${error.code} ${error.message}`)
      })

      event?.setResult(DragResult.DRAG_SUCCESSFUL)
      return
    }
  } catch (error) {
    const err = error as BusinessError
    hilog.error(0x0000, TAG, `${err.code} ${err.message}`)
  }
})

3.2 本地文件拖拽:FileUri + startDataLoading(带进度、带落盘)

本地文件场景建议把拖拽数据构造成FileUri类型。落入时通过startDataLoading()读取并在需要时拷贝到本地沙箱;如果你把destUri配置好,UDMF会自动处理拷贝与冲突策略,你只需要在进度回调里取到最终可用的uri并刷新UI。

拖出方:以$rawfile的视频为例,先把资源复制到应用沙箱目录,再把沙箱路径的uri放进拖拽数据里。

Video({ src: $rawfile('video.mp4'), controller: new VideoController() })
  .draggable(true)
  .onDragStart((event: DragEvent) => {
    try {
      const data = this.context!.resourceManager.getRawFdSync('video.mp4')
      const filePath = this.context?.filesDir + '/video.mp4'
      const dest = fileIo.openSync(filePath, fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE)
      const bufferSize = data.length as number
      const buf = new ArrayBuffer(bufferSize)
      fileIo.readSync(data.fd, buf, { offset: data.offset, length: bufferSize })
      fileIo.writeSync(dest.fd, buf, { offset: 0, length: bufferSize })
      fileIo.close(dest.fd)
      this.context!.resourceManager.closeRawFd('video.mp4')

      const originalVideoUri = fileUri.getUriFromPath(filePath)
      const unifiedData = new unifiedDataChannel.UnifiedData()
      const unifiedRecord = new unifiedDataChannel.UnifiedRecord()
      const video: uniformDataStruct.FileUri = {
        uniformDataType: 'general.file-uri',
        oriUri: originalVideoUri,
        fileType: 'general.video'
      }
      unifiedRecord.addEntry(uniformTypeDescriptor.UniformDataType.VIDEO, video)
      unifiedData.addRecord(unifiedRecord)
      event.setData(unifiedData)
    } catch (error) {
      const err = error as BusinessError
      hilog.error(0x0000, TAG, `${err.code} ${err.message}`)
    }
  })

落入方:在onDrop()里通过startDataLoading(options)发起加载,给出destUri、冲突策略、进度监听。注意这里示例带上了disableDataPrefetch: true,这在文件类落位场景中更利于按需加载与避免不必要的数据预取。

Column() {
  Video({ src: this.targetVideoUri, controller: new VideoController() })
}
.onDrop((event?: DragEvent) => {
  try {
    const progressListener: unifiedDataChannel.DataProgressListener =
      (_progress: unifiedDataChannel.ProgressInfo, dragData: unifiedDataChannel.UnifiedData | null) => {
        if (!dragData) {
          hilog.info(0x0000, TAG, 'dragData is undefined')
          return
        }
        const records = dragData.getRecords()
        for (let i = 0; i < records.length; i++) {
          const types = records[i].getTypes()
          if (!types.includes(uniformTypeDescriptor.UniformDataType.FILE_URI)) {
            continue
          }
          const fileUriUds = records[i].getEntry(
            uniformTypeDescriptor.UniformDataType.FILE_URI
          ) as uniformDataStruct.FileUri
          const typeDescriptor = uniformTypeDescriptor.getTypeDescriptor(fileUriUds.fileType)
          if (!typeDescriptor.belongsTo(uniformTypeDescriptor.UniformDataType.VIDEO)) {
            continue
          }
          this.targetVideoUri = fileUriUds.oriUri
        }
      }

    const destUri = fileUri.getUriFromPath(this.context!.distributedFilesDir)
    const options: unifiedDataChannel.DataSyncOptions = {
      destUri: destUri,
      fileConflictOptions: unifiedDataChannel.FileConflictOptions.OVERWRITE,
      progressIndicator: unifiedDataChannel.ProgressIndicator.DEFAULT,
      dataProgressListener: progressListener
    }
    ;(event as DragEvent).startDataLoading(options)
  } catch (error) {
    const err = error as BusinessError
    hilog.error(0x0000, TAG, `startDataLoading errorCode: ${err.code}, errorMessage: ${err.message}`)
  }
}, { disableDataPrefetch: true })

这里把destUri设置为distributedFilesDir有一个很实际的意义:当拖拽发生在多端协同环境中(例如键鼠穿越的跨设备拖拽),落入方依然遵循同一套startDataLoading()流程,UDMF会把文件同步到目标端可访问的位置,开发者只需要在进度回调里拿到最终可用的uri并更新UI即可。

6. 图文混排:多Entry的智慧

当我们在富文本编辑器中拖拽时,情况变得复杂。你是要纯文本?还是要带格式的HTML?还是要图片?

  • Text组件:简单,但灵活性差,适合展示。
  • RichEditor组件:王者之选。支持交互式编辑,通过addImageSpanaddTextSpan动态插入内容。
  • UDMF多Entry:这是“终极适配”方案。发送方在UnifiedRecord中同时塞入“纯文本”、“HTML”、“PixelMap”等多种格式。接收方是记事本就拿纯文本,是浏览器就拿HTML。这种“我全都要”的策略,最大程度保证了兼容性。

6.1 RichEditor落入:必须显式setResult避免“重复落入”

RichEditor本身具备默认落入行为。如果你在onDrop()里又手动插入了一遍Span,但没有设置拖拽结果,系统可能继续执行默认落入逻辑,导致“文字落入两次”。工程上建议在RichEditor的onDrop()第一时间调用event.setResult(0)event.setResult(DragResult.DRAG_SUCCESSFUL)(取决于你的实现与枚举定义),然后再进入自定义解析逻辑。

RichEditor({ controller: this.targetController })
  .allowDrop([
    uniformTypeDescriptor.UniformDataType.IMAGE,
    uniformTypeDescriptor.UniformDataType.OPENHARMONY_PIXEL_MAP,
    uniformTypeDescriptor.UniformDataType.TEXT,
    uniformTypeDescriptor.UniformDataType.PLAIN_TEXT
  ])
  .onDrop((event: DragEvent) => {
    try {
      event.setResult(0)
      this.receiveDragData(event)
    } catch (error) {
      const err = error as BusinessError
      hilog.error(0x0000, TAG, `on drop error, errorCode: ${err.code}, errorMessage: ${err.message}`)
    }
  })

receiveDragData()里按records遍历:遇到图片就addImageSpan(),遇到文本就addTextSpan(),并将插入位置设置为当前光标位置getCaretOffset(),这样“拖到哪插到哪”。

async receiveDragData(event: DragEvent) {
  try {
    const dragData = event.getData() as unifiedDataChannel.UnifiedData
    if (dragData === undefined) {
      return
    }
    const records = dragData.getRecords()
    for (let i = 0; i < records.length; i++) {
      const types = records[i].getTypes()

      if (types.includes(uniformTypeDescriptor.UniformDataType.FILE_URI)) {
        const fileUriUds = records[i].getEntry(
          uniformTypeDescriptor.UniformDataType.FILE_URI
        ) as uniformDataStruct.FileUri
        const typeDescriptor = uniformTypeDescriptor.getTypeDescriptor(fileUriUds.fileType)
        if (typeDescriptor.belongsTo(uniformTypeDescriptor.UniformDataType.IMAGE)) {
          this.targetController.addImageSpan(fileUriUds.oriUri, {
            imageStyle: this.imageStyle,
            offset: this.targetController.getCaretOffset()
          })
        }
        continue
      }

      if (types.includes(uniformTypeDescriptor.UniformDataType.PLAIN_TEXT)) {
        const plainTextUds = records[i].getEntry(
          uniformTypeDescriptor.UniformDataType.PLAIN_TEXT
        ) as uniformDataStruct.PlainText
        this.targetController.addTextSpan(plainTextUds.textContent, {
          style: this.textStyle,
          offset: this.targetController.getCaretOffset()
        })
        continue
      }

      if (types.includes(uniformTypeDescriptor.UniformDataType.OPENHARMONY_PIXEL_MAP)) {
        const pixelMapUds = records[i].getEntry(
          uniformTypeDescriptor.UniformDataType.OPENHARMONY_PIXEL_MAP
        ) as uniformDataStruct.PixelMap
        this.targetController.addImageSpan(pixelMapUds.pixelMap, {
          imageStyle: this.imageStyle,
          offset: this.targetController.getCaretOffset()
        })
        continue
      }
    }
  } catch (error) {
    const err = error as BusinessError
    hilog.error(0x0000, TAG, `${err.code} ${err.message}`)
  }
}

6.2 多Entry图文混排:拖出方“主动适配”,落入方“按需解析”

当接收方可能是RichEditor、RichText甚至其他组件时,只靠一种格式很难保证效果一致。多Entry策略的思路是:拖出方尽可能丰富数据表达,落入方根据组件能力优先选择最合适的Entry。

拖出方构造数据时,可以为图片记录同时放入FILE_URIOPENHARMONY_PIXEL_MAP两种Entry;对文字记录则放入PLAIN_TEXT。下面示例展示了在onDragStart()里根据选区构造records的主逻辑(完整实现可按业务扩展)。

RichEditor({ controller: this.sourceController })
  .onSelectionChange((value: RichEditorRange) => {
    this.selectedStartIndex = value.start as number
    this.selectedEndIndex = value.end as number
  })
  .onDragStart((event) => {
    try {
      const selection = this.sourceController.getSelection()
      this.buildUnifiedRecords(selection)
      event.setData(this.unifiedData)
    } catch (error) {
      const err = error as BusinessError
      hilog.error(0x0000, TAG, `${err.code} ${err.message}`)
    }
  })
buildUnifiedRecords(selection: RichEditorSelection) {
  try {
    selection.spans.forEach(async (item) => {
      if (typeof (item as RichEditorImageSpanResult)['imageStyle'] !== 'undefined') {
        const originImageUri = this.getOriginImageUri()
        const imageData: uniformDataStruct.FileUri = {
          uniformDataType: 'general.file-uri',
          oriUri: originImageUri,
          fileType: 'general.image'
        }
        const unifiedRecord = new unifiedDataChannel.UnifiedRecord(
          uniformTypeDescriptor.UniformDataType.FILE_URI,
          imageData
        )

        const createdPixelMap = this.getOriginPixelMap()
        if (createdPixelMap) {
          const pixelMap: uniformDataStruct.PixelMap = {
            uniformDataType: 'openharmony.pixel-map',
            pixelMap: createdPixelMap
          }
          unifiedRecord.addEntry(uniformTypeDescriptor.UniformDataType.OPENHARMONY_PIXEL_MAP, pixelMap)
        }

        this.unifiedData.addRecord(unifiedRecord)
        return
      }

      if (typeof (item as RichEditorTextSpanResult)['value'] !== 'undefined') {
        const selectedText = this.getSelectedText(item as RichEditorTextSpanResult)
        const textData: uniformDataStruct.PlainText = {
          uniformDataType: 'general.plain-text',
          textContent: selectedText
        }
        const unifiedRecord = new unifiedDataChannel.UnifiedRecord(
          uniformTypeDescriptor.UniformDataType.PLAIN_TEXT,
          textData
        )
        this.unifiedData.addRecord(unifiedRecord)
      }
    })
  } catch (error) {
    const err = error as BusinessError
    hilog.error(0x0000, TAG, `${err.code} ${err.message}`)
  }
}

三、 生态协同:打破物理边界

鸿蒙最大的魅力在于“多端协同”。拖拽技术在这里得到了升华。

  1. 分屏拖拽:在同一块屏幕上,两个App并排运行,数据在指尖自由流转。
  2. 跨设备拖拽(键鼠穿越):当平板与2in1设备在系统能力支持下组成协同环境,鼠标可以跨越屏幕边界,拖拽数据也随之跨端流转。对应用开发者而言,关键不在“额外写一套跨端代码”,而在于把拖拽数据封装成标准的UDMF结构,并遵循使用限制条件:落入方能够按类型解析records、文件类数据使用startDataLoading()安全落盘、uri与在线地址合法可读。系统负责跨端链路,应用负责数据正确与落位逻辑正确。
  3. 中转站与小艺
    • 中转站:暂存区。把图片、文字先拖进去,等需要时再拖出来。
    • 小艺:AI入口。拖拽内容给小艺,直接触发AI分析。
    • 注意:小艺与中转站只支持落入标准化数据通路中定义的数据类型,起拖时需要构造相应数据。应用预置资源(安装前已在HAP中存在的资源文件)不支持拖入小艺与中转站;工程上若确有需求,通常需要先把资源复制到沙箱目录,生成可访问的uri,再参与拖拽流程。

生态协同里的“工程要点”

跨窗口、跨应用、跨设备这些能力看起来很“系统”,但实际项目稳定性的关键仍然落在应用侧两个动作上:

  • 起拖数据可读:文件uri合法、在线地址可访问、记录类型与fileType匹配,不要传空、不合法或临时不可读的uri。
  • 落位逻辑可复现allowDrop()只负责“允许进入”,onDrop()必须真的做解析、刷新UI、必要时调用startDataLoading(),否则会出现“可落入提示已出现但落位失败”的假象。

四、 避坑指南

在修炼拖拽神功的路上,这些坑你一定要避开:

  1. “绿色角标”的骗局

    • 现象:拖拽时看到右上角出现了绿色的“+”号,以为稳了,结果松手后什么也没发生。
    • 真相:绿色角标只代表你的allowDrop通过了类型检查。真正的落位逻辑在onDrop里! 如果你在onDrop里没有正确处理数据、没有刷新UI,或者读取数据失败(比如无效的URI),操作依然会失败。务必在onDrop中做好异常捕获和UI更新。
  2. 手势冲突

    • 现象:长按想触发拖拽,结果触发了组件原本的长按菜单。
    • 解法:使用parallelGesture(并行手势)。让拖拽的长按(通常是500ms)与业务逻辑共存。
  3. 色差之谜

    • 现象:拖出来的图片颜色不对,变蓝了或变红了。
    • 真相PixelMap的编码格式问题。默认是BGRA_8888,如果接收方按RGBA解码就会偏色。接收方应通过systemDefinedPixelMap.details['pixel-format']检查编码格式,做到“知己知彼”。
    const systemDefinedPixelMap = pixelMapUds.pixelMap as image.PixelMap
    const details = (systemDefinedPixelMap as Record<string, object>)['details'] as Record<string, string> | undefined
    const pixelFormat = details?.['pixel-format']
    hilog.info(0x0000, TAG, `pixel-format=${pixelFormat}`)
    
  4. 模拟器的痛

    • 部分AI能力(如文字识别)在模拟器上无法运行。调试此类功能,请务必使用真机
  5. onDragMove为什么不触发

    • 真相:只有监听了onDrop()事件,拖拽在组件范围内移动时才会触发onDragMove()。如果你只写了onDragMove()而没写onDrop(),你会以为“拖拽事件失效”,实际上是触发条件未满足。
  6. 拖到“输入框”没反应

    • 真相:ArkUI默认支持落入的组件包括Search、TextInput、TextArea、Video等;如果你用Text组件手搓了一个“看起来像输入框”的UI,它默认不会响应拖拽。要支持拖拽,必须显式设置allowDrop()并实现onDrop()
  7. 拖到小艺提示不支持

    • 真相:多半是拖拽数据源设置不当导致,例如文件uri非法、在线地址不可读,UDMF无法读取数据,最终落位失败。优先检查:起拖时setData()是否为空、uri是否有效、类型与内容是否匹配。

结语

最后简单总结一下,鸿蒙的统一拖拽,表面看是一个简单的交互,背后却是UDMF标准化数据通路与分布式能力的深度结合。作为开发者,掌握这一技术,不仅能为用户提供丝滑的操作体验,更能让你的应用无缝融入鸿蒙的“全场景”生态。

技术无止境,探索不停歇。希望你读完后能把“统一拖拽”从演示Demo提升到生产级实现:数据结构清晰、落位逻辑可靠、文件处理安全、跨端协同顺滑。