一、这玩意儿有啥用

开发"一次开发,多端部署"应用,除了适配硬件差异(屏幕尺寸、分辨率),还得关注交互方式差异。

不同设备用的输入设备不一样,交互方式就不一样:手机和平板用手指触控屏幕,电脑用鼠标控制光标,智慧屏用灵犀指向遥控控制光标。要是只适配一种交互方式,应用在其他设备上体验就差了。

比如视频播放应用,控制播放/暂停这功能,不同设备的操作方式就完全不一样:

直板机:单击屏幕就行。

平板:单击屏幕、鼠标左键点击、键盘空格键都行。

电脑:单击屏幕、鼠标左键点击、触控板点击、键盘空格键都行。

智慧屏:灵犀指向遥控按OK键、灵犀悬浮触控点击触控板。

穿戴设备:单击屏幕。

所以多设备交互开发得针对触屏、鼠标、键盘、遥控器等不同输入设备做适配,保证功能一致性的基础上,遵循各设备的交互规范,实现自然流畅的体验。

二、不同设备咋交互

多设备交互这概念听着挺高大上,其实就是应用在不同设备上实现同一功能,适配相应的输入设备。

先看看不同设备支持哪些输入设备:

直板机:只有触控屏。

折叠屏:只有触控屏。

阔折叠:只有触控屏。

三折叠:触控屏和手写笔(Mate XTs)。

平板:触摸屏、鼠标、键盘、手写笔。

电脑:触控屏、触控板、鼠标、键盘、手柄。

智慧屏:灵犀指向遥控、灵犀悬浮触控、走焦类遥控、键盘、鼠标、手柄。

智能穿戴:触控屏、表冠。

这个表得记清楚,后面适配交互事件时就靠它判断哪些输入设备得适配。

再看视频播放暂停/播放这个功能在不同设备上的操作方式:

触控屏:单击屏幕。

鼠标:点击左键。

灵犀指向遥控:单击OK键。

走焦类遥控器:单击OK键。

键盘:按下空格键。

这五种操作方式得都适配,不然在某些设备上就没法用这功能。

三、交互归一咋实现

交互归一是个面向多设备输入的响应框架,把不同输入设备的交互行为抽象为同一事件,简化开发逻辑。

比如触屏点击、触控板点击、鼠标左键单击、遥控器OK键确认,这些都可以统一抽象为点击事件。遥控器功能键、键盘快捷键可以抽象为按键事件。

但交互归一不是把所有输入方式简单合并为单一事件,而是通过对不同设备的几十种底层交互事件进行语义抽象与归类,在保证交互差异可控的前提下,大幅减少事件类型数量。

最终形成的是一组标准化的交互API集合,开发者还得根据具体场景选择并适配相应的抽象事件,实现跨设备的一致性与灵活性兼顾的交互体验。

ArkUI框架提供了丰富的交互功能,支持直接处理基础输入事件,以及由这些事件驱动的手势事件,同时支持拖拽事件、焦点事件等复杂交互。

四、基础输入事件咋处理

基础输入事件就是用户用输入设备(触摸屏、键盘、鼠标、触控板)交互时,设备驱动层检测并生成原始信号,操作系统捕获这些底层信号,封装成标准化的基础事件,传递给上层应用处理。

基础事件分两类:指向性事件和非指向性事件。从事件的目标如何确定来理解这两类事件。

指向性事件:交互动作第一次开始时(手指按下、鼠标点击),用户的手指或鼠标指针碰到屏幕上的哪个组件,这个被命中的组件就成为整个交互过程的接收目标。包括触摸事件、鼠标事件、轴事件、手写笔事件。

场景案例:用手写笔在屏幕上书写,手写笔第一次点击的位置(比如画板),画板组件就是交互目标。

在这里插入图片描述

手写笔套件的示例代码:

if (canIUse('SystemCapability.Stylus.Handwrite')) {
  // Using the canIUse interface to prevent the stylus event from not being supported by some devices
  HandwriteComponent({
    handwriteController: this.controller,
    defaultPenType: PenType.PEN,
    defaultPenInfo: [{ penType: PenType.PEN, penWidth: this.penWidth },
      { penType: PenType.BALLPOINT_PEN, penWidth: this.ballpointPenWidth }] as PenHspInfo[],
    widthRatio: 1,
    heightRatio: 1,
  })
} else {
  Text($r('app.string.HandwriteDescInfo'))
}

这段代码的意思:

  • 先用 canIUse 判断设备是否支持手写笔事件
  • 支持就用 HandwriteComponent 组件,设置手写笔控制器、笔类型、笔宽度
  • 不支持就显示提示文本

非指向性事件:事件的接收者由当前焦点所在的组件决定。包括按键事件、表冠事件、焦点轴事件。

场景案例:用键盘填写表单,多个输入框之间可通过Tab键切换,当前获得焦点的输入框会被高亮显示。用户输入的字符内容会被系统视为针对该焦点输入框的交互。

按键事件的示例代码:

.onKeyEvent((event?: KeyEvent) => {
  if (event) {
    if (event.type === KeyType.Down) {
      this.eventType = 'Down';
    } else if (event.type === KeyType.Up) {
      this.eventType = 'Up';
    }
    this.keyText = event.keyText;
    this.sourceTool = event.keySource;
    this.getUserTextData();
  }
  return true;
})
.onMouse((event: MouseEvent): void => {
  if (event) {
    // ...
  }
})

这段代码的意思:

  • 用 onKeyEvent 监听按键事件
  • 判断按键类型是按下还是抬起
  • 获取按键文本和按键来源
  • 返回 true 表示已消费该事件,阻止事件继续冒泡

有个细节得注意:onKeyEvent 事件默认是冒泡的,在回调函数中,如果事件已被处理,建议返回 true,表示已消费该事件。这可以阻止事件继续冒泡,避免上层节点重复响应,防止按键事件被触发多次。

还有个细节:灵犀手写笔不响应 onHover 事件,这个特性只适用于其他型号的手写笔设备。

这张图展示了常见基础输入事件在不同输入设备上的触发方式,可以对照参考。

五、手势事件咋识别

手势是由一系列基础事件累积并满足特定条件后识别出的交互行为,比如点击就是快速按下并抬起。

ArkUI 中,系统组件默认支持常见手势(按钮支持点击事件),也可以在组件上绑定一个或多个手势。默认情况下,多手势识别会按照手势注册的顺序依次匹配和处理:

多个手势互斥执行(仅一个生效):用互斥识别机制,避免冲突响应。

多个手势同时响应:用并行识别机制,允许多个手势并发处理,提升交互灵活性。

精细控制哪些手势可参与识别与竞争:用手势冲突处理机制。

这张图展示了常见手势事件在不同输入设备上的触发方式,可以对照参考。

旋转手势的示例代码:

.gesture(
  RotationGesture()
    .onActionUpdate((event: GestureEvent) => {
      if (event) {
        // Obtain the rotation angle and change the rotation angle of the image
        this.angle = this.rotateValue + event.angle;
        this.sourceType = event.source;
        this.sourceTool = event.sourceTool;
        this.getUserTextData();
      }
    })
    .onActionEnd(() => {
      this.rotateValue = this.angle;
    })
)

这段代码的意思:

  • 用 RotationGesture 创建旋转手势
  • 在手势更新时获取旋转角度,更新图片旋转角度
  • 在手势结束时保存当前旋转角度

典型场景:宫格排列的界面元素,可以通过双指触发 PinchGesture 捏合手势,动态修改网格布局的 columnsTemplate 属性。比如视频应用中双指捏合实现视频元素的显示个数调整。

双指捏合调整视频列数的示例代码:

Grid() {
  // ...
}
// ...
.gesture(PinchGesture({ fingers: 2 }).onActionUpdate((event: GestureEvent) => {
  if (event.scale > 1 && this.currentWidthBreakpoint !== 'sm') {
    if (this.currentWidthBreakpoint === 'md') {
      this.getUIContext().animateTo({
        duration: 500
      }, () => {
        this.videoGridColumn = '1fr 1fr 1fr';
      })
    } else {
      this.getUIContext().animateTo({
        duration: 500
      }, () => {
        this.videoGridColumn = '1fr 1fr 1fr 1fr';
      })
    }
  } else if (event.scale < 1 && this.currentWidthBreakpoint !== 'sm') {
    if (this.currentWidthBreakpoint === 'md') {
      this.getUIContext().animateTo({
        duration: 500
      }, () => {
        this.videoGridColumn = '1fr 1fr 1fr 1fr';
      })
    } else {
      this.getUIContext().animateTo({
        duration: 500
      }, () => {
        this.videoGridColumn = '1fr 1fr 1fr 1fr 1fr';
      })
    }
  } else {
    Logger.info(`Two-finger operation is not supported`);
  }
}))

这段代码的意思:

  • 用 PinchGesture 创建双指捏合手势
  • 捏合放大时(scale > 1),根据当前断点调整视频列数
  • 捏合缩小时(scale < 1),根据当前断点调整视频列数
  • sm 断点不支持双指操作

这个功能挺实用的,用户可以双指捏合调整视频显示个数,体验更灵活。

六、焦点事件咋管理

用键盘、电视遥控器、车机摇杆或旋钮等非指向性输入设备与应用程序间接交互时,得把页面中可操作元素设置为可获焦状态,并配置获焦视觉效果,保证交互体验。

获焦/失焦:通过 onFocus 和 onBlur 事件监听组件的焦点变化。组件获焦时,遵循子组件优先原则。若子组件需获焦,其所有祖先组件均需可获焦。容器组件需获焦时,其子组件应不可获焦,并配置点击事件。

部分组件默认可获焦,比如 Button、TextInput 等基础组件和 Column、Row 等容器组件。若组件有获焦能力但默认不可获焦,比如 Text、Image 等组件,可设置 focusable(true) 使其可获焦。部分组件为无交互行为的组件,通常不可获焦,比如 Blank、Circle 组件。

走焦:触发走焦时,系统遍历组件树中可走焦的组件。当前焦点框架支持三种走焦算法:

线性走焦:按照子节点在节点树中的挂载顺序进行焦点导航。

投影走焦:适用于容器内子组件尺寸不一的场景,通过空间位置关系计算最佳焦点目标。

自定义走焦:开发者可通过 tabIndex 和 nextFocus 灵活自定义走焦逻辑,满足复杂交互需求。

<img src="https://contentcenter-vali-drcn.dbankcdn.cn/pvt_2/DeveloperAlliance_scene_100_1/40/v3/rL6gKWnPSSedMOD7QB73KQ/zh-cn_image_0000002513429655.gif?HW-CC-KV=V1&HW-CC-Date=20260413T053653Z&HW-CC-Expire=86400&HW-CC-Sign=1A6F40E17060BE6A83D7F896D4686F0815F93987D2EDFCE67B649D11295F4D00" title="null" crop="0,0,1,1" id="wvwRN" class="ne-image">

这张图展示了使用键盘走焦的效果,可以看到焦点从一个元素移动到另一个元素。

这张图展示了走焦样式的设计指南,获焦元素要有明显的视觉反馈。

走焦常用属性有五个:

focusable:设置当前组件是否可以获焦,参数是 boolean。

tabIndex:自定义组件Tab键走焦顺序值,参数是 number。

defaultFocus:设置当前组件是否为当前页面上的默认焦点,参数是 boolean。

tabStop:设置当前容器组件是否为走焦可停留容器,参数是 boolean。

nextFocus:设置当前容器组件的自定义走焦规则,参数是 Optional。

这张图展示了不同输入设备上支持触发焦点事件的方式,可以对照参考。

七、拖拽事件咋实现

拖拽发生在两个组件之间,它不是简单的单次输入,而是一个过程,通常包含以下步骤:

长按或点击组件A,触发拖拽。

保持按压或点击,持续将组件A向组件B拖拽。

抵达组件B中,释放按压点击,完成拖拽。

也可以在未抵达组件B的中途,释放按压点击,取消拖拽。

一个完整的拖拽事件包含多个拖拽子事件:

onDragStart:绑定A组件,触控屏长按/鼠标左键按下后移动触发。

onDragEnter:绑定B组件,触控屏手指、鼠标移动进入B组件瞬间触发。

onDragMove:绑定B组件,触控屏手指、鼠标在B组件内移动触发。

onDragLeave:绑定B组件,触控屏手指、鼠标移动退出B组件瞬间触发。

onDrop:绑定B组件,在B组件内,触控屏手指抬起、鼠标左键松开时触发。

这张图展示了不同输入设备上触发拖拽事件的方式,可以对照参考。

图片拖拽的示例代码:

.allowDrop([uniformTypeDescriptor.UniformDataType.IMAGE])
.onDrop((event: DragEvent) => {
  try {
    let dragData: UnifiedData = (event as DragEvent).getData() as UnifiedData;
    if (dragData !== undefined) {
      let records: unifiedDataChannel.UnifiedRecord[] = dragData.getRecords();
      if (records.length > 0) {
        for (let i = 0; i < records.length; i++) {
          let types = records[i].getTypes();
          if (types.includes(uniformTypeDescriptor.UniformDataType.FILE_URI)) {
            // Retrieve the image resource URLs from the record and assign them
            const fileUriUds =
              records[i].getEntry(uniformTypeDescriptor.UniformDataType.FILE_URI) as uniformDataStruct.FileUri;
            let typeDescriptor = uniformTypeDescriptor.getTypeDescriptor(fileUriUds.fileType);
            if (typeDescriptor.belongsTo(uniformTypeDescriptor.UniformDataType.IMAGE)) {
              this.targetImage = fileUriUds.oriUri;
            }
          }
        }
      } else {
        hilog.info(0x0000, TAG, `%{public}s`, `dragData arr is null`);
      }
    } else {
      hilog.info(0x0000, TAG, `%{public}s`, `dragData is undefined`);
    }
    this.dragSuccess = true;
  } catch (error) {
    const err = error as BusinessError;
    hilog.error(0x0000, TAG, `startDataLoading errorCode: ${err.code}, errorMessage: ${err.message}`);
  }
})

这段代码的意思:

  • 用 allowDrop 设置允许拖入的数据类型为图片
  • 用 onDrop 监听拖拽完成事件
  • 获取拖拽数据,判断数据类型
  • 如果是图片类型的文件URI,就获取图片资源地址并赋值

这个功能可以让用户把图片从其他应用拖入当前应用,体验很方便。

八、实际开发咋整

实际开发中,得根据业务场景明确需适配的目标设备。

比如视频类应用,通常需覆盖手机、平板、电脑及智慧屏。运动健康类应用,侧重手机与智能穿戴设备。

开发者应结合业务特点和用户高频使用场景,确定适配范围。在明确目标设备后,需进一步依据各设备支持的输入方式,针对性地设计和适配相应的交互事件。

以视频播放页的暂停/播放功能为例,开发步骤:

第一步:明确长视频类应用的目标适配设备为手机、平板、电脑及智慧屏。

第二步:根据设备支持的输入设备一览表,可知手机、平板、电脑和智慧屏支持的输入设备包括触控屏、手写笔、鼠标、键盘、手柄、灵犀指向遥控、灵犀悬浮触控、走焦类遥控。

第三步:实现播放/暂停功能,根据不同输入设备的交互方式,适配相应的事件处理机制。

触控屏、鼠标、灵犀指向遥控、走焦类遥控都可以通过 onClick 点击事件触发:

Flex({
  // ...
}) {
  Column() {
    // ...
}
.width('100%')
.onClick(() => {
  // 播放/暂停逻辑
})

键盘得监听特定按键的 onKeyEvent 按键事件:

.onKeyEvent((event?: KeyEvent) => {
  //If the key type is pressed, the subsequent code will not be executed, and the specific key logic will be executed when released.
  if (!event || event.type !== KeyType.Down) {
    return;
  }
  // Space key controls pause/play.
  if (event.keyCode === KeyCode.KEYCODE_SPACE) {
    this.avPlayerUtil!.playerStateControl();
  }
  //press ESC to exit full screen.
  if (event.keyCode === KeyCode.KEYCODE_ESCAPE) {
    this.windowUtil!.recover();
  }
  //Right-click fast forward
  if (event.keyCode === KeyCode.KEYCODE_DPAD_RIGHT) {
    this.avPlayerUtil!.fastForward();
  }
  //Left click to go back quickly
  if (event.keyCode === KeyCode.KEYCODE_DPAD_LEFT) {
    this.avPlayerUtil!.rewind();
  }
})

这段代码的意思:

  • 用 onKeyEvent 监听按键事件
  • 判断按键类型是按下,不是按下就不处理
  • 空格键控制播放/暂停
  • ESC键退出全屏
  • 方向右键快进
  • 方向左键快退

空格键的键码是 KEYCODE_SPACE,其他按键的键码可通过 keyCode 枚举查询,比如回车键对应 KEYCODE_ENTER。应用可根据实际交互需求,灵活适配不同按键。

开发者可根据业务场景明确适配的设备范围,并针对不同功能所支持的输入方式,灵活选择与之匹配的交互事件进行适配。

多设备交互这事儿看着复杂,其实就是根据不同设备的输入方式,适配相应的交互事件。核心是交互归一,把不同输入设备的交互行为抽象为同一事件,简化开发逻辑。

基础输入事件分两类:指向性事件和非指向性事件。指向性事件的目标由手指或鼠标指针触碰的位置决定,非指向性事件的目标由当前焦点所在的位置决定。

手势事件是由基础事件累积并满足特定条件后识别出的交互行为。焦点事件用于非指向性输入设备的交互,需要设置可获焦状态和获焦视觉效果。拖拽事件是一个过程,包含多个子事件。

实际开发时,得先明确适配的设备范围,然后根据设备支持的输入方式,适配相应的交互事件。代码示例可以直接参考,照着写就行。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐