轻规划鸿蒙开发实战22:高频录入场景下的 TextInput 软键盘遮挡自适应与物理退格优化

背景介绍

在“轻规划”(AeroPlan)的日常使用中,“今日随笔”、“灵感闪念记录”和“愿景信输入”都包含了高密度的文本录入需求。

对于这类长文本输入场景,开发者面临着两个非常顽固的交互体验稳定性风险:

  1. 软键盘遮挡输入框:当用户在屏幕中下方连续输入长文本时,一旦软键盘升起,输入框很容易被键盘完全盖住。用户只能盲打,看不见自己正在输入的字。
  2. 富文本(RichEditor)下的退格混乱:在“今日随笔”编辑中,我们使用 RichEditor 实现图文混排。在用户按下退格键(Backspace)时,如果光标前是一个特殊的“里程碑卡片”或“语音胶囊”,如果直接删除,可能会导致底层数据结构残缺,界面表现异常,甚至引发组件绘制的崩溃。
    轻规划鸿蒙开发实战22:高频录入场景下的 TextInput 软键盘遮挡自适应与物理退格优化-2.png
    今天,我们将聚焦高级文本输入与富文本交互,实战拆解如何通过系统级避让与 deleteBackward 接口优化,实现极其丝滑的录入交互。

1. 架构纵览:键盘事件分发与富文本删除拦截管线

为了保障高灵敏的编辑交互,当软键盘状态发生更新、或触发物理按键时,系统管线流程如下:

轻规划鸿蒙开发实战22:高频录入场景下的 TextInput 软键盘遮挡自适应与物理退格优化.png

在 HarmonyOS 架构中,软键盘的唤起与物理键值的输入是由系统的窗口管理服务(WindowManagerService)以及输入法框架(InputMethodFramework)协同调度的。当用户点击输入框唤起软键盘时:

  1. 焦点变化(Focus Event):目标组件获取焦点,激活输入法客户端(IMF Client)。
  2. 键盘拉起与高度计算:输入法服务端启动键盘进程并渲染键盘视图,同时将虚拟键盘在屏幕中所占的物理像素高度(px)上报给主窗口管理器。
  3. 窗口规整与避让回调:窗口管理器接收到高度变化后,若应用开启了规整或避让配置,会对视口高度进行裁切或平移,同时将 keyboardHeightChange 事件分发至应用的渲染主线程(ArkUI Engine)。
  4. UI重新测量与布局:ArkUI Engine 根据视口大小的变化重新计算 UI 组件树的 Layout 约束。开发者通过注册监听器获取键盘实时高度,并据此调整滚动容器(Scroll)的位移(Scroll Offset),完成输入框自适应防遮挡。
  5. 按键拦截管线:当用户在键盘上操作“退格键”时,IMF 会封装 deleteBackward 请求,将其分发给富文本组件(RichEditor)。富文本组件会在执行实际数据节点链表切除之前,优先调用 aboutToDelete 回调函数。此时应用可以决定是拦截并原子化处理,还是放通让系统执行默认的字符级删除。

2. 系统级软键盘避让与输入框可见度自适应

在 HarmonyOS 中,系统的 Window 默认提供了软键盘避让机制。我们在页面的主窗口中,可以设置避让模式为 “压缩布局(RESIZE)”“平移布局(OFFSET)”

避让模式对比与多维选型

为了在高频文本录入场景下做出最优的设计选择,我们可以根据下表进行选型:

维度 OFFSET(平移模式) RESIZE(压缩模式)
避让原理 窗口整体向上平移以露出当前获得焦点的输入框 应用可视区高度缩小,留出软键盘所占空间
头部导航栏表现 导航栏会被强行推到屏幕外,用户无法点击返回按钮 导航栏固定在顶部,仅下方内容区高度变小
滚动容器支持 较差,无法感知 Scroll 内部列表底部的动态伸缩 极佳,配合 Scroll/List 的弹性计算,可自适应滑动
性能损耗 极低(仅做矩阵变换或简单的窗口平移) 中(需要触发整个组件树的重新 Measure & Layout)
适用场景 登录页、单表单简单页面、输入框固定的场景 长随笔编辑页、聊天聊天框页、复杂的长表单页面
避坑指南:长页面滚动下的 Scroll 避让失效与主动高度感知避让器

当我们在 Scroll 内部嵌套 TextInput,且底部有大量留白或复杂布局时,一旦软键盘弹起,系统默认的避让行为可能会计算失准。这是因为系统在计算避让高度时,只考虑了当前聚焦 TextInput 的底部边界,而无法预估用户后续在滚动过程中,是否会触发其他需要规整的布局结构。

为了彻底解决此问题,我们设计了一套主动高度感知避让器。该避让器不仅依靠系统默认规整,更通过主动捕获 keyboardHeightChange 的回调,驱动 Scroll 组件进行平滑的增量位移补偿。

import window from '@ohos.window';
import { common } from '@kit.AbilityKit';

@Component
export struct ScrollInputContainer {
  // 声明软键盘高度状态变量(单位:vp),用于控制页面组件的弹性边距
  @State private keyboardHeight: number = 0;
  // 实例化滚动控制器,用于在软键盘弹起时主动控制视图滚动
  private scroller: Scroller = new Scroller();

  aboutToAppear() {
    // 获取当前组件上下文,并强转为 UIAbilityContext,用于获取窗口实例
    let context = getContext(this) as common.UIAbilityContext;
    
    // 异步获取当前应用的主窗口实例
    window.getLastWindow(context).then((win) => {
      // 注册软键盘高度变化的监听器,系统会将最新的高度(像素px)回调给应用
      win.on('keyboardHeightChange', (heightInPx: number) => {
        // 将系统返回的物理像素 px 转换为 ArkUI 推荐的视口独立像素 vp
        const heightInVp = px2vp(heightInPx);
        this.keyboardHeight = heightInVp;
        
        // 软键盘拉起时(高度大于 0)
        if (heightInVp > 0) {
          // 在系统避让的基础上,主动平滑滚动滚动条,将输入区域强行拉入用户最佳可视中心点
          // 延迟 50ms 以确保系统第一阶段的 Layout 布局计算已经完成
          setTimeout(() => {
            this.scroller.scrollBy(0, 150);
          }, 50);
        }
      });
    });
  }

  build() {
    // 将整个编辑区域包裹在 Scroll 滚动容器中
    Scroll(this.scroller) {
      Column() {
        // 模拟页面顶部大量的图文排版占位内容
        Text("轻规划 · 每日灵感收集")
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .margin({ top: 20, bottom: 20 })

        ForEach([1, 2, 3, 4, 5], (item: number) => {
          Column() {
            Text(`过往规划卡片 #${item}`)
              .fontSize(14)
              .fontColor('#666666')
          }
          .width('100%')
          .height(100)
          .backgroundColor('#F5F5F5')
          .borderRadius(8)
          .margin({ bottom: 12 })
          .justifyContent(FlexAlign.Center)
        }, (item: number) => item.toString())

        // 目标文本输入组件
        TextInput({ placeholder: "在此输入您的今日随笔..." })
          .height(48)
          .width('100%')
          .backgroundColor('#FFFFFF')
          .border({ width: 1, color: '#E0E0E0', radius: 8 })
          // 【避让核心】:根据键盘高度动态调整底部外边距,腾出空间以防系统级绘制重叠
          .margin({ bottom: this.keyboardHeight > 0 ? 30 : 150 })
          .padding({ left: 12, right: 12 })
      }
      .padding({ left: 16, right: 16 })
    }
    .width('100%')
    .height('100%')
    // 开启 Scroll 容器的阻尼回弹效果,提升滑动体验
    .edgeEffect(EdgeEffect.Spring)
    .backgroundColor('#FAFAFA')
  }
}

3. RichEditor 退格拦截:deleteBackward 极客调优

在“今日随笔”富文本编辑器中,用户可能会插入特定的结构化数据(例如“行程卡片”或“语音 Span”)。在 ArkUI 中,RichEditor 底层通过组织一个包含了 TextSpanImageSpan 的链表来承载多样式内容。

如果用户在这些特殊 Span 后面按下退格键(Backspace),系统默认的退格逻辑会尝试像对待普通字符那样,逐个像素或者逐个字符地删除。这不仅在视觉上会导致卡片被“撕裂”破坏,更严重的是会使富文本的数据结构残缺,引发未定义行为或稳定性风险。

我们必须在退格动作被应用到数据节点前进行拦截,实现原子化删除(Atomic Deletion)

拦截管线与删除逻辑设计

RichEditoraboutToDelete 接口提供了一个拦截机会。该回调接受一个 RichEditorDeleteValue 对象,并要求返回一个布尔值:

  • 返回 true:允许系统执行默认的字符退格逻辑。
  • 返回 false:拦截系统默认退格逻辑。此时开发者需要通过代码手动控制选区或 Span 节点的删除。
物理退格拦截核心代码

以下是我们在“轻规划”中为 RichEditor 量身定制的物理退格原子化拦截优化实现:

@Component
export struct RichDiaryEditor {
  // 声明富文本控制器实例,用于直接操作富文本的文本段和图片段
  private controller: RichEditorController = new RichEditorController();

  build() {
    Column() {
      RichEditor({ controller: this.controller })
        .width('100%')
        .height(300)
        .backgroundColor('#FFFFFF')
        .border({ width: 1, color: '#E5E5E5', radius: 8 })
        .padding(12)
        .onReady(() => {
          // 当 RichEditor 组件就绪后,预置一些说明文本以及一个行程大头针(ImageSpan)
          this.controller.addTextSpan("今天完成了新一期的鸿蒙开发规划,以下是我的出行路线:\n");
          this.controller.addImageSpan($r('app.media.icon'), {
            imageStyle: {
              size: ["24vp", "24vp"],
              verticalAlign: ImageSpanAlignment.BOTTOM
            }
          });
          this.controller.addTextSpan(" 点击这里可以查看详细的路线地图。");
        })
        // 【核心机制】:通过拦截即将删除的事件,重写物理退格逻辑
        .aboutToDelete((value: RichEditorDeleteValue) => {
          return this.handleDeleteBackward(value);
        })
    }
    .width('100%')
    .padding(16)
  }

  /**
   * 处理退格删除的拦截函数
   * @param value 包含当前删除动作所波及的字符区间以及 Span 数组的结构体
   * @returns 返回布尔值,代表是否放通默认的删除行为
   */
  private handleDeleteBackward(value: RichEditorDeleteValue): boolean {
    // 从删除事件数据包中获取即将受波及的 Span 列表
    const spans = value.richEditorSpans;
    
    // 如果没有受波及的 Span,放通默认逻辑
    if (!spans || spans.length === 0) {
      return true;
    }

    // 提取当前光标即将删除的第一个 Span 目标进行分析
    const targetSpan = spans[0];
    
    // 检查此 Span 是否是图片类型的 Span(我们在富文本中把里程碑卡片和行程大头针渲染为 ImageSpan)
    if (targetSpan.spanPosition.spanIndex !== undefined && targetSpan.value instanceof ImageSpanResult) {
      console.info("RichDiaryEditor", "Custom ImageSpan detected, performing atomic deletion...");
      
      // 获取当前 ImageSpan 在富文本链表中的确切起止字符位置(spanRange)
      const spanStartPos = targetSpan.spanPosition.spanRange[0];
      const spanEndPos = targetSpan.spanPosition.spanRange[1];

      // 通过 controller.deleteSpans 接口,传入该 Span 的起始与结束索引,一次性完整删除,避免残缺
      this.controller.deleteSpans({
        start: spanStartPos,
        end: spanEndPos
      });
      
      // 返回 false:明确告知 ArkUI 引擎,当前退格事件已由应用手动原子化处理,拒绝执行系统默认的单字符退格
      return false;
    }

    // 对于普通的 TextSpan(纯文字),直接返回 true,允许系统按正常方式逐字删除
    return true;
  }
}

轻规划鸿蒙开发实战22:高频录入场景下的 TextInput 软键盘遮挡自适应与物理退格优化-1.png

4. 极客避坑:软键盘消失时光标残留与“幽灵聚焦”

在长富文本编辑页面中,如果用户完成了随笔录入并点击了“保存”按钮,软键盘收起。然而,在部分旧版本的 HarmonyOS 运行期环境中,由于 FocusHub(焦点控制中心)与输入法窗口管理器之间的焦点状态未同步注销,虽然软键盘收回了,但 RichEditorTextInput 内部依然在闪烁光标。

此时,只要用户触碰屏幕上的任何其他非输入组件,系统都会错误地认为该输入框需要重新唤醒,导致软键盘灵异般自动弹起,影响用户的流式阅读体验。

避坑指南:显式释放焦点与光标隐藏

为了彻底解决这种“幽灵聚焦”的问题,我们不能单纯依靠输入法框架去隐藏键盘,而是要在保存事件或点击空白区域的瞬间,主动将当前激活的输入组件焦点剥离,重定向至页面顶部的非交互性容器。

@Entry
@Component
struct DiaryDetailPage {
  // 定义控制随笔编辑器聚焦状态的标识符,用于条件分支管理
  @State private isEditing: boolean = true;
  private controller: RichEditorController = new RichEditorController();

  build() {
    // 根容器指定唯一标识 ID:'root_container_id',用于承接焦点的转移
    Column() {
      // 顶部的自定义标题栏,用来充当焦点避风港
      Row() {
        Text("编辑随笔")
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
        
        Blank()
        
        Button("保存并完成")
          .onClick(() => {
            this.saveDiaryAndDismissKeyboard();
          })
          .backgroundColor('#007DFF')
          .fontColor('#FFFFFF')
      }
      .width('100%')
      .height(56)
      .padding({ left: 16, right: 16 })
      .backgroundColor('#FFFFFF')
      .border({ width: { bottom: 1 }, color: '#EEEEEE' })
      .id('title_bar_container') // 赋予标题栏一个可以寻址的 ID

      // 文本编辑区域
      RichEditor({ controller: this.controller })
        .width('100%')
        .height(300)
        .margin({ top: 16 })
        .backgroundColor('#FFFFFF')
        .padding(12)

      // 页面下方可能存在的其他内容展示
      Column() {
        Text("注意:请及时保存以同步云端规划。")
          .fontSize(12)
          .fontColor('#999999')
      }
      .margin({ top: 40 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F7F7F7')
    // 将根容器设置为可获焦,以便作为全局焦点容器来承接重定向
    .focusable(true)
    .id('root_container_id')
  }

  /**
   * 保存随笔并安全地释放当前组件焦点,规避幽灵聚焦
   */
  private saveDiaryAndDismissKeyboard() {
    // 1. 执行随笔落盘持久化与网络队列提交
    console.info("RichDiaryEditor", "Diary content saved successfully.");
    
    // 2. 强行将焦点转移给页面最顶部的非输入容器组件,消除 RichEditor/TextInput 的光标残留状态
    // focusControl.requestFocus 接收目标组件的 ID,成功转移后输入框的光标会自动熄灭并通知系统键盘安全离场
    let focusSuccess = focusControl.requestFocus('root_container_id');
    
    if (focusSuccess) {
      console.info("RichDiaryEditor", "Focus released. Keyboard dismissed safely.");
    } else {
      // 若根节点转移失败,尝试退而求其次转移给标题栏组件
      focusControl.requestFocus('title_bar_container');
    }
  }
}

5. 性能与稳定性最佳实践

在高密度的文本录入与高频触发的布局自适应过程中,如果频繁重建布局树,可能导致打字卡顿或帧率(FPS)抖动。为保证录入过程流畅如水,建议遵循以下最佳实践:

  1. 避免键盘高度监听中的高耗时操作:在 keyboardHeightChange 回调中,仅进行必要的滚动计算或状态变量赋值。绝对禁止在回调内发起同步网络请求、文件 I/O 读写,或者执行复杂的过滤运算。
  2. 配合弹性动画平滑过渡:当键盘弹起、组件底部边距(Margin)发生改变时,如果界面产生瞬时的硬切,容易产生闪烁感。可以使用 ArkUI 内置的属性动画 animation,让 Margin 的变化跟随键盘高度实现平滑渐变。
  3. 保持数据链表一致性:对富文本中非文本 Span 的删除,尽量包裹在 controller.deleteSpans 事务中进行一次性清除,切忌拆分成多次删除。这能有效防范由于节点断裂而导致的界面重绘崩溃。

6. 总结与下期预告

通过主动高度感知 Scroll 避让、RichEditor aboutToDelete 拦截,以及焦点重定向控制,我们为“轻规划”建立了一套高容错性、零死角的长文本录入系统。

在高负载的端侧编辑与持久化中,如果我们处于偏远的山区或者没有网络连接的极端离线环境,AI 智能拆解服务该怎么做?

在下一篇文章中,我们将踏入数据容灾与本地 NLP 降级:无网络极端离线环境下的“本地降级”与日历 AutoSync 协同防御策略! 敬请期待。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐