鸿蒙跨设备协同开发05——跨设备拖拽
如果你也对鸿蒙开发感兴趣,加入“Harmony自习室”吧!扫描下方名片,关注公众号,公众号更新更快,同时也有更多学习资料和技术讨论群。

1、概述
当用户拥有两台平板设备时,可以共享一套键鼠,通过跨设备拖拽,一步将设备A的素材拖拽到设备B快速创作,实现跨设备的协同工作体验。演示如下:

跨端拖拽提供跨设备的键鼠共享能力,支持在平板或2in1类型的任意两台设备之间拖拽文件、文本。
当前HarmonyOS系统应用中,文件管理器、浏览器支持拖出;备忘录支持拖入。用户可以体验以下场景:
-
将A设备文件管理器中的图片拖拽至B设备的备忘录应用。
-
将A设备备忘录中的文本拖拽至B设备的备忘录应用,并在B设备中使用A设备连接的键盘输入,协同操作。
【在开发跨设备拖拽的功能时,系统将自动完成键鼠穿越和跨设备的数据传递】
使用跨设备拖拽开发需要满足以下基本条件:
-
设备需要是:HarmonyOS NEXT Developer Preview0及以上版本的平板或2in1设备。
-
双端设备需要登录同一华为账号。
-
双端设备需要打开Wi-Fi和蓝牙开关,并接入同一个局域网。
-
打开键鼠穿越开关。
-
应用本身预置的资源文件(即应用在安装前的HAP包中已经存在的资源文件)不支持跨设备拖拽。
2、拖拽开发
ArkUI框架对以下组件实现了默认的拖拽能力,支持对数据的拖出或拖入响应,开发者只需要将这些组件的draggable属性设置为true,即可使用默认拖拽能力。
-
默认支持拖出能力的组件(可从组件上拖出数据):Search、TextInput、TextArea、RichEditor、Text、Image、Hyperlink。
-
默认支持拖入能力的组件(目标组件可响应拖入数据):Search、TextInput、TextArea、Video。
其中Text、TextInput、TextArea、Hyperlink、Image和RichEditor组件的draggable属性默认为true。
我们也可以通过实现通用拖拽事件来自定义拖拽响应。
其他组件需要我们将draggable属性设置为true,并在onDragStart等接口中实现数据传输相关内容,才能正确处理拖拽。
2.1 接口说明
整个拖拽过程涉及到的API总共有7个,分别是:
onDragStart(event: (event: DragEvent, extraParams?: string) => CustomBuilder | DragItemInfo)onDragEnter(event: (event: DragEvent, extraParams?: string) => void)onDragMove(event: (event: DragEvent, extraParams?: string) => void)onDragLeave(event: (event: DragEvent, extraParams?: string) => void)onDrop(event: (event: DragEvent, extraParams?: string) => void)onDragEnd(event: (event: DragEvent, extraParams?: string) => void)onPreDrag(event: (preDragStatus: PreDragStatus) => void)
这7个API功能和作用分别介绍如下:
👉🏻 onDragStart
触发时机:第一次拖拽此事件绑定的组件时,长按时间 >= 500ms,然后手指移动距离 >= 10vp(如果长按触发时间 < 500ms,长按事件优先拖拽事件响应)
onDragStart(event: (event: DragEvent, extraParams?: string) => CustomBuilder | DragItemInfo)
针对默认支持拖出能力的组件,如果我们设置了onDragStart,系统会优先执我们设置的onDragStart,并根据执行情况决定是否使用系统默认的拖出能力:
-
-
如果开发者返回了自定义背板图,则不再使用系统默认的拖拽背板图;
-
如果开发者设置了拖拽数据,则不再使用系统默认填充的拖拽数据。
-
【注意:文本类组件Text、Search、TextInput、TextArea、RichEditor对选中的文本内容进行拖拽时,不支持背板图的自定义】
onDragStart方法涉及到三个类:DragEvent、CustomBuilder、DragItemInfo。分别介绍如下:
⭐️ DragEvent表示拖拽事件,结构定义如下:
class DragEvent {/* 功能描述:当拖拽结束时,是否使能并使用系统默认落位动效。应用可将该值设定为true来禁用系统默认落位动效,并实现自己的自定义落位动效。当不配置或设置为false时,系统默认落位动效生效,当松手位置的控件可接收拖拽的数据时,落位为缩小消失动效,若不可接收数据,则为放大消失动效。当未禁用系统默认落位动效情况下,应用不应再实现自定义动效,以避免动效上的冲突。*/+ useCustomDropAnimation: boolean;// 切换复制和剪贴模式的角标显示状态。+ dragBehavior: DragBehavior;// 还包含以下方法:setData(unifiedData: UnifiedData): void // 向DragEvent中设置拖拽相关数据。getData(): UnifiedData // 从DragEvent中获取拖拽相关数据。数据获取结果请参考错误码说明。getSummary(): Summary // 从DragEvent中获取拖拽相关数据的简介。setResult(dragRect: DragResult): void // 向DragEvent中设置拖拽结果。getResult(): DragResult // 从DragEvent中获取拖拽结果。getPreviewRect(): Rectangle // 获取拖拽跟手图相对于当前窗口的位置,以及跟手图尺寸信息,单位VP,其中x和y代表跟手图左上角的窗口坐标,width和height代表跟手图的尺寸。getVelocityX(): number // 获取当前拖拽的x轴方向拖动速度。坐标轴原点为屏幕左上角,单位为vp,分正负方向速度,从左往右为正,反之为负。getVelocityY(): number // 获取当前拖拽的y轴方向拖动速度。坐标轴原点为屏幕左上角,单位为vp,分正负方向速度,从上往下为正,反之为负。getVelocity(): number // 获取当前拖拽的主方向拖动速度。为xy轴方向速度的平方和的算术平方根。getWindowX(): number // 当前拖拽点相对于窗口左上角的x轴坐标,单位为vp。getWindowY(): number // 当前拖拽点相对于窗口左上角的y轴坐标,单位为vp。getDisplayX(): number // 当前拖拽点相对于屏幕左上角的x轴坐标,单位为vp。getDisplayY(): number // 当前拖拽点相对于屏幕左上角的y轴坐标,单位为vp。getModifierKeyState(Array<string>) => bool // 获取功能键按压状态。报错信息请参考以下错误码。支持功能键 'Ctrl'|'Alt'|'Shift'|'Fn',设备外接带Fn键的键盘不支持Fn键查询。}// 其中DragBehavior是一个枚举,定义如下:enum DragBehavior {COPY, // 指定对数据的处理方式为复制。MOVE // 指定对数据的处理方式为剪切。}
⭐️ CustomBuilder表示自定义UI描述,定义如下:
// 生成用户自定义组件,在使用时结合@Builder使用。type CustomBuilder = () => any | void;
⭐️ DragItemInfo比CustomBuilder可以设置更多的信息,定义如下:
class DragItemInfo {pixcelMap: PixelMap; // 设置拖拽过程中显示的图片。builder: CustomBuilder; // 刚刚介绍过d的自定义UI描述extraInfo: string; // 拖拽项(DragItemInfo)的描述}
在DragEvent中,还包括了一些关联的类,例如:UnifiedData、Summary、DragResult、Rectangle。他们的定义如下:
-
UnifiedData及其关联的类如下
class UnifiedData {// 当前统一数据对象中所有数据记录的属性,包含时间戳、标签、粘贴范围以及一些附加数据等。properties: UnifiedDataProperties;}// 定义统一数据对象中所有数据记录的属性,包含时间戳、标签、粘贴范围以及一些附加数据等。class UnifiedDataProperties {extras: Record<string, object> // 是一个字典类型对象,用于设置其他附加属性数据。非必填字段,默认值为空字典对象。tag: string // 用户自定义标签。非必填字段,默认值为空字符串。timestamp: Date // UnifiedData的生成时间戳。默认值为1970年1月1日(UTC)。shareOptions: ShareOptions // 指示UnifiedData支持的设备内使用范围,非必填字段,默认值为CROSS_APP。getDelayData: GetDelayData // 延迟获取数据回调。当前只支持同设备剪贴板场景,后续场景待开发。非必填字段,默认值为undefined。}// UDMF支持的设备内使用范围类型枚举。enum ShareOptions {IN_APP = 0 // 表示允许在本设备同应用内使用。CROSS_APP = 1 // 表示允许在本设备内跨应用使用。}// 对UnifiedData的延迟封装,支持延迟获取数据。type GetDelayData = (type: string) => UnifiedData
-
Summary结构如下:
class Summary {summary: Record<string, number> // 是一个字典类型对象,key表示数据类型(UniformDataType),value为统一数据对象中该类型记录大小总和(单位:Byte)。totalSize: number // 统一数据对象内记录总大小(单位:Byte)。}
-
DragResult(枚举)结构如下:
enum DragResult {DRAG_SUCCESSFUL // 拖拽成功,在onDrop中使用。DRAG_FAILED // 拖拽失败,在onDrop中使用。DRAG_CANCELED // 拖拽取消,在onDrop中使用。DROP_ENABLED // 组件允许落入,在onDragMove中使用。DROP_DISABLED // 组件不允许落入,在onDragMove中使用。}
-
Rectangle结构如下:
class Rectangle {x: Length // 触摸点相对于组件左上角的x轴坐标。默认值:0vpy: Length // 触摸点相对于组件左上角的y轴坐标。默认值:0vpwidth: Length // 触摸热区的宽度。默认值:'100%'height: Length // 触摸热区的高度。默认值:'100%'}type Length = string | number | Resource;
👉🏻 onDragEnter
触发时机:被拖拽的内容进入到释放目标的组件范围内时,触发回调(当监听了onDrop事件时,此事件才有效)
onDragEnter(event: (event: DragEvent, extraParams?: string) => void)
👉🏻 onDragMove
触发时机:被拖拽的内容在释放目标的组件范围内移动时,触发回调,(当监听了onDrop事件时,此事件才有效)
onDragMove(event: (event: DragEvent, extraParams?: string) => void)
👉🏻 onDragLeave
触发时机:被拖拽的内容从释放目标的组件范围内移出时,触发回调,(当监听了onDrop事件时,此事件才有效)
onDragLeave(event: (event: DragEvent, extraParams?: string) => void)
👉🏻 onDrop
触发时机:被拖拽的内容从释放目标的组件上方释放时,触发回调,(当监听了onDrop事件时,此事件才有效)
onDrop(event: (event: DragEvent, extraParams?: string) => void)
如果我们没有在onDrop中主动调用event.setResult()设置拖拽接收的结果,则系统按照数据接收成功处理。
👉🏻 onDragEnd
触发时机:绑定次事件的组件触发的拖拽结束后
onDragEnd(event: (event: DragEvent, extraParams?: string) => void)
👉🏻 onPreDrag
触发时机:绑定此事件的组件,当触发拖拽发起前的不同阶段时,触发回调。
onPreDrag(event: (preDragStatus: PreDragStatus) => void)
其中,PreDragStatus是一个枚举,定义如下:
enum PreDragStatus {ACTION_DETECTING_STATUS = 0, // 拖拽手势启动阶段。(按下50ms时触发)READY_TO_TRIGGER_DRAG_ACTION = 1, // 拖拽准备完成,可发起拖拽阶段。(按下500ms时触发)PREVIEW_LIFT_STARTED = 2, // 拖拽浮起动效发起阶段。(按下800ms时触发)PREVIEW_LIFT_FINISHED = 3, // 拖拽浮起动效结束阶段。(浮起动效完全结束时触发)PREVIEW_LANDING_STARTED = 4, // 拖拽落回动效发起阶段。(落回动效发起时触发)PREVIEW_LANDING_FINISHED = 5, // 拖拽落回动效结束阶段。(落回动效结束时触发)ACTION_CANCELED_BEFORE_DRAG = 6 // 拖拽浮起落位动效中断。(已满足READY_TO_TRIGGER_DRAG_ACTION状态后,未达到动效阶段,手指抬手时触发)}
在拖拽接口中,都有一个参数叫 extraParams,这个参数用于表达组件在拖拽中需要用到的额外信息,extraParams是Json对象转换的string字符串,可以通过Json.parse转换的Json对象获取如下属性:
{// 当拖拽事件设在父容器的子元素时,selectedIndex表示当前被拖拽子元素是父容器第selectedIndex个子元素,selectedIndex从0开始。// 仅在ListItem组件的拖拽事件中生效。selectedIndex: number;// 当前拖拽元素在List组件中放下时,insertIndex表示被拖拽元素插入该组件的第insertIndex个位置,insertIndex从0开始。// 仅在List组件的拖拽事件中生效。insertIndex: number;}
2.2、示例
示例效果如下:

示例代码如下:
// xxx.etsimport { unifiedDataChannel, uniformTypeDescriptor } from '@kit.ArkData';import { promptAction } from '@kit.ArkUI';import { BusinessError } from '@kit.BasicServicesKit';@Entry@Componentstruct Index {@State targetImage: string = '';@State targetText: string = 'Drag Text';@State imageWidth: number = 100;@State imageHeight: number = 100;@State imgState: Visibility = Visibility.Visible;@State videoSrc: string = 'resource://RAWFILE/02.mp4';@State abstractContent: string = "abstract";@State textContent: string = "";@State backGroundColor: Color = Color.Transparent;@BuilderpixelMapBuilder() {Column() {Image($r('app.media.icon')).width(120).height(120).backgroundColor(Color.Yellow)}}getDataFromUdmfRetry(event: DragEvent, callback: (data: DragEvent) => void) {try {let data: UnifiedData = event.getData();if (!data) {return false;}let records: Array<unifiedDataChannel.UnifiedRecord> = data.getRecords();if (!records || records.length <= 0) {return false;}callback(event);return true;} catch (e) {console.log("getData failed, code = " + (e as BusinessError).code + ", message = " + (e as BusinessError).message);return false;}}getDataFromUdmf(event: DragEvent, callback: (data: DragEvent) => void) {if (this.getDataFromUdmfRetry(event, callback)) {return;}setTimeout(() => {this.getDataFromUdmfRetry(event, callback);}, 1500);}private PreDragChange(preDragStatus: PreDragStatus): void {if (preDragStatus == PreDragStatus.READY_TO_TRIGGER_DRAG_ACTION) {this.backGroundColor = Color.Red;} else if (preDragStatus == PreDragStatus.ACTION_CANCELED_BEFORE_DRAG|| preDragStatus == PreDragStatus.PREVIEW_LANDING_FINISHED) {this.backGroundColor = Color.Blue;}}build() {Row() {Column() {Text('start Drag').fontSize(18).width('100%').height(40).margin(10).backgroundColor('#008888')Image($r('app.media.icon')).width(100).height(100).draggable(true).margin({ left: 15 }).visibility(this.imgState).onDragEnd((event) => {// onDragEnd里取到的result值在接收方onDrop设置if (event.getResult() === DragResult.DRAG_SUCCESSFUL) {promptAction.showToast({ duration: 100, message: 'Drag Success' });} else if (event.getResult() === DragResult.DRAG_FAILED) {promptAction.showToast({ duration: 100, message: 'Drag failed' });}})Text('test drag event').width('100%').height(100).draggable(true).margin({ left: 15 }).copyOption(CopyOptions.InApp)TextArea({ placeholder: 'please input words' }).copyOption(CopyOptions.InApp).width('100%').height(50).draggable(true)Search({ placeholder: 'please input you word' }).searchButton('Search').width('100%').height(80).textFont({ size: 20 })Column() {Text('change video source')}.draggable(true).onDragStart((event) => {let video: unifiedDataChannel.Video = new unifiedDataChannel.Video();video.videoUri = '/resources/rawfile/01.mp4';let data: unifiedDataChannel.UnifiedData = new unifiedDataChannel.UnifiedData(video);(event as DragEvent).setData(data);return { builder: () => {this.pixelMapBuilder()}, extraInfo: 'extra info' };})Column() {Text('this is abstract').fontSize(20).width('100%')}.margin({ left: 40, top: 20 }).width('100%').height(100).onDragStart((event) => {this.backGroundColor = Color.Transparent;let data: unifiedDataChannel.PlainText = new unifiedDataChannel.PlainText();data.abstract = 'this is abstract';data.textContent = 'this is content this is content';(event as DragEvent).setData(new unifiedDataChannel.UnifiedData(data));}).onPreDrag((status: PreDragStatus) => {this.PreDragChange(status);}).backgroundColor(this.backGroundColor)}.width('45%').height('100%')Column() {Text('Drag Target Area').fontSize(20).width('100%').height(40).margin(10).backgroundColor('#008888')Image(this.targetImage).width(this.imageWidth).height(this.imageHeight).draggable(true).margin({ left: 15 }).border({ color: Color.Black, width: 1 }).allowDrop([uniformTypeDescriptor.UniformDataType.IMAGE]).onDrop((dragEvent?: DragEvent) => {this.getDataFromUdmf((dragEvent as DragEvent), (event: DragEvent) => {let records: Array<unifiedDataChannel.UnifiedRecord> = event.getData().getRecords();let rect: Rectangle = event.getPreviewRect();this.imageWidth = Number(rect.width);this.imageHeight = Number(rect.height);this.targetImage = (records[0] as unifiedDataChannel.Image).imageUri;event.useCustomDropAnimation = false;this.imgState = Visibility.None;// 显式设置result为successful,则将该值传递给拖出方的onDragEndevent.setResult(DragResult.DRAG_SUCCESSFUL);})})Text(this.targetText).width('100%').height(100).border({ color: Color.Black, width: 1 }).margin(15).allowDrop([uniformTypeDescriptor.UniformDataType.PLAIN_TEXT]).onDrop((dragEvent?: DragEvent) => {this.getDataFromUdmf((dragEvent as DragEvent), (event: DragEvent) => {let records: Array<unifiedDataChannel.UnifiedRecord> = event.getData().getRecords();let plainText: unifiedDataChannel.PlainText = records[0] as unifiedDataChannel.PlainText;this.targetText = plainText.textContent;})})Video({ src: this.videoSrc, previewUri: $r('app.media.icon') }).width('100%').height(200).controls(true).allowDrop([uniformTypeDescriptor.UniformDataType.VIDEO])Column() {Text(this.abstractContent).fontSize(20).width('100%')Text(this.textContent).fontSize(15).width('100%')}.width('100%').height(100).margin(20).border({ color: Color.Black, width: 1 }).allowDrop([uniformTypeDescriptor.UniformDataType.PLAIN_TEXT]).onDrop((dragEvent?: DragEvent) => {this.getDataFromUdmf((dragEvent as DragEvent), (event: DragEvent) => {let records: Array<unifiedDataChannel.UnifiedRecord> = event.getData().getRecords();let plainText: unifiedDataChannel.PlainText = records[0] as unifiedDataChannel.PlainText;this.abstractContent = plainText.abstract as string;this.textContent = plainText.textContent;})})}.width('45%').height('100%').margin({ left: '5%' })}.height('100%')}}
