一个聊天页面,中部 Scroll 撑满,底部固定输入栏,TextInput 蹲在 Column 最下面。点输入框,键盘弹起来,输入框被挡得严严实实,光标都看不见。你给底部输入栏加了一行 expandSafeArea([SafeAreaType.KEYBOARD], [SafeAreaEdge.BOTTOM]),心想“这下该避让了吧”。键盘再弹,输入框还在原地,日志干干净净,onFocus 正常触发,位置一动不动。

你把 expandSafeArea 删了,重新跑,输入框还是不动。于是你开始怀疑键盘高度回调没触发、怀疑 Scroll 把布局撑死了、怀疑 HarmonyOS 7 的键盘避让坏了。但真正的问题有两个,而且叠在一起:expandSafeArea 那行的语义和你理解的正好相反,它声明的是“这个组件不避让键盘”,不是“这个组件要避让键盘”;而默认的 OFFSET 避让模式,只保证“光标可见”,它根本不为输入栏下方新插入的候选列表或工具条预留空间。

expandSafeArea 是“声明不避让”,不是“开启避让”

先把这条钉死。官方最佳实践原文:当 expandSafeArea 的 type 设为 SafeAreaType.KEYBOARD 时,系统会将软键盘区域视作安全区,从而不会避让软键盘。如果希望某些组件不避让键盘,才给它设置 expandSafeArea。

你把这一行加在整页根 Column 上,等于告诉系统“这一整页都不要避让键盘”,底部输入栏当然被盖住。正确用法是“挑不该被顶走的组件加”,比如上抬模式下需要保持不动的顶部标题栏;挂在根容器上就是全页放弃避让。你看到的“TextInput 的 onFocus 正常、位置一动不动、无任何报错”,正是官方描述的“配置生效但语义相反”,不是日志漏了。

OFFSET 只保证光标可见,不保证输入栏可达

删掉那行 expandSafeArea 之后,为什么还是不动?因为默认避让模式是 KeyboardAvoidMode.OFFSET,它的物理行为是把整个 Page 内容向上平移键盘高度的距离。但“平移”不等于“保证输入栏完整可达”。

官方的描述是:默认策略保证输入框不会被软键盘遮挡,输入框上方的组件会跟着抬起,下方的组件不会露出;而且“当输入光标距离屏幕底部的高度大于键盘高度时,page 不会抬起”。你的聊天页里,TextInput 位于底部输入栏内部,光标位置本身可能已经高于键盘顶边,或者即使低于,OFFSET 抬起之后输入栏下方的工具条、表情面板入口仍然被键盘盖住。你看到的现象——“输入框被挡、页面不动、日志干净”——不是避让没触发,是 OFFSET 的语义本来就不覆盖你期望的那个范围。

typescript

// 默认 OFFSET 不是万能避让,它只保证光标可见
// 对于底部固定输入栏 + 上方可滚动区的聊天页,OFFSET 不够

聊天/评论页的正确结构:RESIZE + layoutWeight,而不是 OFFSET

需要整体压缩而不是整体平移时,正确的模式是 KeyboardAvoidMode.RESIZE。RESIZE 的物理行为是压缩 Page 可用高度:Page 下设置百分比宽高的组件会跟随压缩,直接设置固定宽高的组件保持原尺寸。

聊天页的标准结构是:根 Column 全高,中间消息区 layoutWeight(1) 吸收压缩量,底部输入栏固定高度,留在正常布局中。键盘弹起时,Page 高度被压缩,layoutWeight(1) 的消息区收缩,底部输入栏被“推”到键盘上方,而不是被整体平移推出屏幕。

typescript

import { KeyboardAvoidMode } from '@kit.ArkUI';

@Entry
@Component
struct ChatPage {
  aboutToAppear(): void {
    // 关键:切换为压缩模式,而不是默认的 OFFSET
    this.getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE);
  }

  build() {
    Column() {
      // 顶部标题栏:固定高度,不参与压缩
      Row() { Text('聊天') }
        .height(56)

      // 中间消息区:layoutWeight(1),键盘弹起时被压缩
      List() {
        // 消息列表
      }
      .layoutWeight(1)        // 这一行是 RESIZE 能生效的前提

      // 底部输入栏:固定高度,留在正常布局中
      Row() {
        TextInput({ placeholder: '输入消息' })
          .layoutWeight(1)
        Button('发送')
      }
      .height(56)
    }
    .height('100%')
    // 不要在这里加 expandSafeArea([SafeAreaType.KEYBOARD])
    // 那行是“不避让”,不是“避让”
  }
}

layoutWeight(1) 那一行是 RESIZE 能生效的核心前提。如果没有一个可压缩的弹性区域吸收窗口高度的变化,RESIZE 压缩 Page 之后,固定高度的组件不会跟着缩,输入框要么被挤出可视区,要么和键盘重叠。官方问答里反复强调的结构就是“顶部固定 + 中间 layoutWeight(1) + 底部固定”。

RESIZE 下外层套了 Scroll,压缩量传不进去

还有一个变体会让 RESIZE 看起来“不生效”。如果你在 layoutWeight(1) 的中间区域外面套了一层 Scroll,并且 Scroll 自身没有弹性约束,RESIZE 压缩的是 Page 可用高度,但 Scroll 的视口高度由它自己的 height 或父容器约束决定。固定高度或外层 Scroll 包裹时,List / Scroll 不跟随窗口压缩,可视区高度不变,键盘占用的物理空间仍然会把底部输入栏盖住。

解法不是把 Scroll 删掉,而是让 Scroll 本身也成为可压缩链条的一部分:Scroll 设 layoutWeight(1),内部内容按百分比或弹性高度布局,不要给 Scroll 一个固定的 height。压缩量从 Page 传到 Scroll,再从 Scroll 传到它的视口,整个链条才通。

自定义键盘的避让是另一个开关,不设就默认不避让

如果你用了 customKeyboard,还有一条独立的规则:自定义键盘默认不开启避让,需要手动设置 TextInput 的 customKeyboard 属性中 supportAvoidance: true,系统才会提供自定义键盘的避让能力。这和在 EntryAbility 里设置 KeyboardAvoidMode 是两套不同的开关,一个管系统键盘的页面避让,一个管自定义键盘的避让。只设了前者没设后者,系统键盘正常,自定义键盘照样遮挡。

排查顺序:先看 expandSafeArea 用反没有,再看避让模式,最后看弹性链条

下次“键盘弹了页面纹丝不动”的时候,按这个顺序走,比反复调 offset 快。

第一步,全局搜索 expandSafeArea([SafeAreaType.KEYBOARD]。如果它加在整页根节点或者底部输入栏的父容器上,先删掉或者挪到“不该被顶走的顶部栏”上。它的语义是“不避让”,不是“避让”。

第二步,确认当前 KeyboardAvoidMode 是不是默认的 OFFSET。聊天页、评论页、任何底部有固定输入栏的页面,OFFSET 的“只保证光标可见”通常不够,切到 RESIZE。

第三步,检查 RESIZE 生效的结构前提:根 Column 是否有 layoutWeight(1) 的弹性区域吸收压缩量,底部输入栏是否固定高度且留在正常布局流里,外层 Scroll 是否也给了 layoutWeight 而不是固定 height。

第四步,如果用了 customKeyboard,确认 supportAvoidance: true 有没有设。系统键盘和自定义键盘的避让是两套开关,别只设一个。

expandSafeArea 的键盘参数是一个“负面声明”,它标记的是“这里不要避让”;OFFSET 的默认避让只覆盖光标可见,不覆盖底部输入栏的完整可达;RESIZE 把压缩量交给 layoutWeight 去吸收,但弹性链条上任何一环写了固定高度,压缩就传不过去。下一次输入框被键盘挡住、日志却干干净净的时候,先别怀疑键盘回调没触发——打开代码看一眼,expandSafeArea 那一行,是不是正好把你想避让的东西声明成了“不避让”。

Logo

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

更多推荐