Site owner avatar
Flutter输入面板布局优化:键盘、表情面板与@联想的丝滑切换

Flutter输入面板布局优化:键盘、表情面板与@联想的丝滑切换

1987 字
7 分钟
AI 摘要

正在生成中...

在做评论输入框的时候,我想实现一种比较接近抖音、小红书的交互:点击输入框弹出系统键盘,点击表情后切换成表情面板,点击键盘图标再切回系统键盘。切换过程中,输入框的位置应该保持稳定,不能出现面板先收缩、再弹起,或者后面的内容突然透出来的情况。

这个问题看起来只是一个 TextField 加一个 EmojiPanel,真正做起来却会同时牵涉到焦点、窗口 inset、Scaffold 的自动避让、系统键盘动画和底部安全区。只要其中一个地方多负责了一次布局,体验就会明显变差。

最终想要的交互

评论输入框里其实有两类功能,不能把它们混成一种键盘:

  • 表情面板:替代系统键盘,占据系统键盘原本所在的区域。
  • @ 联想选择器:显示在输入框上方,系统键盘继续保留,用户可以边看候选边继续输入搜索词。

所以最终的结构应该是:

text
评论输入区域
├── 回复提示
├── @ 联想条(可选,保留系统键盘)
├── 多行输入框
├── 图片 / @ / 表情 / 发送按钮
└── 表情等自定义面板(可选,替代系统键盘)
输入面板的分层结构:@ 联想条、输入框、按钮行与表情面板

这里最重要的判断是:@ 联想条是输入区上方的辅助内容,而表情面板才是“键盘类型”的切换。

第一个问题:面板为什么会先收缩再弹起

最初的实现很容易写成这样:

dart
if (emojiOpen) {
return EmojiPanel(
height: MediaQuery.of(context).viewInsets.bottom,
);
}

点击表情的瞬间,系统键盘并不会立刻消失,而是执行一段退场动画。viewInsets.bottom 会从键盘高度逐帧下降到 0。如果表情面板直接使用这个值,它也会跟着一起变矮:

text
键盘 291 → 220 → 120 → 40 → 0
表情 291 → 220 → 120 → 40 → 260

最后的 260 还是面板自己的最小高度,于是用户看到的就是表情面板先往下缩,再突然撑回来。

正确做法是:在打开自定义面板的那一刻锁定键盘高度,后续键盘退场的 inset 变化不再影响面板尺寸。

dart
double? _lockedPanelHeight;
final panelHeight = _lockedPanelHeight ??= inputPanelHeight(context);

inputPanelHeight 在键盘真实存在时读取当前高度,键盘已经收起时才使用历史缓存或兜底高度。这样可以保证表情面板和刚刚显示过的系统键盘高度一致。

第二个问题:为什么第一次点击表情会失败

为了处理“面板打开后键盘又自己弹出来”的异常,我增加了一个键盘看守:

dart
if (panelOpen && insets > 80) {
onKeyboardAppeared?.call();
}

这段逻辑本身是有意义的,但有一个非常隐蔽的时序问题:点击表情后,旧键盘正在退场,insets 在前几帧仍然大于 80。于是刚打开的表情面板被误判为“键盘重新出现”,面板立即关闭,用户只能再点一次。

解决方案是给键盘看守增加一个退场过渡状态:

dart
bool _awaitingKeyboardDismiss = false;

从键盘态切到表情面板时,先进入等待状态:

dart
if (old.panel == null && widget.panel != null) {
_awaitingKeyboardDismiss = true;
}

只有当键盘 inset 真正降到阈值以下,才开始监听键盘是否重新出现:

dart
if (panelOpen && _awaitingKeyboardDismiss && insets <= 80) {
_awaitingKeyboardDismiss = false;
}
if (panelOpen && !_awaitingKeyboardDismiss && insets > 80) {
onKeyboardAppeared?.call();
}

这段状态转换可以表示为:

text
键盘态
│ 点击表情
面板已显示 + 等待旧键盘退场
│ inset 降到阈值以下
稳定面板态 + 开启键盘异常看守
键盘与表情面板切换时的稳定过渡:输入栏位置不随键盘动画抖动

它解决了两个看起来相反的问题:第一次点击表情可以成功,同时也不会丢掉键盘异常重现时的保护逻辑。

第三个问题:从表情切回键盘时,为什么会透出后面的内容

从表情面板切回系统键盘时,表情面板会立即移除,但系统键盘需要一段时间才能升起。为了保持输入框位置不动,中间需要保留一块和键盘等高的占位区域。

如果这个占位只是:

dart
SizedBox(height: gap)

它就是透明的。键盘还没有完全升起时,用户就能看到后面的评论列表或页面内容,视觉上像是输入面板破了一块。

占位区域应该使用输入面板的背景色:

dart
ColoredBox(
color: AppColors.of(context).surface,
child: SizedBox(
width: double.infinity,
height: gap,
),
)

浅色主题下它是白色,深色主题下则自动使用深色面板色。这样键盘升起过程中,输入区域仍然是一整块连续的背景。

用一个固定高度的宿主管理布局

为了避免聊天、评论、发布页各自处理一套键盘逻辑,我把通用结构抽成了 InputPanelHost。它负责四件事:

  1. 保存输入框焦点。
  2. 计算并锁定系统键盘高度。
  3. 用固定高度渲染自定义面板。
  4. 管理键盘和面板切换时的底部占位。

核心布局可以抽象成:

dart
final wantPanel = panelOpen || awaitingKeyboard ? panelHeight : 0.0;
final reserve = keyboardInset > wantPanel
? keyboardInset
: wantPanel;
final filled = panelOpen ? panelHeight : 0.0;
final gap = reserve - filled;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SafeArea(
top: false,
bottom: !panelOpen,
child: inputBar,
),
if (panelOpen)
SizedBox(height: panelHeight, child: panel),
if (gap > 0)
ColoredBox(
color: surfaceColor,
child: SizedBox(
width: double.infinity,
height: gap,
),
),
],
);

这里的关键不是某个具体组件,而是一个不变量:

输入框上方的内容和输入框本身不参与键盘动画;键盘和功能面板只在底部占据同一块空间。

因此,切换功能面板时只替换固定盒子里的内容,盒子的高度不会跟着内容变化。

焦点不能用 unfocus() 处理

点击表情时,如果直接执行:

dart
FocusScope.of(context).unfocus();

输入框会失去焦点,光标消失。关闭表情面板时再调用 requestFocus(),又会和系统键盘的动画、输入连接重建产生竞争,最终经常表现为需要点击两次,或者光标位置发生变化。

这里使用的是:

dart
Future<void> hideKeyboardKeepFocus() {
return SystemChannels.textInput.invokeMethod('TextInput.hide');
}
Future<void> showKeyboard() {
return SystemChannels.textInput.invokeMethod('TextInput.show');
}

切到表情时只隐藏系统键盘,不清除输入焦点;切回键盘时显式发送 TextInput.show。这样光标一直留在输入框里,表情插入也能继续使用当前光标位置。

resizeToAvoidBottomInset 只能有一个负责人

如果页面使用了 InputPanelHost,外层 Scaffold 应该关闭自动避让:

dart
Scaffold(
resizeToAvoidBottomInset: false,
body: ...,
)

否则会出现两份底部位移:

  • Scaffold 根据键盘 inset 把 body 顶起一次。
  • InputPanelHost 又根据键盘或面板高度添加一次占位。

两个组件都认为自己在“保护输入框”,结果就是输入框先跳上去,再被动画拉回来。

同样,底部安全区也只能计算一次。表情面板展开时,底部导航栏区域由表情面板内部的 SafeArea 负责;输入栏这一侧不能再次叠加底部安全区。

@ 联想为什么不应该放进这个面板

@ 选择器和表情面板看起来都是“输入辅助功能”,但它们的空间语义不同。

@ 选择器需要保留系统键盘,因为用户可能会继续搜索昵称;它应该作为输入框上方的一行候选条存在:

dart
if (_mentionActive)
MentionPicker(
query: _mentionQuery,
onPick: _pickMentionUser,
),

如果把 @ 选择器也当作自定义键盘来切换,就会收起系统键盘,用户输入搜索词的路径反而变长。未来如果增加投票、图片、位置等真正需要替代键盘的功能,再把它们放进固定高度的功能面板即可。

这次优化后的检查清单

以后再做类似输入面板,可以按下面的顺序排查:

  • 外层 Scaffold 是否设置了 resizeToAvoidBottomInset: false
  • 是否使用窗口原始 inset,而不是在被 Scaffold 调整过的 body 里读取失真的值。
  • 自定义面板是否锁定了打开瞬间的键盘高度。
  • 键盘退场期间,是否被错误的“键盘出现”回调关掉。
  • 面板切回键盘时,底部过渡占位是否有背景色。
  • 输入框和功能面板是否只计算了一次底部安全区。
  • 切换面板时是否保留了输入框焦点和光标。
  • @ 联想是否保持在输入区上方,并保留系统键盘。

最后

这次问题让我意识到,移动端输入框的“丝滑”并不是简单加一个动画。真正决定体验的是布局职责是否清晰:输入框负责编辑,系统键盘负责输入,功能面板负责替代键盘,而过渡占位负责把两套动画之间的空档填完整。

只要把这几块区域放进一个固定的布局模型里,键盘、表情和后续功能就可以稳定切换,不会因为某一帧的 viewInsets 变化而让整个页面跟着抖动。

文章标题:Flutter输入面板布局优化:键盘、表情面板与@联想的丝滑切换

文章作者:Ditto

文章链接:https://blog.blueke.top/posts/flutter-input-panel-smooth-switch[复制]

发布时间2026 年 08 月 03 日 12:33


商业转载请联系站长获得授权,非商业转载请注明本文出处及文章链接,您可以自由地在任何媒体以任何形式复制和分发作品,也可以修改和创作,但是分发衍生作品时必须采用相同的许可协议。
本文采用CC BY-NC-SA 4.0进行许可。

1 / 1