👋 你好,欢迎来到我的博客!我是【菜鸟学鸿蒙】
   我是一名在路上的移动端开发者,正从传统“小码农”转向鸿蒙原生开发的进阶之旅。为了把学习过的知识沉淀下来,也为了和更多同路人互相启发,我决定把探索 HarmonyOS 的过程都记录在这里。
  
  🛠️ 主要方向:ArkTS 语言基础、HarmonyOS 原生应用(Stage 模型、UIAbility/ServiceAbility)、分布式能力与软总线、元服务/卡片、应用签名与上架、性能与内存优化、项目实战,以及 Android → 鸿蒙的迁移踩坑与复盘。
  🧭 内容节奏:从基础到实战——小示例拆解框架认知、专项优化手记、实战项目拆包、面试题思考与复盘,让每篇都有可落地的代码与方法论。
  💡 我相信:写作是把知识内化的过程,分享是让生态更繁荣的方式。
  
   如果你也想拥抱鸿蒙、热爱成长,欢迎关注我,一起交流进步!🚀

前言

聊天页、评论框、表单页,这类页面有一个共同特征:底部常驻一个输入栏,左边是输入框,右边是发送按钮。软键盘弹出来的那一刻,问题出现了——输入框还在,发送按钮消失了,或者整条底部栏都被键盘压进了屏幕底部看不见的位置。

系统的自动避让只保证焦点输入组件可见,并不关心"输入框旁边的按钮"。这是自动避让机制的设计边界,不是 bug。要让整个底部输入栏都随键盘上浮,需要切换到手动避让模式。

这篇文章围绕这个具体场景,梳理官方的手动避让方案,搭建一个最小底部输入栏 Demo,并整理几个高频踩坑点。

一、自动避让到底做了什么,为什么不够

HarmonyOS 系统内置了自动键盘避让能力。键盘弹出时,系统根据键盘高度对窗口根节点进行压缩,让当前获得焦点的组件上移到键盘之上。

这在登录页这类简单场景里效果不错:整屏只有一两个独立输入框,系统压缩根节点,输入框自然上移,没什么问题。

底部输入栏场景的结构不同:

┌─────────────────────────────┐
│        消息/内容列表          │  ← 占据页面主体
├─────────────────────────────┤
│  TextInput    │  Button(发送) │  ← 底部固定栏
└─────────────────────────────┘

键盘弹出后,系统压缩根节点,整体页面高度变小。底部输入栏因为没有足够的空间而被挤压或者推出可视区域。TextInput 获得焦点,系统会优先保证它可见,但 Button 和它是同一个 Row 容器里的兄弟节点,系统不保证这个容器整体可见。

结果就是:TextInput 可见,Button 消失,或者整个底部栏被截断。

手动避让的思路是:主动告诉系统不要压缩页面,由开发者自己监听键盘高度变化,将底部栏精确地向上偏移对应距离。

二、手动避让的官方机制

根据 IME Kit 开发指南和 expandSafeArea 接口文档,手动避让分三步。

关闭系统自动压缩

在页面根节点(通常是最外层 Column 或 Stack)上设置:

.expandSafeArea([SafeAreaType.KEYBOARD])

SafeAreaType.KEYBOARD 从 API version 10 开始支持,表示软键盘区域。加上这个属性之后,键盘弹出时系统不再压缩页面高度,改为在根节点底部增加一个等于键盘高度的 padding——但在手动避让模式下,我们会自己处理偏移,不依赖这个 padding。

监听键盘高度变化

通过 inputMethod.getController() 获取 InputMethodController 实例(API 10+ 推荐用 getController(),早期版本用 getInputMethodController()),然后注册 keyboardHeightChange 事件:

inputController.on('keyboardHeightChange', (height: number) => {
  // height 单位为 px
  this.keyboardHeight = height;
});

此事件从 API version 10 开始支持,无需额外权限,普通应用可用。height 单位是 px,键盘弹出时值大于 0,键盘收起时回调值为 0。

注意:官方文档明确提到,window.on('keyboardHeightChange') 是系统 API,普通应用不可用,不要混淆。

在回调里调整布局

拿到键盘高度后,对底部输入栏容器做 translate 偏移:

.translate({ y: -this.keyboardHeight })

translate 属性的 y 为负值表示向上移动,单位同样是 px,与 keyboardHeight 回调返回的单位一致,直接使用即可,不需要 vp 换算。

三、最小 Demo:底部输入栏避让

下面这个 Demo 目标是:键盘弹出时,底部 Row(包含 TextInput 和发送按钮)整体上移,消息列表同步缩短,不遮挡任何交互元素。

根据官方接口定义,按照手动避让三步骤组织为最小示例:

import { inputMethod } from '@kit.IMEKit';

@Entry
@Component
struct ChatInputDemo {
  // 键盘高度状态变量,变化时触发 UI 重新渲染
  @State keyboardHeight: number = 0;

  // API 10+ 推荐使用 getController()
  private inputController: inputMethod.InputMethodController = inputMethod.getController();

  // 模拟消息列表数据
  private messages: string[] = [
    '你好', '最近怎么样', '有空聊聊', '好的', '我在',
    '稍等', '发给我一下', '收到', '好的没问题', '好的好的',
  ];

  build() {
    Column() {
      // ① 消息列表区域:随键盘高度动态调整底部 padding,避免内容被输入栏遮挡
      List({ space: 8 }) {
        ForEach(this.messages, (msg: string, index: number) => {
          ListItem() {
            Text(msg)
              .padding(12)
              .backgroundColor('#E8E8E8')
              .borderRadius(8)
              .width('auto')
              .alignSelf(ItemAlign.End)
          }
        })
      }
      .layoutWeight(1)
      .width('100%')
      .padding({ left: 16, right: 16, bottom: 8 })

      // ② 底部输入栏:作为整体进行 translate 偏移
      Row({ space: 8 }) {
        TextInput({ placeholder: '说点什么…' })
          .layoutWeight(1)
          .height(40)

        Button('发送')
          .width(64)
          .height(40)
          .fontSize(14)
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 8, bottom: 8 })
      .backgroundColor(Color.White)
      // 核心:将整个底部栏向上偏移键盘高度
      .translate({ y: -this.keyboardHeight })
    }
    .width('100%')
    .height('100%')
    // 步骤 1:关闭自动压缩,交给手动控制
    .expandSafeArea([SafeAreaType.KEYBOARD])
  }

  aboutToAppear(): void {
    // 步骤 2:注册键盘高度变化监听
    try {
      this.inputController.on('keyboardHeightChange', (height: number) => {
        // 步骤 3:更新状态变量,触发 translate 重新计算
        this.keyboardHeight = height;
      });
    } catch (err) {
      console.error(`键盘监听注册失败: ${err.code}, ${err.message}`);
    }
  }

  aboutToDisappear(): void {
    // 页面销毁时取消监听,避免内存泄漏
    try {
      this.inputController.off('keyboardHeightChange');
    } catch (err) {
      console.error(`键盘监听注销失败: ${err.code}, ${err.message}`);
    }
  }
}

这段代码解决什么问题: 键盘弹出时,通过 translate 将包含输入框和发送按钮的整个 Row 容器整体上移,而不是单独处理输入框。发送按钮不会再"失踪"。

真正需要关注的几个地方:

  • expandSafeArea([SafeAreaType.KEYBOARD]) 加在根节点 Column 上,这是关闭自动压缩的关键,缺少这一步,系统会同时做自动压缩,translate 偏移就会叠加,导致底部栏超出屏幕上方。
  • translate({ y: -this.keyboardHeight }) 只加在底部 Row 上,不要加在根节点。根节点负责撑满全屏,不能跟着移动。
  • keyboardHeight 是 @State 变量,变化时 ArkUI 自动触发 Row 重新渲染,translate 随之生效。

四、消息列表怎么配合

光让底部栏上移还不够。键盘弹出后,列表底部会被底部输入栏盖住最后几条消息。

处理思路是给 List 的底部 padding 随键盘高度动态变化:

List({ space: 8 }) {
  // ...
}
.layoutWeight(1)
.padding({
  left: 16,
  right: 16,
  // 键盘高度 > 0 时,额外增加底部内边距,让最后一条消息不被遮挡
  bottom: this.keyboardHeight > 0 ? this.keyboardHeight + 56 : 8
})

这里 56 是底部输入栏的高度估算值(padding 8*2 + TextInput 40),实际项目中建议通过 onAreaChange 精确获取输入栏高度,而不是硬编码。

如果希望键盘弹出和收起时列表滚动有平滑过渡,可以在状态变量更新时包一层 animateTo:

this.inputController.on('keyboardHeightChange', (height: number) => {
  animateTo({ duration: 200, curve: Curve.EaseOut }, () => {
    this.keyboardHeight = height;
  });
});

animateTo 从 API version 7 开始支持,Curve.EaseOut 适合跟随键盘弹出的动效节奏。

五、几个关键点拆开看

1. expandSafeArea 和 translate 必须配对使用

如果只加 translate 不加 expandSafeArea,系统自动避让和手动 translate 会同时生效,底部栏会偏移两倍距离,跑出屏幕外。

如果只加 expandSafeArea 不加 translate,系统不压缩页面,底部栏留在原位,键盘直接盖住它。

两者必须同时存在,缺一就是另一种"发送按钮找不到"。

2. keyboardHeight 回调单位是 px,不需要换算

translate 的 y 接受 number | string,当传入 number 类型时单位是 vp;但官方键盘避让示例中直接将回调的 px 值赋给 translate,这里的行为以官方示例和实际验证为准。实际项目中建议在目标设备上检查偏移是否精确,如有偏差可尝试通过 vp2px / px2vp 工具函数转换。

3. 监听必须在组件生命周期内注册和注销

aboutToAppear 中注册,aboutToDisappear 中调用 off('keyboardHeightChange')。如果页面跳转后没有注销,旧页面的回调还在运行,会导致状态更新到已销毁的组件,引发不可预期的问题。

4. 折叠屏状态变化时需要重新处理

官方文档明确提到:折叠屏设备折叠状态改变时,键盘高度可能随之变化,需要在对应生命周期中重新注册监听。折叠态和展开态的可视区域尺寸不同,键盘高度也不同。如果应用运行在折叠屏上,需要监听折叠状态变化(display.on('foldStatusChange')),在回调中重新执行监听注册逻辑。这属于折叠屏适配的独立话题,这里只做提示。

5. 分屏场景下键盘高度变化更频繁

分屏模式下,应用窗口可能随时被调整大小,键盘高度也会跟着变化。手动避让方案本身能覆盖这种场景,因为 keyboardHeightChange 事件会在每次高度变化时触发,回调中重新计算偏移量即可。需要注意的是,分屏下窗口宽度变化可能使布局产生其他问题,这和键盘避让是两个独立的适配维度。

六、容易踩坑的地方

误区一:把 expandSafeArea 加在 TextInput 或 Row 上

官方文档说明得很清楚:expandSafeArea 放在哪个组件上,就只有那个组件的绘制区域延伸到安全区之外,且父节点也必须允许其绘制到对应区域。如果不加在根节点上,效果不可预期。建议始终加在页面最外层容器上。

误区二:用 margin 或 padding 做偏移,而不是 translate

修改 margin 和 padding 会影响整个布局流,导致父容器重新计算高度,可能挤压列表区域。translate 是视觉偏移,不影响布局计算,底部栏的原始占位不变,列表区域不会因此收缩。

误区三:keyboardHeightChange 没有用 try/catch 包裹

on 和 off 都有可能抛出错误码,官方接口文档列出了 12800006(InputMethodController 错误)。不加 try/catch,注册失败时没有任何提示,键盘避让静默失效,定位很麻烦。

误区四:只处理了 TextInput 的避让,忽略了操作按钮

这就是文章开头描述的问题根源。自动避让只保证焦点组件可见。手动避让中,避让的单位是整个底部容器,TextInput 和发送按钮在同一个 Row 里,对 Row 做 translate,两者同时上移。不要对 TextInput 单独做偏移。

七、实际项目中怎么排查

遇到键盘避让异常,建议按以下顺序检查:

  1. 检查 API Level:expandSafeArea 和 on('keyboardHeightChange') 均从 API 10 开始支持,先确认工程 compileSdkVersion 和目标设备版本。

  2. 检查 expandSafeArea 的位置:是否加在页面根节点,父节点是否允许子节点绘制到键盘区域。

  3. 检查 translate 是否加在整个底部容器上:不是 TextInput,不是 Button,是包含它们的 Row/Column。

  4. 检查监听是否成功注册:在 keyboardHeightChange 回调里打一条 console 日志,确认弹键盘时是否有输出,以及 height 值是否合理(一般在 900px 到 1400px 之间,因设备而异)。

  5. 检查 expandSafeArea 和 translate 是否同时存在:单独使用其中一个都会出问题,两者缺一不可。

  6. 折叠屏/分屏场景:检查折叠状态变化时是否重新注册了监听,以及监听注销逻辑是否正确。

开发经验总结

  • 自动避让保证焦点组件可见,不保证焦点组件的兄弟节点可见。底部输入栏需要手动避让。
  • 手动避让的核心是两件事:用 expandSafeArea([SafeAreaType.KEYBOARD]) 关掉系统压缩,用 on('keyboardHeightChange') 拿到键盘高度,对整个底部容器做 translate。
  • 消息列表的底部 padding 也需要随键盘高度动态调整,否则最后几条消息会被遮住。
  • keyboardHeightChange 的注册和注销要与组件生命周期严格绑定。
  • 折叠屏和分屏是独立的适配维度,与键盘避让叠加时需要分别处理。

如果你正在开发类似聊天或评论页面,可以先检查一下:键盘弹出时,底部栏的 translate 偏移量是否和 keyboardHeight 回调值一致,以及 expandSafeArea 是否确实加在了根节点上。这两个地方的配合如果不对,现象往往是"输入框在,按钮没了"或者"底部栏跑出屏幕外",两者指向的原因恰好相反。

📝 写在最后

如果你觉得这篇文章对你有帮助,或者有任何想法、建议,欢迎在评论区留言交流!你的每一个点赞 👍、收藏 ⭐、关注 ❤️,都是我持续更新的最大动力!

我是一个在代码世界里不断摸索的小码农,愿我们都能在成长的路上越走越远,越学越强!

感谢你的阅读,我们下篇文章再见~👋

✍️ 作者:菜鸟不学编程
🧵 本文原创,转载请注明出处。

Logo

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

更多推荐