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

在 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

从软键盘切到自定义面板时,最容易出现"高度跳一下"的问题。

错误做法通常是:

  1. 隐藏键盘
  2. 清除焦点
  3. 切换面板
  4. 面板高度从 0 开始动画

这样键盘收起的瞬间,底部会先塌陷,再展开成面板高度。

正确思路是:切换时把当前键盘高度作为动画起点。

也就是说,在 Keyboard 模式下,动画高度目标不应该是 0.dp,而应该是当前键盘高度:

kotlin 复制代码
KeyboardPanelMode.Keyboard -> keyboardHeight.current
is KeyboardPanelMode.Panel -> targetPanelHeight

这样从键盘切到表情面板时,高度会从当前键盘高度连续过渡到面板高度。

如果表情面板高度和键盘高度接近,用户几乎感知不到切换。

Panel -> Keyboard

从自定义面板切回软键盘时,问题反过来。

键盘不是立刻弹出来的,IME inset 会从 0 慢慢变大。如果此时直接进入 Keyboard 模式,并把底部高度设置成实时键盘高度,那么第一帧高度很可能是 0。

结果就是:

  1. 面板消失
  2. 底部高度掉到 0
  3. 键盘弹出
  4. 底部高度再被撑起来

用户看到的就是明显的闪跳。

解决办法是增加一个过渡状态:

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 的核心思路。

它不试图接管业务输入栏,也不关心面板内容,只专注处理最容易出错的那部分:软键盘和自定义面板之间的高度协同。

相关推荐
千里马学框架5 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台5 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone5 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc5 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo5 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077005 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼5 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone5 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen5 天前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone5 天前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui