在 enableEdgeToEdge 模式下处理软键盘与自定义面板协同

项目地址:SaberArthur/KeyboardPanelBox
Android 的聊天输入区看起来很简单:底部一个输入框,点击表情按钮时显示表情面板,点击输入框时显示软键盘。
但当页面开启 enableEdgeToEdge() 后,这件事会变得微妙起来。软键盘、自定义面板、导航栏 inset、Activity resize、Compose 布局高度都会参与进来。如果处理不好,就会出现这些问题:
- 软键盘弹出时底部高度变成双倍
- 从软键盘切到表情面板时高度突然跳变
- 从表情面板切回软键盘时先塌陷再弹起
- 软键盘和自定义面板看起来同时存在
- 不同输入法、不同导航栏模式下表现不一致
这篇文章记录一下我在 Compose 里处理这类问题的思路,以及为什么我最后把它抽成了一个专门处理底部协同的组件:KeyboardPanelBox。
问题背景
在传统非 edge-to-edge 页面里,很多场景依赖系统的 adjustResize。
软键盘弹出时,系统会 resize Activity 的可用区域,页面内容被系统往上挤。业务层只需要把输入框放到底部,看起来就能工作。
但在 edge-to-edge 模式下,我们通常希望自己接管 inset:
kotlin
enableEdgeToEdge()
这意味着页面需要自己理解状态栏、导航栏和 IME inset。对于普通页面,这通常不是问题;但对于聊天输入区这种"软键盘和自定义面板要互相替换"的场景,问题就来了。
为什么 imePadding 不够
Compose 提供了 Modifier.imePadding(),它很适合让内容避开软键盘。
但是聊天底部输入区不只是"避开软键盘",它还需要:
- 软键盘显示时,底部区域高度等于键盘高度
- 表情面板显示时,底部区域高度等于表情面板高度
- 软键盘切表情面板时,高度不能先掉到 0
- 表情面板切软键盘时,高度不能先塌陷
- 点击列表时,键盘和面板都能收起
如果只给底部容器加 imePadding(),会有一个典型问题:
kotlin
KeyboardPanelBox(
modifier = Modifier.imePadding(),
...
)
而组件内部又根据 IME 高度撑起底部区域,那么键盘高度就会被应用两次,视觉上就是双倍高度。
所以这类组件不能简单依赖 imePadding(),而应该把 IME 高度作为状态机的一部分。
核心思路
底部输入区本质上有三种模式:
kotlin
sealed interface KeyboardPanelMode {
data object None : KeyboardPanelMode
data object Keyboard : KeyboardPanelMode
data class Panel(val key: String) : KeyboardPanelMode
}
也就是:
None:键盘和自定义面板都不显示Keyboard:软键盘显示Panel(key):某个自定义面板显示,比如 emoji、menu、more
然后我们只维护一个核心值:
kotlin
bottomHeight: Dp
这个高度负责撑开底部区域。
页面上方的内容不用知道底部到底是键盘还是表情面板,它只需要在 Column 里使用 weight(1f),自然被底部区域挤压即可。
键盘高度怎么来
在 Compose 里可以通过 WindowInsets.ime 获取 IME inset:
kotlin
val imeBottomPx = WindowInsets.ime.getBottom(density)
但直接使用它还不够。导航栏高度也可能包含在 bottom inset 里,所以要减去 WindowInsets.navigationBars:
kotlin
val navBottomPx = WindowInsets.navigationBars.getBottom(density)
val keyboardHeightPx = (imeBottomPx - navBottomPx).coerceAtLeast(0)
另外,在键盘动画过程中,navigationBars 的值可能发生跳变。为了避免高度抖动,可以在键盘收起时锁定一个稳定的 navigation bar 高度,用它参与计算。
最后,还需要记录一次最近的有效键盘高度。原因是从自定义面板切回软键盘时,键盘并不是立刻到位的。如果此时没有历史高度,就很难保持底部区域稳定。
Keyboard -> Panel
从软键盘切到自定义面板时,最容易出现"高度跳一下"的问题。
错误做法通常是:
- 隐藏键盘
- 清除焦点
- 切换面板
- 面板高度从 0 开始动画
这样键盘收起的瞬间,底部会先塌陷,再展开成面板高度。
正确思路是:切换时把当前键盘高度作为动画起点。
也就是说,在 Keyboard 模式下,动画高度目标不应该是 0.dp,而应该是当前键盘高度:
kotlin
KeyboardPanelMode.Keyboard -> keyboardHeight.current
is KeyboardPanelMode.Panel -> targetPanelHeight
这样从键盘切到表情面板时,高度会从当前键盘高度连续过渡到面板高度。
如果表情面板高度和键盘高度接近,用户几乎感知不到切换。
Panel -> Keyboard
从自定义面板切回软键盘时,问题反过来。
键盘不是立刻弹出来的,IME inset 会从 0 慢慢变大。如果此时直接进入 Keyboard 模式,并把底部高度设置成实时键盘高度,那么第一帧高度很可能是 0。
结果就是:
- 面板消失
- 底部高度掉到 0
- 键盘弹出
- 底部高度再被撑起来
用户看到的就是明显的闪跳。
解决办法是增加一个过渡状态:
kotlin
holdBottomUntilKeyboard = true
holdBottomHeight = currentPanelHeight
当从面板切回键盘时,先保持当前面板高度。等到实时键盘高度追上历史键盘高度或当前保持高度后,再解除 hold。
简化逻辑是:
kotlin
if (holdBottomUntilKeyboard) {
bottomHeight = maxOf(holdBottomHeight, keyboardHeight.current)
}
这样键盘弹起过程中,底部区域不会塌陷。
为什么必须考虑 enableEdgeToEdge
这个组件是针对 enableEdgeToEdge() 模式设计的。
在 edge-to-edge 模式下,页面自己处理 IME inset,组件可以准确控制底部区域高度。
如果不在 edge-to-edge 模式下,系统可能会因为软键盘 resize Activity,同时组件内部又根据 IME inset 应用一遍键盘高度。结果就是高度被重复处理,看起来像软键盘和面板同时存在。
因此接入时要注意:
kotlin
enableEdgeToEdge()
并且不要再给 KeyboardPanelBox 外层套 imePadding()。
推荐:
kotlin
KeyboardPanelBox(
state = state,
modifier = Modifier.fillMaxWidth(),
inputBar = { ... },
) {
panel("emoji", height = 260.dp) { ... }
}
避免:
kotlin
KeyboardPanelBox(
state = state,
modifier = Modifier
.fillMaxWidth()
.imePadding(),
inputBar = { ... },
) {
panel("emoji", height = 260.dp) { ... }
}
API 设计
我希望组件不关心业务面板是什么,所以没有把 panel 写死成 Emoji、Menu。
使用方通过字符串 key 注册面板:
kotlin
KeyboardPanelBox(
state = state,
inputBar = {
InputBar(
modifier = Modifier.keyboardPanelInput(state),
onKeyboardClick = { state.showKeyboard() },
onEmojiClick = { state.showPanel("emoji") },
onMenuClick = { state.showPanel("menu") },
onMoreClick = { state.showPanel("more") },
)
},
) {
panel("emoji", height = 260.dp) {
EmojiPanel()
}
panel("menu", height = 200.dp) {
MenuPanel()
}
panel("more", height = 240.dp) {
MorePanel()
}
}
输入框通过 modifier 绑定:
kotlin
BasicTextField(
value = text,
onValueChange = { text = it },
modifier = Modifier.keyboardPanelInput(state),
)
这样组件可以在 showKeyboard() 时请求焦点,也可以在用户直接点击输入框时同步状态。
使用方式
完整结构通常是这样:
kotlin
val state = rememberKeyboardPanelState()
Column(
modifier = Modifier
.fillMaxSize()
.navigationBarsPadding()
) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
) {
MainContent()
}
KeyboardPanelBox(
state = state,
inputBar = {
InputBar(...)
},
) {
panel("emoji", height = 260.dp) {
EmojiPanel()
}
}
}
注意这里主体内容由使用方自己管理。KeyboardPanelBox 只负责底部输入栏和面板。
这样设计的好处是,它不只适用于聊天列表,也可以用于评论区、编辑器、表单页等任何需要底部输入协同的页面。
总结
软键盘协同问题的关键,不是"怎么显示一个面板",而是"谁负责底部高度"。
在 enableEdgeToEdge() 模式下,比较稳定的做法是:
- 不依赖
imePadding()直接处理底部输入容器 - 自己读取 IME 高度
- 用状态机管理
None / Keyboard / Panel - 键盘切面板时,不让高度掉到 0
- 面板切键盘时,保持旧高度直到键盘顶上来
- 主体内容由外层布局自然收缩
这就是 KeyboardPanelBox 的核心思路。
它不试图接管业务输入栏,也不关心面板内容,只专注处理最容易出错的那部分:软键盘和自定义面板之间的高度协同。