在 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 写死成 EmojiMenu

使用方通过字符串 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 的核心思路。

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

相关推荐
plainGeekDev4 小时前
ProGuard → R8
android·java·kotlin
雨白5 小时前
C 语言字符串:从字符数组、指针到核心操作实战
android
Java小白笔记5 小时前
MySQL中存储过程大表分批删除历史数据
android·mysql·adb
aidou13146 小时前
Kotlin中沉浸式状态栏显示布局
android·kotlin·windowmanager·沉浸式状态栏·insetslistener·insetscompat·systembars
我命由我123456 小时前
Android 构建项目问题:XML 片段出错,com.ctc.wstx.exc.WstxUnexpectedCharException
android·xml·android studio·android jetpack·android-studio·android runtime
阿pin7 小时前
Android随笔-pipe是什么?
android·linux·pipe
帅次7 小时前
Android 高级工程师面试:Flow 与状态流 近1年高频追问 22 题
android·面试·职场和发展
雨季余静7 小时前
RustDesk Android APK 从零编译全步骤
android
辰合软件7 小时前
通达OA配置文件详解
android·adb