对应Demo: KeyboardDemo | 难度: 高级 | 关键词: setKeyboardAvoidMode, KeyboardAvoidMode, keyboardHeightChange, 软键盘避让
我做聊天页面的时候,差点把键盘给砸了------不是物理键盘,是软键盘。需求很简单:底部一个输入栏,点击输入框弹出软键盘,输入栏跟着键盘上去,聊天记录不被遮挡。听起来多简单的事儿?我在Android上用android:windowSoftInputMode="adjustResize"一行配置就搞定了。结果到HarmonyOS这边,我从下午搞到凌晨两点,底部输入栏要么被键盘完全盖住,要么整个页面被顶上去连标题栏都看不见了,要么输入栏上去了但聊天记录底部被截断,要么页面上下跳动跟蹦迪似的。
最离谱的是,我搜了一堆资料,有的说用window.setKeyboardAvoidMode,有的说手动监听键盘高度做偏移,还有的说用expandSafeArea。我把这些方案全试了一遍,每个都有bug。后来才搞清楚------HarmonyOS的键盘避让机制经历过好几个版本的变更,网上很多资料都是旧版本的做法,在新版本上根本不work。比如那个window.setKeyboardAvoidMode,在当前版本根本不存在于window对象上------这个方法是通过UIContext调用的!
还有一个让人崩溃的坑------keyboardHeightChange回调返回的键盘高度单位是PX,不是VP!我直接拿这个值当VP用了,偏移量差了好几倍,输入栏要么飞出屏幕,要么只上移了一点点还是被挡住。排查了两个小时才发现是单位搞错了。
今天这篇,我把键盘避让从原理到实战全部讲清楚,帮你少走我走过的弯路。

键盘避让的三种模式
HarmonyOS提供了三种键盘避让模式,通过setKeyboardAvoidMode设置。不同模式的行为完全不同,选错了模式后面怎么调都不对。
| 模式 | 数值 | 行为 | 适用场景 |
|---|---|---|---|
| DEFAULT | 0 | 页面上移(整体推上去),底部内容可能超出屏幕 | 普通表单页面 |
| RESIZE | 1 | 页面高度压缩,布局重新计算 | 聊天页面、底部有固定输入栏的页面 |
| NONE | 4 | 不做任何避让,键盘直接覆盖内容 | 有悬浮控件的页面、手动处理避让 |
默认是DEFAULT模式。如果你的应用什么都没设置,用的就是DEFAULT。
设置避让模式------关键:通过UIContext调用
这里有个巨大的坑------setKeyboardAvoidMode不是通过window对象调用的,而是通过UIContext。我第一次写的时候搜到的资料说用window.setKeyboardAvoidMode,直接在window对象上调用,编译没报错但运行时方法不存在。后来翻官方文档才发现是通过UIContext调用的。
typescript
// ❌ 错误:window对象上没有setKeyboardAvoidMode
let context = this.getUIContext().getHostContext() as common.UIAbilityContext
let win = window.getLastWindow(context)
win.setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE) // 运行时报错!方法不存在
// ✅ 正确:通过UIContext设置
this.getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE)
我后来在技术社区里看到好几个人问"setKeyboardAvoidMode为什么报错",十有八九都是在window对象上调用的。这个问题太常见了,因为旧版本的文档和教程确实是在window上调用的,后来API改了位置但很多资料没更新。
在页面中设置
最常用的做法是在页面的aboutToAppear里设置避让模式:
typescript
@Entry
@Component
struct ChatPage {
aboutToAppear(): void {
// 设置键盘避让模式为RESIZE(压缩模式)
this.getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE)
}
}
在Ability中全局设置
如果你想所有页面都用同一种避让模式,可以在EntryAbility里设置。但这个比较麻烦,因为UIContext需要在页面加载后才能获取。我更推荐在页面级别设置------不同页面可能需要不同的避让模式。
DEFAULT模式:页面上移
DEFAULT模式的行为是:键盘弹出时,整个页面向上移动,让当前获得焦点的输入框可见。页面的高度不变,只是位置上移了。
这个模式有个明显的问题------底部固定的内容可能被推出屏幕。比如你的页面底部有个固定定位的按钮或者输入栏,键盘弹出来后,整个页面上移,底部元素就跑到屏幕上面去了------输入框是可见了,但操作按钮和输入栏不见了。
没有键盘时:
┌──────────────┐
│ 标题栏 │
│ │
│ 输入框 │
│ │
│ 底部按钮 │
└──────────────┘
DEFAULT模式键盘弹出后:
┌──────────────┐
│ 标题栏 │ ← 还在,但上方可能有空白
│ 输入框 │ ← 可见,页面上移了
│ │
├──────────────┤
│ 软键盘 │
└──────────────┘
底部按钮?被推到屏幕上方了!看不见!
DEFAULT模式适合那种不需要底部固定元素的普通表单页面------输入框在页面中间偏上的位置,键盘弹出来后输入框还是可见的,没有底部元素被遮挡的问题。比如登录页面、注册页面,输入框在中间,下方只有个"登录"按钮,键盘弹出后按钮虽然被推走了但用户已经填完信息了。
但聊天页面绝对不能用DEFAULT模式 ------底部输入栏是核心交互元素,被推走就完蛋了。

RESIZE模式:页面压缩------聊天页面的救星
RESIZE模式的行为是:键盘弹出时,页面的可用高度被压缩,布局重新计算。相当于页面高度从屏幕高度变成了屏幕高度 - 键盘高度。
这个模式是做聊天页面的最佳选择------页面高度压缩后,底部的输入栏会自然地"浮"在键盘上方,聊天记录区域也会缩小,不会被遮挡。
没有键盘时:
┌──────────────┐
│ 标题栏 │
│ │
│ 聊天记录 │ ← 占满剩余空间
│ │
│ 输入栏 │ ← 底部固定
└──────────────┘
RESIZE模式键盘弹出后:
┌──────────────┐
│ 标题栏 │ ← 位置不变
│ │
│ 聊天记录 │ ← 高度被压缩了
│ │
│ 输入栏 │ ← 在键盘上方
├──────────────┤
│ 软键盘 │
└──────────────┘

聊天页面的核心布局
RESIZE模式下的聊天页面布局有个关键------用Column+layoutWeight(1)让聊天记录区域自动填充剩余空间。键盘弹出后,页面高度缩小,聊天记录区域跟着缩小,输入栏始终在底部。
typescript
@Entry
@Component
struct ChatPage {
@State messages: string[] = ['你好', '你好呀', '今天天气真好']
@State inputText: string = ''
aboutToAppear(): void {
this.getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE)
}
build() {
Column() {
// 标题栏
this.TitleBar()
// 聊天记录区域------layoutWeight(1)让它自动填充剩余空间
List({ space: 12 }) {
ForEach(this.messages, (msg: string, index: number) => {
ListItem() {
Text(msg)
.padding(12)
.backgroundColor('#e8e8e8')
.borderRadius(12)
.maxWidth('70%')
}
.width('100%')
.alignListItem(index % 2 === 0 ? ListItemAlign.Start : ListItemAlign.End)
}, (msg: string, index: number) => `${index}`)
}
.layoutWeight(1) // 关键:自动填充剩余空间
.width('100%')
// 底部输入栏------不用layoutWeight,固定高度
this.InputBar()
}
.width('100%')
.height('100%')
.backgroundColor('#f5f5f5')
}
@Builder TitleBar() {
Row() {
Image($r('app.media.ic_back'))
.width(24)
.height(24)
Text('聊天')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.margin({ left: 12 })
}
.width('100%')
.height(56)
.padding({ left: 16 })
.backgroundColor(Color.White)
// expandSafeArea让标题栏不参与键盘避让
.expandSafeArea([SafeAreaType.KEYBOARD], [SafeAreaEdge.TOP])
}
@Builder InputBar() {
Row({ space: 8 }) {
TextInput({ text: this.inputText, placeholder: '输入消息...' })
.layoutWeight(1)
.height(40)
.backgroundColor(Color.White)
.borderRadius(20)
.onChange((value: string) => {
this.inputText = value
})
Button('发送')
.height(40)
.backgroundColor('#007DFF')
.fontColor(Color.White)
.borderRadius(20)
.onClick(() => {
if (this.inputText.length > 0) {
this.messages.push(this.inputText)
this.inputText = ''
}
})
}
.width('100%')
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.backgroundColor(Color.White)
}
}
RESIZE模式的关键点:
- 整体用
Column布局 - 标题栏固定高度,用
expandSafeArea不受键盘影响 - 聊天记录用
layoutWeight(1)自动填充 - 输入栏固定高度,在Column最底部
- 键盘弹出后页面高度压缩,聊天记录缩小,输入栏上移到键盘上方
expandSafeArea:让标题栏不被影响
RESIZE模式下,如果标题栏不加expandSafeArea,它可能也会被压缩或者位移。加上expandSafeArea后,标题栏的位置和大小不会因为键盘弹出而改变:
typescript
.expandSafeArea([SafeAreaType.KEYBOARD], [SafeAreaEdge.TOP])
参数说明:
- 第一个参数
[SafeAreaType.KEYBOARD]--- 指定对键盘类型的安全区域生效 - 第二个参数
[SafeAreaEdge.TOP]--- 指定顶部边缘
如果不加这个,在RESIZE模式下标题栏可能会跟着页面一起被压缩,高度变小或者位置偏移。我之前没加这个,键盘弹出后标题栏被挤成了一小条,看起来特别丑。
NONE模式:不避让
NONE模式就是完全不避让,键盘直接覆盖在内容上面。页面高度不变,位置不变,键盘就像一层半透明的遮罩盖在上面。
NONE模式键盘弹出后:
┌──────────────┐
│ 标题栏 │ ← 不动
│ 聊天记录 │ ← 底部被键盘遮挡
│ 输入栏 │ ← 被键盘完全遮挡!
├──────────────┤
│ 软键盘 │ ← 直接覆盖在上面
└──────────────┘
NONE模式一般配合keyboardHeightChange事件手动处理偏移,适合做高度自定义的布局方案。比如你的页面有一个绝对定位的悬浮按钮,你不想让它因为键盘而被推走或压缩,就可以用NONE模式,然后手动计算偏移量。
不过大部分场景不需要NONE模式。RESIZE和DEFAULT就能满足需求。
keyboardHeightChange:监听键盘高度
有时候你需要知道键盘的高度,比如手动调整某些元素的位置、在键盘弹出时自动滚动到底部等。这时候用window.on('keyboardHeightChange')监听:
typescript
import { window, display } from '@kit.ArkUI'
import { common } from '@kit.AbilityKit'
@Entry
@Component
struct KeyboardDemo {
@State keyboardHeight: number = 0
@State inputText: string = ''
private curWindow: window.Window | undefined = undefined
aboutToAppear(): void {
let context = this.getUIContext().getHostContext() as common.UIAbilityContext
window.getLastWindow(context).then((win) => {
this.curWindow = win
win.on('keyboardHeightChange', (data: number) => {
// data是键盘高度,单位是PX!不是VP!
if (data > 0) {
// 键盘弹出
this.keyboardHeight = this.px2vp(data)
console.info(`键盘高度: ${data}px = ${this.keyboardHeight}vp`)
} else {
// 键盘收起
this.keyboardHeight = 0
}
})
})
}
aboutToDisappear(): void {
// 重要:页面销毁时移除监听,避免内存泄漏
if (this.curWindow !== undefined) {
this.curWindow.off('keyboardHeightChange')
}
}
// px转vp的关键方法
private px2vp(px: number): number {
let displayInfo = display.getDefaultDisplaySync()
return px / displayInfo.densityPixels
}
build() {
Column() {
Text(`键盘高度: ${this.keyboardHeight}vp`)
.fontSize(16)
Column() {
Text('内容区域')
}
.layoutWeight(1)
.width('100%')
Row() {
TextInput({ text: this.inputText, placeholder: '输入...' })
.layoutWeight(1)
.height(40)
.onChange((value: string) => {
this.inputText = value
})
}
.width('100%')
.padding(12)
.backgroundColor(Color.White)
}
.width('100%')
.height('100%')
}
}
PX vs VP:最容易踩的坑------再说一遍都不嫌多
keyboardHeightChange回调的data参数单位是PX,不是VP!这是无数人踩过的坑------直接拿这个值当VP用,结果偏移量完全不对。
我第一次做的时候,拿键盘高度直接设marginBottom,结果输入栏要么飞出屏幕(偏移太大),要么只上移了一点点还是被挡住(偏移太小)。排查了两个小时,打印了各种数值,最后发现键盘高度是800多(px),但屏幕的vp高度才360多。除以屏幕密度2.0之后才得到正确的vp值400左右。
必须做单位转换:
typescript
// ❌ 直接用px值当vp,偏移量不对
win.on('keyboardHeightChange', (data: number) => {
this.keyboardHeight = data // 这是px!不是vp!
// 如果直接用这个值设margin,偏差会是densityPixels倍
})
// 比如键盘高度810px,直接当vp用就偏移810vp
// 但实际应该偏移810/2.25=360vp(假设densityPixels=2.25)
// ✅ 转换成vp
private px2vp(px: number): number {
let displayInfo = display.getDefaultDisplaySync()
return px / displayInfo.densityPixels
}
win.on('keyboardHeightChange', (data: number) => {
this.keyboardHeight = this.px2vp(data) // 正确:转成vp
})
display.getDefaultDisplaySync().densityPixels是屏幕的像素密度。用px值除以densityPixels就得到vp值。这个转换在HarmonyOS中很常见,不只是键盘高度------很多系统API返回的尺寸都是px,需要手动转换。
注意:display需要从@kit.ArkUIimport,不是@kit.DeviceInfoKit:
typescript
import { display } from '@kit.ArkUI'
一定要在aboutToDisappear中移除监听
如果你在aboutToAppear里注册了keyboardHeightChange监听,一定要在aboutToDisappear里移除,否则页面销毁后回调还在执行,会导致内存泄漏甚至crash。
typescript
aboutToDisappear(): void {
if (this.curWindow !== undefined) {
this.curWindow.off('keyboardHeightChange')
}
}
这个问题我之前没注意,导致应用长时间使用后内存占用越来越大,最终OOM。后来加上off之后问题就消失了。养成习惯------有on就必须有off,跟Android的register/unregister一个道理。
expandSafeArea详解
expandSafeArea是个很重要的属性------它可以让指定组件"忽略"安全区域的避让,不跟着页面一起移动或压缩。
最常见的场景:键盘弹出后,你不想让顶部标题栏被推上去或压缩。给标题栏加expandSafeArea:
typescript
// 标题栏不想被键盘影响
Row() {
Text('聊天')
}
.width('100%')
.height(56)
.backgroundColor(Color.White)
.expandSafeArea([SafeAreaType.KEYBOARD], [SafeAreaEdge.TOP])
expandSafeArea参数说明:
- 第一个参数:安全区域类型数组
SafeAreaType.KEYBOARD--- 键盘SafeAreaType.SYSTEM--- 状态栏、导航栏SafeAreaType.CUTOUT--- 刘海屏
- 第二个参数:边缘方向数组
SafeAreaEdge.TOP--- 顶部SafeAreaEdge.BOTTOM--- 底部SafeAreaEdge.START--- 左侧SafeAreaEdge.END--- 右侧
也可以同时指定多个类型和多个边缘:
typescript
.expandSafeArea(
[SafeAreaType.KEYBOARD, SafeAreaType.SYSTEM],
[SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]
)
如果没有传第二个参数,默认所有边缘都生效。
expandSafeArea的方向语义
这个有点反直觉------expandSafeArea([SafeAreaType.KEYBOARD], [SafeAreaEdge.TOP])的意思不是"在顶部展开安全区域",而是"让组件在顶部边缘不避让键盘"。
换句话说:
- 加了
expandSafeArea([SafeAreaType.KEYBOARD], [SafeAreaEdge.TOP])的组件 → 键盘弹出时该组件在顶部不受影响 - 没加的组件 → 键盘弹出时该组件会参与避让(上移或压缩)
在DEFAULT模式下,expandSafeArea能让标题栏不被推上去:
DEFAULT模式 + 标题栏有expandSafeArea:
┌──────────────┐
│ 标题栏 │ ← 不动!expandSafeArea生效了
│ 输入框 │ ← 可见,页面上移
├──────────────┤
│ 软键盘 │
└──────────────┘
DEFAULT模式 + 标题栏没有expandSafeArea:
│ 标题栏 │ ← 被推上去了,可能看不见
│ 输入框 │ ← 可见
├──────────────┤
│ 软键盘 │
└──────────────┘
手动偏移方案(不推荐但了解一下)
如果你不想用系统的避让模式,想完全手动控制,可以把避让模式设为NONE,然后监听键盘高度手动调整布局。这种方案灵活但容易出bug:
typescript
import { window, display } from '@kit.ArkUI'
import { common } from '@kit.AbilityKit'
@Entry
@Component
struct ManualKeyboardDemo {
@State keyboardHeight: number = 0
@State inputText: string = ''
private curWindow: window.Window | undefined = undefined
aboutToAppear(): void {
// 设置为NONE模式,完全手动处理
this.getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.NONE)
let context = this.getUIContext().getHostContext() as common.UIAbilityContext
window.getLastWindow(context).then((win) => {
this.curWindow = win
win.on('keyboardHeightChange', (data: number) => {
if (data > 0) {
let displayInfo = display.getDefaultDisplaySync()
this.keyboardHeight = data / displayInfo.densityPixels
} else {
this.keyboardHeight = 0
}
})
})
}
aboutToDisappear(): void {
if (this.curWindow !== undefined) {
this.curWindow.off('keyboardHeightChange')
}
}
build() {
Column() {
// 内容区域
Scroll() {
Column() {
Text('这是内容区域')
.fontSize(20)
}
}
.layoutWeight(1)
.width('100%')
// 底部输入栏 --- 用marginBottom手动偏移
Row() {
TextInput({ text: this.inputText, placeholder: '输入...' })
.layoutWeight(1)
.height(40)
.onChange((value: string) => {
this.inputText = value
})
Button('发送')
.height(40)
}
.width('100%')
.padding(12)
.backgroundColor(Color.White)
.margin({ bottom: this.keyboardHeight }) // 手动偏移
}
.width('100%')
.height('100%')
}
}
这个方案的问题在于:
- px转vp的计算可能不精确------不同设备的densityPixels可能不同,在某些设备上偏移量会有小误差
- 需要自己处理动画过渡------否则偏移是瞬间的,体验生硬。系统默认模式有平滑动画
- 和系统的避让逻辑冲突------如果忘了设NONE模式,系统避让和手动偏移会叠加,偏移量翻倍
- 代码量大,维护成本高------每个页面都要写一遍监听和偏移逻辑
- 边界情况多------键盘弹出/收起的时机、输入框切换、屏幕旋转......都需要处理
我建议除非有特殊需求(比如高度自定义的键盘交互),否则不要用这种方案。RESIZE模式能满足90%的场景。
常见场景的最佳实践
场景1:聊天页面(最常见)
需求:底部输入栏,键盘弹出后输入栏在键盘上方,聊天记录可滚动
方案:
1. setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE)
2. Column布局:标题栏 + List(聊天记录, layoutWeight(1)) + 输入栏
3. 标题栏加expandSafeArea([SafeAreaType.KEYBOARD], [SafeAreaEdge.TOP])
4. 键盘弹出后监听keyboardHeightChange,自动滚动到底部
额外细节:
- 键盘弹出后要自动滚动到最新消息
- 输入框获得焦点时如果聊天记录很长,可能需要手动scrollToBottom
- 发送消息后也要滚动到底部
场景2:登录/注册表单
需求:输入框在页面中部,键盘弹出后输入框可见,底部有"登录"按钮
方案:
1. 保持DEFAULT模式(默认值,不用设置)
2. 系统自动把页面推上去,让输入框可见
3. 如果底部按钮需要始终可见,可以用expandSafeArea
4. 或者把按钮放在输入框上方而不是页面最底部
注意:DEFAULT模式下底部按钮会被推走
如果"登录"按钮必须可见,建议把它紧挨着输入框放
不要放在页面最底部
场景3:搜索页面
需求:顶部搜索框,键盘弹出后搜索框不动,搜索结果区域被压缩
方案:
1. setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE)
2. 标题栏(搜索框)加expandSafeArea不参与压缩
3. 搜索结果区域layoutWeight(1)自动压缩
这种布局跟聊天页面类似,只是"输入框"在顶部
场景4:悬浮按钮 + 输入框
需求:页面右下角有悬浮按钮,输入框获取焦点后键盘弹出
悬浮按钮不能被推走
方案:
1. setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE)
2. 悬浮按钮用Stack+position绝对定位,不受布局影响
3. 悬浮按钮加expandSafeArea不参与避让
悬浮按钮用绝对定位(position)是关键
这样它不参与Column的布局计算,不受RESIZE影响
实战:完整的聊天页面
把上面的所有内容整合成一个实际可用的聊天页面:
typescript
import { window, display } from '@kit.ArkUI'
import { common } from '@kit.AbilityKit'
interface ChatMessage {
id: string
content: string
isMine: boolean
time: string
}
@Entry
@Component
struct KeyboardDemo {
@State messages: ChatMessage[] = [
{ id: '1', content: '你好!最近怎么样?', isMine: false, time: '10:00' },
{ id: '2', content: '还不错,你呢?', isMine: true, time: '10:01' },
{ id: '3', content: '我也挺好的,周末有空一起吃饭吗?', isMine: false, time: '10:02' },
{ id: '4', content: '好啊!去哪吃?', isMine: true, time: '10:03' },
{ id: '5', content: '听说新开了一家火锅店,评价不错', isMine: false, time: '10:04' },
]
@State inputText: string = ''
@State keyboardHeight: number = 0
private listScroller: Scroller = new Scroller()
private curWindow: window.Window | undefined = undefined
aboutToAppear(): void {
// 关键:设置RESIZE模式
this.getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE)
// 监听键盘高度(用于自动滚动到底部)
let context = this.getUIContext().getHostContext() as common.UIAbilityContext
window.getLastWindow(context).then((win) => {
this.curWindow = win
win.on('keyboardHeightChange', (data: number) => {
if (data > 0) {
let displayInfo = display.getDefaultDisplaySync()
this.keyboardHeight = data / displayInfo.densityPixels
// 键盘弹出后滚动到底部
setTimeout(() => {
this.listScroller.scrollEdge(Edge.Bottom)
}, 100)
} else {
this.keyboardHeight = 0
}
})
})
}
aboutToDisappear(): void {
// 移除键盘监听,防止内存泄漏
if (this.curWindow !== undefined) {
this.curWindow.off('keyboardHeightChange')
}
}
build() {
Column() {
// 标题栏 --- 不参与键盘避让
Row() {
Image($r('app.media.ic_back'))
.width(24)
.height(24)
.fillColor(Color.White)
.onClick(() => {
// 返回
})
Text('小明')
.fontSize(18)
.fontColor(Color.White)
.fontWeight(FontWeight.Medium)
.margin({ left: 12 })
.layoutWeight(1)
Image($r('app.media.ic_more'))
.width(24)
.height(24)
.fillColor(Color.White)
}
.width('100%')
.height(56)
.padding({ left: 16, right: 16 })
.backgroundColor('#007DFF')
.expandSafeArea([SafeAreaType.KEYBOARD], [SafeAreaEdge.TOP])
// 聊天记录
List({ space: 8 }) {
ForEach(this.messages, (msg: ChatMessage) => {
ListItem() {
Row() {
if (msg.isMine) {
Blank()
}
Column() {
Text(msg.content)
.fontSize(16)
.fontColor(msg.isMine ? Color.White : '#333')
.padding(10)
.backgroundColor(msg.isMine ? '#007DFF' : '#e8e8e8')
.borderRadius(msg.isMine ?
{ topLeft: 12, topRight: 12, bottomLeft: 12, bottomRight: 4 } :
{ topLeft: 12, topRight: 12, bottomLeft: 4, bottomRight: 12 })
.maxWidth('70%')
Text(msg.time)
.fontSize(10)
.fontColor('#999')
.margin({ top: 4 })
}
.alignItems(msg.isMine ? HorizontalAlign.End : HorizontalAlign.Start)
if (!msg.isMine) {
Blank()
}
}
.width('100%')
}
}, (msg: ChatMessage) => msg.id)
}
.layoutWeight(1) // 关键:自动填充剩余空间
.width('100%')
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.scrollBar(BarState.Off)
.scroller(this.listScroller)
// 底部输入栏
Row({ space: 8 }) {
Image($r('app.media.ic_voice'))
.width(28)
.height(28)
.fillColor('#666')
TextInput({ text: this.inputText, placeholder: '输入消息...' })
.layoutWeight(1)
.height(36)
.backgroundColor('#f0f0f0')
.borderRadius(18)
.padding({ left: 16, right: 16 })
.onChange((value: string) => {
this.inputText = value
})
if (this.inputText.length > 0) {
Button('发送')
.height(36)
.fontSize(14)
.backgroundColor('#007DFF')
.fontColor(Color.White)
.borderRadius(18)
.padding({ left: 16, right: 16 })
.onClick(() => {
this.sendMessage()
})
} else {
Image($r('app.media.ic_emoji'))
.width(28)
.height(28)
.fillColor('#666')
}
Image($r('app.media.ic_plus'))
.width(28)
.height(28)
.fillColor('#666')
}
.width('100%')
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.backgroundColor(Color.White)
}
.width('100%')
.height('100%')
.backgroundColor('#f5f5f5')
}
private sendMessage(): void {
if (this.inputText.length === 0) {
return
}
let now = new Date()
let timeStr = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`
let newMsg: ChatMessage = {
id: Date.now().toString(),
content: this.inputText,
isMine: true,
time: timeStr,
}
this.messages.push(newMsg)
this.inputText = ''
// 消息发送后滚动到底部
setTimeout(() => {
this.listScroller.scrollEdge(Edge.Bottom)
}, 50)
}
}
这个实现的关键点:
- RESIZE模式 --- 页面高度自动压缩,输入栏自然浮在键盘上方
- expandSafeArea --- 标题栏不参与压缩,保持固定
- layoutWeight(1) --- 聊天记录区域自动填充剩余空间
- keyboardHeightChange --- 监听键盘弹出,自动滚动到最新消息
- aboutToDisappear --- 移除键盘监听,防止内存泄漏
- scrollEdge(Edge.Bottom) --- 发送消息后和键盘弹出后都要滚动到底部
- setTimeout --- 延迟滚动,等布局稳定后再执行
OFFSET_WITH_CARET和RESIZE_WITH_CARET模式
从较新版本开始,HarmonyOS还提供了两种更精细的避让模式,特别适合有多个输入框的表单页面:
- OFFSET_WITH_CARET --- 偏移模式,但基于光标位置避让
- RESIZE_WITH_CARET --- 压缩模式,但基于光标位置避让
这两种模式跟OFFSET/RESIZE的区别在于------普通的OFFSET/RESIZE是基于焦点元素(整个TextInput)来避让的,而WITH_CARET版本是基于光标位置来避让的。在多个输入框之间切换时,WITH_CARET版本会根据光标位置重新计算避让区域,确保光标始终可见。
typescript
// 多输入框表单页面推荐用RESIZE_WITH_CARET
this.getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE_WITH_CARET)
比如你有一个很长的表单,有10个输入框,用户从第1个输入框切换到第10个输入框时,普通的RESIZE可能只会让第10个输入框的底部可见,但光标位置可能还在被键盘遮挡的区域。WITH_CARET版本会让光标位置可见,用户体验更好。
不过要注意,这两个模式的可用性取决于你的HarmonyOS SDK版本。低版本可能不支持,用之前查一下API版本号。
自定义键盘
HarmonyOS支持自定义软键盘,通过customKeyboard修饰符给TextInput/TextArea指定自定义键盘面板。这种功能在数字输入、密码键盘、表情面板等场景中很有用:
typescript
@State inputValue: string = ''
@State showCustomKeyboard: boolean = false
@Builder CustomKeyboardBuilder() {
Column() {
Row() {
ForEach(['1', '2', '3', '4', '5', '6', '7', '8', '9'], (key: string) => {
Button(key)
.width(32)
.height(40)
.fontSize(18)
.margin(2)
.onClick(() => {
this.inputValue += key
})
}, (key: string) => key)
}
.flexWrap(FlexWrap.Wrap)
.justifyContent(FlexAlign.Center)
Row() {
Button('删除')
.width(70)
.height(40)
.margin(2)
.onClick(() => {
if (this.inputValue.length > 0) {
this.inputValue = this.inputValue.substring(0, this.inputValue.length - 1)
}
})
Button('0')
.width(32)
.height(40)
.margin(2)
.onClick(() => {
this.inputValue += '0'
})
Button('确认')
.width(70)
.height(40)
.margin(2)
.fontColor('#007DFF')
.onClick(() => {
// 关闭自定义键盘
// 注意:自定义键盘的关闭方式可能因版本不同
})
}
}
.width('100%')
.padding(12)
.backgroundColor(Color.White)
}
TextInput({ text: this.inputValue, placeholder: '输入数字' })
.type(InputType.Number)
.customKeyboard(this.CustomKeyboardBuilder)
自定义键盘默认不支持避让功能------它弹出时不会触发系统的键盘避让机制。如果需要自定义键盘也参与避让,可以设置KeyboardOptions的supportAvoidance为true。不过自定义键盘的API在不同版本间变化较大,建议查看最新文档。
还有一个常见的坑------自定义键盘面板跟表情面板切换。很多聊天App的输入栏下面有三个面板:表情面板、更多功能面板、系统键盘。这三个面板互斥,只能同时显示一个。切换时需要先关闭当前面板再打开新面板。这个交互逻辑比较复杂,需要仔细处理。
常见踩坑汇总
坑1:在window对象上调用setKeyboardAvoidMode
- 应该通过
this.getUIContext()调用 - window对象上没有这个方法
- 网上很多旧资料写的是window上的,别被骗了
坑2:keyboardHeightChange返回的是PX不是VP
- 必须除以
densityPixels转成VP - 直接用PX值做偏移,位置肯定不对
- 这是我花了两小时才找到的bug原因
坑3:DEFAULT模式底部内容被推走
- DEFAULT模式整个页面上移,底部固定元素可能看不见
- 聊天页面必须用RESIZE模式
- 表单页面如果底部有按钮也要小心
坑4:标题栏被键盘推上去或压缩
- 给标题栏加
expandSafeArea([SafeAreaType.KEYBOARD], [SafeAreaEdge.TOP]) - 让标题栏不参与键盘避让
- 这是在DEFAULT和RESIZE模式下都要注意的
坑5:忘记移除keyboardHeightChange监听
- 必须在
aboutToDisappear里off掉 - 否则内存泄漏,长时间使用后可能OOM
- 养成习惯:有on就有off
坑6:手动偏移方案和系统避让冲突
- 手动偏移要配合NONE模式
- 如果用了DEFAULT/RESIZE又手动偏移,效果会叠加出错
- 偏移量变成两倍
坑7:键盘弹出后聊天记录不滚动到底部
- 监听
keyboardHeightChange,键盘弹出后scrollEdge(Edge.Bottom) - 加个小延时(100ms)等布局稳定后再滚动
- 发送消息后也要滚动到底部
坑8:自定义键盘不参与避让
- 自定义键盘默认不避让
- 需要设置
KeyboardOptions.supportAvoidance = true - 自定义键盘的避让行为可能因版本不同
坑9:RESIZE模式下布局没有用layoutWeight
- 不用layoutWeight的话聊天记录区域不会自动压缩
- 输入栏和聊天记录的布局会乱
- Column + layoutWeight(1)是关键
坑10:display需要从@kit.ArkUI import
- 不是从
@kit.DeviceInfoKitimport import { display } from '@kit.ArkUI'- 从错误的kit import会编译报错
键盘避让这个话题看似简单,实际做起来坑极多。我见过的HarmonyOS应用里,聊天页面的键盘处理做得好的真不多------要么输入栏被盖住,要么标题栏消失,要么页面跳动,要么消息列表底部被截断。核心就一句话:聊天页面用RESIZE模式+expandSafeArea+layoutWeight,大部分问题都能解决。如果还有问题,再考虑用keyboardHeightChange监听做额外处理。
我做了好几个聊天类的应用,每次做键盘避让都有新的感悟。第一次做的时候纯靠试,什么方案都试了一遍,搞得一团糟。第二次做的时候学聪明了,直接用RESIZE+expandSafeArea,10分钟搞定。第三次更顺了,直接把之前封装好的聊天页面模板拿来改改就行。所以如果你是第一次做,别怕踩坑,踩过了下次就会了。
还有一个很多人忽略的细节------键盘弹出时,如果聊天记录很长,虽然输入栏跟着上去了,但最新的消息可能还是被键盘挡住。所以一定要在keyboardHeightChange回调里自动滚动到底部,不然用户发送消息后看不到自己的消息,以为没发出去。
实用要点回顾:
- 聊天页面用
KeyboardAvoidMode.RESIZE,表单页面用DEFAULT setKeyboardAvoidMode通过UIContext调用,不是window对象keyboardHeightChange回调的值是PX,要除以densityPixels转VP- 标题栏加
expandSafeArea([SafeAreaType.KEYBOARD])防止被推走 aboutToDisappear里移除键盘监听防止内存泄漏- 键盘弹出后延时滚动到底部,等布局稳定
- 优先用系统避让模式,手动偏移方案尽量不用
- 聊天记录区域用
layoutWeight(1)自动填充 - 多输入框表单可以考虑
RESIZE_WITH_CARET模式 display从@kit.ArkUIimport,不是其他kit