
1. 为什么要在 Flutter 里自己造一个数字键盘移动端开发做久了总会碰到系统键盘不听话的场景。尤其是涉及金额输入、密码输入、验证码输入这类业务系统键盘弹出来那一瞬间布局被顶飞、光标乱跳、输入框被遮挡用户体验直接掉一个档次。更别提有些定制化需求比如键盘上要带“清空”“完成”“小数点禁用”这类业务按钮系统键盘根本没法满足。我最近接手的一个项目就遇到了这个问题。页面底部有一个固定悬浮的输入区域点击后需要弹出一个纯数字键盘要求键盘高度可控、按键布局自定义、并且不能把整个页面顶上去。最开始尝试了TextField配合keyboardType: TextInputType.number结果在部分机型上系统键盘依然会带出字母切换键而且键盘高度完全不可控。后来又试了SystemChannels.textInput去拦截代码复杂度飙升维护成本极高。最终我选择了Overlay这个方案。Overlay 是 Flutter 框架自带的一个层叠布局机制它允许你把一个 Widget 插入到当前页面的最上层独立于原有的 Widget 树。用它来做自定义键盘好处非常明显键盘的显示和隐藏完全由自己控制不依赖系统输入法键盘可以做成任意布局而且因为它是插入到 Overlay 中的不会影响原有页面的布局结构不会出现“页面被顶上去”的尴尬情况。这篇文章我会把整个实现过程拆开讲清楚包括 Overlay 的核心原理、键盘的布局设计、输入框与键盘的联动逻辑、光标与文本插入的处理、以及我在实际开发中踩过的那些坑。如果你也在做 Flutter 的定制输入场景这套方案可以直接抄作业。2. Overlay 机制的核心原理与选型考量2.1 Overlay 到底是什么为什么它适合做键盘Overlay 在 Flutter 里其实是一个Stack的变体。每个Navigator内部都会维护一个 Overlay路由跳转本质上就是往 Overlay 里插入和移除OverlayEntry。你可以把它理解成一张“悬浮在页面之上的透明画布”任何插入到这张画布上的 Widget 都会显示在当前页面内容的上方。关键点在于Overlay 里的 Widget 不参与原有页面的布局计算。也就是说你把键盘插入 Overlay 后页面本身的Column、ListView不会因为键盘的出现而重新布局。这一点和系统键盘的行为完全不同——系统键盘弹出时Scaffold的resizeToAvoidBottomInset会触发页面重新布局导致内容被压缩或顶起。用 Overlay 做键盘本质上就是自己管理一个悬浮层。显示键盘就是Overlay.of(context).insert(entry)隐藏就是entry.remove()。整个过程干净利落没有多余的副作用。2.2 为什么不用 BottomSheet 或 Dialog你可能会问用showModalBottomSheet不也能弹出一个底部面板吗确实可以但有几个致命问题。第一BottomSheet默认带有模态遮罩会阻挡页面上其他区域的点击。而数字键盘通常需要用户能同时看到输入框和键盘模态遮罩会破坏这个交互。第二BottomSheet的动画和高度控制虽然可以自定义但它的生命周期和路由绑定关闭时需要Navigator.pop在复杂的页面栈里容易出问题。比如用户快速切换页面时BottomSheet可能还来不及关闭就跟着路由一起销毁导致状态异常。第三Dialog就更不合适了它是居中弹窗和键盘的底部悬浮定位完全不符。Overlay 的优势就在于它是“无状态”的——你插入一个OverlayEntry它就存在你调用remove()它就消失。不涉及路由栈不涉及模态管理完全由开发者掌控。2.3 整体方案设计思路我的整体设计分为三个部分键盘 Widget负责渲染数字按键、小数点、删除键、完成键等处理点击事件。Overlay 管理器负责键盘的插入、移除、动画控制以及键盘高度的计算。输入控制器负责接收键盘传来的按键事件更新输入框的文本和光标位置。这三者之间通过回调函数通信耦合度低方便单独测试和替换。输入控制器我封装了一个CustomInputController内部持有一个TextEditingController和一个FocusNode键盘按键触发时直接操作TextEditingController的text和selection属性。提示不要试图用FocusNode去唤起系统键盘因为我们的目标是完全屏蔽系统键盘。在输入框的onTap里直接调用FocusScope.of(context).unfocus()然后显示自定义键盘。3. 键盘布局设计与核心细节解析3.1 按键布局的网格化设计数字键盘的布局看起来简单但要做得规整、适配不同屏幕宽度还是需要一点技巧。我采用的是经典的 4 列 4 行布局列1列2列3列4123删除456清空789完成.000完成跨行实际实现时我用GridView或者Table都可以但更推荐用ColumnRow手动拼因为按键的宽高比、间距、圆角这些细节用GridView反而不好控制。每个按键封装成一个KeyboardButtonWidget接收label、onTap、flex等参数。按键的高度我一般设置为键盘总高度的1/4减去间距。键盘总高度建议设为屏幕高度的 40% 左右或者固定 280dp。这个值可以根据业务需求调整但不要低于 240dp否则按键太小误触率会很高。3.2 按键的视觉反馈与防误触数字键盘的按键点击频率很高如果没有任何视觉反馈用户会感觉“没按到”。我在KeyboardButton里用了GestureDetector的onTapDown和onTapUp来切换背景色模拟按压效果。注意不要用InkWell因为InkWell的水波纹动画在快速点击时会有延迟感数字键盘需要的是“即按即反馈”。防误触方面我做了两件事。第一按键之间保留至少 1dp 的间距避免相邻按键的点击区域重叠。第二在onTap回调里加了一个 200ms 的节流防止用户手抖连点导致重复输入。这个节流不是必须的但在金额输入场景下非常有用避免用户不小心输入了“1111”而不是“111”。3.3 删除键的长按连续删除删除键如果只能点一次删一个字符用户输入长数字时会很痛苦。我给它加了长按连续删除的功能。实现方式是在onTapDown时启动一个Timer.periodic每隔 100ms 触发一次删除onTapUp或onTapCancel时取消 Timer。同时第一次点击时立即删除一个字符然后延迟 500ms 再启动连续删除这样短按和长按的体验都很自然。Timer? _deleteTimer; void _onDeleteTapDown() { _deleteOnce(); _deleteTimer Timer(const Duration(milliseconds: 500), () { _deleteTimer Timer.periodic(const Duration(milliseconds: 100), (_) { _deleteOnce(); }); }); } void _onDeleteTapUp() { _deleteTimer?.cancel(); _deleteTimer null; }注意Timer一定要在 Widget 的dispose里取消否则会导致内存泄漏页面销毁后 Timer 还在跑操作已经失效的TextEditingController会直接抛异常。3.4 小数点与“00”键的处理逻辑小数点键不是任何时候都能点的。如果当前输入已经包含小数点再点就应该忽略。如果输入为空点小数点应该自动补一个“0.”。这些逻辑需要在输入控制器里做判断。“00”键也是类似如果当前输入为空点“00”应该变成“0”而不是“00”。如果小数点后已经有两位再点“00”应该被拦截。这些细节看起来小但直接影响用户对键盘“好不好用”的判断。我在CustomInputController里写了一个_canInputDot()和_canInputDoubleZero()方法每次按键前先校验不满足条件就直接 return不做任何操作。4. 完整实操流程与核心环节实现4.1 初始化 Overlay 与键盘入口首先你需要在页面的build方法里拿到OverlayState。通常是在MaterialApp的builder里或者页面的BuildContext里通过Overlay.of(context)获取。但要注意Overlay.of(context)必须在Overlay已经存在的上下文中调用否则会返回 null。我的做法是在页面的initState里延迟一帧获取OverlayState? _overlayState; override void didChangeDependencies() { super.didChangeDependencies(); _overlayState Overlay.of(context); }然后定义一个OverlayEntryOverlayEntry? _keyboardEntry; void _showKeyboard() { if (_keyboardEntry ! null) return; _keyboardEntry OverlayEntry( builder: (context) Positioned( left: 0, right: 0, bottom: 0, child: CustomNumberKeyboard( controller: _inputController, onDone: _hideKeyboard, ), ), ); _overlayState?.insert(_keyboardEntry!); } void _hideKeyboard() { _keyboardEntry?.remove(); _keyboardEntry null; }这里用Positioned把键盘固定在底部left: 0, right: 0让它横向撑满bottom: 0贴底。如果你需要键盘上方有一个半透明遮罩可以在OverlayEntry的 builder 里再包一层GestureDetector点击遮罩时关闭键盘。4.2 输入框与键盘的联动输入框我用的是TextField但做了几处改造。第一readOnly: true禁止系统键盘弹出。第二showCursor: true保留光标显示。第三在onTap里调用_showKeyboard()。TextField( controller: _textController, readOnly: true, showCursor: true, onTap: () { FocusScope.of(context).unfocus(); _showKeyboard(); }, decoration: InputDecoration( hintText: 请输入金额, border: OutlineInputBorder(), ), )这里有一个关键点readOnly: true虽然禁止了系统键盘但TextField仍然可以获取焦点光标仍然会显示。如果你不希望光标显示可以把showCursor设为 false但那样用户就不知道当前输入位置了体验反而不好。4.3 文本插入与光标位置管理键盘按键触发时不能简单地_textController.text 1因为这样光标会跳到末尾。正确的做法是操作TextEditingController的selection属性。void insertText(String text) { final value _textController.value; final selection value.selection; final start selection.start; final end selection.end; final newText value.text.replaceRange(start, end, text); final newOffset start text.length; _textController.value value.copyWith( text: newText, selection: TextSelection.collapsed(offset: newOffset), composing: TextRange.empty, ); }删除逻辑类似但要注意当光标在末尾时删除最后一个字符当光标在中间时删除光标前一个字符。如果选中了一段文本删除应该直接删掉选中部分。void deleteText() { final value _textController.value; final selection value.selection; final start selection.start; final end selection.end; if (start end) { if (start 0) return; final newText value.text.replaceRange(start - 1, start, ); _textController.value value.copyWith( text: newText, selection: TextSelection.collapsed(offset: start - 1), ); } else { final newText value.text.replaceRange(start, end, ); _textController.value value.copyWith( text: newText, selection: TextSelection.collapsed(offset: start), ); } }提示composing: TextRange.empty这行很重要。如果不设置在某些输入法框架下可能会残留 composing 区域导致文本显示异常。4.4 键盘动画与显示隐藏过渡直接插入和移除OverlayEntry是没有动画的键盘会“啪”一下出现体验很生硬。我用了AnimatedPositioned或者SlideTransition来做过渡。最简单的方式是在OverlayEntry的 builder 里包一个TweenAnimationBuilderOverlayEntry( builder: (context) TweenAnimationBuilderdouble( tween: Tween(begin: 1.0, end: 0.0), duration: const Duration(milliseconds: 250), curve: Curves.easeOut, builder: (context, value, child) { return Transform.translate( offset: Offset(0, value * 300), child: child, ); }, child: CustomNumberKeyboard(...), ), )隐藏的时候先播放反向动画动画结束后再调用remove()。这里需要用一个AnimationController来管理稍微复杂一点但效果值得。4.5 键盘高度与安全区域适配不同手机的底部安全区域不一样尤其是带手势条的机型。键盘的底部需要加上MediaQuery.of(context).padding.bottom否则按键会被手势条遮挡。Padding( padding: EdgeInsets.only( bottom: MediaQuery.of(context).padding.bottom, ), child: keyboardContent, )键盘的总高度我建议用MediaQuery.of(context).size.height * 0.4加上安全区域高度。这样在大屏手机上键盘不会显得太小在小屏手机上也不会占满整个屏幕。5. 常见问题与排查技巧实录5.1 键盘弹出后页面被顶上去这个问题通常是因为Scaffold的resizeToAvoidBottomInset默认为 true。虽然我们用的是 Overlay理论上不会触发页面重新布局但如果你在OverlayEntry里用了MediaQuery的viewInsets或者页面本身有ListView监听了键盘高度就可能出现这个问题。解决办法把Scaffold的resizeToAvoidBottomInset设为 false并且在OverlayEntry里不要使用MediaQuery.of(context).viewInsets.bottom。5.2 键盘显示后点击其他区域无法关闭这是因为OverlayEntry默认不拦截外部点击。如果你希望点击键盘外部区域关闭键盘需要在OverlayEntry的 builder 里加一个全屏的GestureDetector放在键盘下方。OverlayEntry( builder: (context) Stack( children: [ Positioned.fill( child: GestureDetector( onTap: _hideKeyboard, behavior: HitTestBehavior.translucent, child: Container(color: Colors.transparent), ), ), Positioned( left: 0, right: 0, bottom: 0, child: CustomNumberKeyboard(...), ), ], ), )注意behavior: HitTestBehavior.translucent这样透明区域也能响应点击。5.3 快速切换页面时键盘残留如果用户在键盘显示的状态下快速返回上一页OverlayEntry可能来不及移除导致键盘残留在新页面上。解决办法是在页面的dispose里强制移除override void dispose() { _keyboardEntry?.remove(); _keyboardEntry null; _textController.dispose(); super.dispose(); }同时在_showKeyboard里加一个判断如果_keyboardEntry已经存在先移除再重新插入避免重复插入。5.4 常见问题速查表问题现象可能原因解决办法键盘弹出后页面被顶起resizeToAvoidBottomInset为 true设为 false点击键盘外部无法关闭OverlayEntry 没有全屏点击层加 GestureDetector快速切页键盘残留dispose 未移除 Entrydispose 里强制 remove删除键长按无响应Timer 未启动或提前取消检查 onTapDown/onTapUp小数点可以重复输入未做校验加 _canInputDot 判断光标跳到末尾直接操作 text 属性用 selection 控制键盘底部被遮挡未加安全区域 padding加 MediaQuery padding.bottom按键点击无反馈用了 InkWell 或无水波纹用 GestureDetector 手动切换背景色5.5 实操心得与避坑建议第一不要在 OverlayEntry 里使用 StatefulWidget 的 setState 来更新键盘状态。因为 OverlayEntry 的 builder 是独立的setState 不会触发键盘重建。如果需要动态更新键盘比如切换按键禁用状态应该用ValueNotifier或者ChangeNotifier来驱动。第二键盘的显示和隐藏要加防抖。用户快速点击输入框时可能会连续触发_showKeyboard导致插入多个 OverlayEntry。我在_showKeyboard开头加了if (_keyboardEntry ! null) return;确保同一时间只有一个键盘实例。第三测试时一定要在真机上跑。模拟器的键盘高度和安全区域和真机有差异尤其是 iPhone 带手势条的机型底部安全区域在模拟器上可能显示为 0但真机上是 34dp。这个差异会导致键盘在真机上被遮挡。第四金额输入场景建议限制小数位数。我在insertText里加了一个校验如果当前文本已经包含小数点且小数部分达到两位再输入数字就直接忽略。这个逻辑虽然简单但能避免很多脏数据。第五键盘的圆角和阴影不要做得太重。数字键盘是高频操作组件视觉上应该轻量、干净。我用的是顶部圆角 16dp阴影BoxShadow的 blurRadius 设为 8颜色用Colors.black12这样既有层次感又不抢眼。6. 性能优化与扩展思路6.1 减少 OverlayEntry 重建OverlayEntry的 builder 在每次markNeedsBuild时都会重新执行。如果你的键盘 Widget 比较复杂频繁重建会影响性能。我的做法是把键盘内容抽成一个独立的StatefulWidget在OverlayEntry的 builder 里只创建一次后续通过ValueListenableBuilder来更新按键状态。OverlayEntry( builder: (context) ValueListenableBuilderKeyboardState( valueListenable: _keyboardState, builder: (context, state, child) { return CustomNumberKeyboard( state: state, onKeyTap: _handleKeyTap, ); }, ), )这样键盘的静态部分不会重建只有状态变化的部分会更新。6.2 支持多种键盘模式这套方案不仅可以做数字键盘稍加改造就能支持身份证键盘带 X、车牌键盘带省份简称、甚至自定义符号键盘。核心思路是把按键布局数据化用一个ListListKeyModel来描述键盘布局KeyModel包含label、value、type等字段。渲染时遍历这个二维数组根据type决定按键的样式和行为。class KeyModel { final String label; final String value; final KeyType type; const KeyModel(this.label, this.value, this.type); } enum KeyType { number, dot, delete, clear, done, doubleZero }这样切换键盘模式只需要换一份布局数据代码复用率极高。6.3 与表单校验的联动在实际业务中键盘输入的内容往往需要实时校验。比如金额不能超过限额、不能以小数点开头、不能有多个小数点等。我在CustomInputController里暴露了一个onChanged回调每次文本变化时触发外部可以传入校验函数校验不通过时把错误信息显示在输入框下方。_inputController.onChanged (text) { final error validateAmount(text); setState(() { _errorText error; }); };这样键盘只负责输入校验逻辑完全解耦方便单独维护和测试。6.4 无障碍适配数字键盘的无障碍支持经常被忽略。我建议给每个按键加上Semantics标签这样读屏软件可以正确朗读按键内容。Semantics( label: 数字键 $value, button: true, child: KeyboardButton(...), )对于删除键标签设为“删除”完成键设为“完成”。这个改动很小但对视障用户来说体验提升巨大。7. 我个人在实际操作中的几点体会这套 Overlay 自定义数字键盘的方案我从第一个版本到现在稳定运行前后迭代了大概四五个版本。最开始只考虑了功能实现后来慢慢补上了动画、防抖、安全区域适配、无障碍支持这些细节。回头来看最核心的经验就是不要试图去改造系统键盘而是完全绕开它。系统键盘的行为在不同平台、不同版本、不同机型上差异太大你永远不知道下一个坑在哪里。用 Overlay 自己画一个键盘虽然前期投入多一点但后期的可控性和稳定性是完全值得的。另外输入控制器的文本插入和光标管理是整套方案里最容易出 bug 的地方。我建议你在写这部分逻辑时一定要把TextEditingController的value当成一个不可变对象来操作每次修改都通过copyWith生成新的 value而不是直接改text属性。这样可以避免很多光标跳动和文本错乱的问题。最后再分享一个小技巧如果你需要在键盘上显示一个“完成”按钮并且希望点击后收起键盘同时触发提交动作可以在onDone回调里先调用_hideKeyboard()然后延迟一帧再执行提交逻辑。因为键盘移除有一个动画过程如果立即提交页面可能会在键盘还没完全消失时就跳转视觉上会很突兀。延迟 250ms 左右等键盘动画结束再提交体验会顺滑很多。