Flutter 自定义数字键盘:基于 Overlay 的完整实现方案 1. 为什么要在 Flutter 里自己造一个数字键盘移动端开发做久了总会碰到系统键盘“不听话”的场景。比如金额输入、密码输入、数量选择这类只需要数字的界面系统键盘弹出来一大片字母、符号、表情全在上面用户误触率高界面也被顶得乱七八糟。更麻烦的是系统键盘在不同机型上的高度、动画、安全区处理都不一样想让它跟你的自定义 UI 严丝合缝地对齐基本是件费力不讨好的事。我在几个涉及支付、记账、库存盘点的项目里都遇到过这个需求最后走通的方案都是同一个思路用 Overlay 挂一个自定义数字键盘上去彻底绕开系统输入法。这个方案的核心价值在于三点。第一键盘完全由自己控制布局、按键大小、震动反馈、动画节奏都能按设计稿来第二它不依赖系统输入法不会出现“用户装了某个输入法导致行为不一致”的问题第三配合Overlay的层级能力键盘可以浮在页面最上层不受Scaffold、BottomSheet、Dialog的布局约束。这篇文章适合谁看如果你正在做 Flutter 的金额输入、密码键盘、收银台、称重录入这类界面并且已经受够了系统键盘的不可控那这篇内容基本可以直接抄作业。我会从整体设计思路讲到 Overlay 的挂载原理再到按键事件分发、光标与文本同步、安全区适配、性能与内存回收最后把我踩过的坑整理成一张速查表。全程按“一个合格从业者在这个场景下最可能采用的方案”来补全细节代码可以直接跑。需要提前说明的是下面所有代码基于 Flutter 稳定版的通用 API不涉及任何平台特定的私有能力Android 和 iOS 都能用。文中出现的项目、页面、模块名称均为虚构代称仅用于说明结构。2. 整体方案设计与技术选型拆解2.1 为什么是 Overlay而不是 BottomSheet 或普通 Widget很多人第一反应是把键盘写成一个普通 Widget塞进Column底部或者用BottomSheet弹出来。这两种做法我都试过问题很明确。普通 Widget 塞进Column的问题在于它会参与父级的布局计算。页面内容一多键盘就被挤下去或者把内容顶上去滚动位置全乱。而且当页面里有ListView、TextField时键盘和输入框的联动会变得非常别扭你得手动算偏移量。BottomSheet稍微好一点它是浮层但它本质上还是挂在当前Scaffold的Overlay之下层级受限于路由。如果你在Dialog里再弹一个BottomSheet层级关系就会打架键盘可能被Dialog盖住。而且BottomSheet自带拖拽手势和遮罩逻辑想关掉这些默认行为又要写一堆配置。Overlay的优势在于它是 Flutter 里最顶层的渲染容器之一OverlayEntry可以插入到Overlay的任意位置并且可以通过Overlay.of(context)从任意位置拿到。把键盘作为一个OverlayEntry插进去它就浮在整个页面之上不受任何布局约束。想显示就insert想隐藏就remove生命周期完全自己掌控。提示Overlay是Navigator的底层依赖每个Navigator都自带一个Overlay。所以只要你的页面在路由栈里就一定能拿到Overlay不需要额外包裹OverlayWidget。2.2 键盘与输入框的通信设计键盘浮在上面输入框在下面两者怎么通信这是整个方案的关键。我的做法是定义一个轻量的控制器让输入框和键盘都持有它通过它来同步文本和光标。控制器需要承担四件事保存当前文本内容、保存光标位置、对外暴露文本变化通知、接收键盘的按键事件。这样输入框只负责显示键盘只负责产生事件逻辑全部收敛到控制器里。好处是解耦彻底键盘可以复用到任何输入框上输入框也不需要知道键盘长什么样。这里有个细节要注意不要用TextEditingController直接当通信桥梁。因为TextEditingController的textsetter 会把光标重置到末尾如果你在中间插入字符光标位置就错了。正确做法是操作TextEditingValue同时设置text和selection。2.3 按键事件的分发模型键盘上的按键分几类数字键、删除键、小数点键、完成键可能还有“清空”“隐藏”这类功能键。每类按键的行为不同但触发方式一致。我采用的方式是给每个按键定义一个KeyType枚举按键被点击时把类型和值一起抛给控制器控制器根据类型决定怎么改文本。这样做的好处是扩展性强。以后要加“”号或者“-”号只需要加一个枚举值和处理分支键盘布局改一下就行不用动通信逻辑。而且按键的视觉表现和业务逻辑彻底分离UI 改版不影响功能。2.4 安全区与多机型的适配策略自定义键盘最容易被忽略的就是安全区。全面屏手机底部有手势条如果键盘贴着屏幕底部按键会被手势条挡住用户点不到。我的处理方式是在键盘容器底部加MediaQuery.of(context).padding.bottom的内边距让按键整体上移。另外键盘高度不要写死。不同屏幕尺寸下固定高度会导致按键过大或过小。我一般按屏幕宽度的比例来算按键高度比如每个按键高度取screenWidth / 4 * 0.6这样在手机和平板上视觉比例都协调。具体数值后面实操部分会给。3. Overlay 挂载与键盘核心实现细节3.1 OverlayEntry 的创建与插入时机OverlayEntry的创建本身很简单关键是插入和移除的时机。我的经验是在输入框获得焦点时插入失去焦点或点击完成时移除。但这里有个坑FocusNode的hasFocus变化和Overlay的插入如果处理不好会出现键盘闪一下又消失的情况。正确的做法是监听FocusNode在didChangeDependencies或者initState里注册监听当hasFocus变为true时插入OverlayEntry变为false时移除。移除前要判断OverlayEntry.mounted避免重复移除报错。void _onFocusChange() { if (_focusNode.hasFocus) { _showKeyboard(); } else { _hideKeyboard(); } } void _showKeyboard() { if (_overlayEntry ! null) return; _overlayEntry OverlayEntry( builder: (context) NumberKeyboard( controller: _controller, onDone: _hideKeyboard, ), ); Overlay.of(context).insert(_overlayEntry!); } void _hideKeyboard() { _overlayEntry?.remove(); _overlayEntry null; }注意Overlay.of(context)拿到的OverlayState是当前路由的。如果你在Dialog里用要确保context是Dialog内部的否则键盘会插到Dialog下面被盖住。3.2 键盘布局的构建方式键盘布局我用的是Column套Row每行四个按键最后一行是“小数点、0、删除、完成”。按键用Expanded包裹保证等宽。按键之间的间距用SizedBox或者Padding控制我倾向于用Padding因为SizedBox在Expanded里容易算错宽度。按键的视觉反馈很重要。数字键按下时要有背景色变化删除键长按要支持连续删除。我用GestureDetector的onTapDown、onTapUp、onTapCancel来控制按下态用onLongPress配合Timer.periodic实现长按连续删除。这里要注意Timer的取消否则页面销毁后定时器还在跑会报setState after dispose。Timer? _deleteTimer; void _onDeleteLongPressStart() { _deleteTimer Timer.periodic(const Duration(milliseconds: 100), (_) { _controller.delete(); }); } void _onDeleteLongPressEnd() { _deleteTimer?.cancel(); _deleteTimer null; }3.3 文本与光标的同步逻辑前面提到操作文本要用TextEditingValue。具体来说插入字符时取当前selection的start和end把新字符插到start位置然后把光标移到新字符后面。删除时如果start end删除start - 1位置的字符如果start ! end删除选中区间。void insert(String value) { final text _controller.text; final selection _controller.selection; final newText text.replaceRange(selection.start, selection.end, value); final newOffset selection.start value.length; _controller.value TextEditingValue( text: newText, selection: TextSelection.collapsed(offset: newOffset), ); } void delete() { final text _controller.text; final selection _controller.selection; if (selection.start selection.end selection.start 0) return; final start selection.start selection.end ? selection.start - 1 : selection.start; final newText text.replaceRange(start, selection.end, ); _controller.value TextEditingValue( text: newText, selection: TextSelection.collapsed(offset: start), ); }这段逻辑看着简单但实际写的时候很容易漏掉边界情况。比如空文本时按删除、光标在开头时按删除、选中一段文本时按删除都要单独处理。我建议把这几个分支写成单元测试跑一遍再上真机。3.4 键盘显示与隐藏的动画处理直接insert和remove是瞬间完成的视觉上很生硬。我的做法是在键盘 Widget 内部用AnimatedPositioned或者AnimatedSlide做入场动画。OverlayEntry插入时键盘从底部滑入移除时先播放滑出动画动画结束后再remove。这里要注意OverlayEntry的remove是同步的如果你在动画没结束时就remove动画会直接中断。所以隐藏逻辑要改成先触发动画状态变化等AnimationController的status变成dismissed后再调用remove。这个回调可以通过onDone传给键盘组件。4. 完整实操流程与关键代码实现4.1 控制器类的完整实现控制器是整个方案的中枢我把它设计成一个ChangeNotifier这样输入框可以直接用AnimatedBuilder或者ValueListenableBuilder监听变化。核心字段就三个TextEditingController、FocusNode、以及键盘的显示状态。class NumberInputController extends ChangeNotifier { final TextEditingController textController TextEditingController(); final FocusNode focusNode FocusNode(); bool _keyboardVisible false; bool get keyboardVisible _keyboardVisible; void showKeyboard() { _keyboardVisible true; notifyListeners(); } void hideKeyboard() { _keyboardVisible false; focusNode.unfocus(); notifyListeners(); } void insert(String value) { final text textController.text; final selection textController.selection; final newText text.replaceRange(selection.start, selection.end, value); final newOffset selection.start value.length; textController.value TextEditingValue( text: newText, selection: TextSelection.collapsed(offset: newOffset), ); } void delete() { final text textController.text; final selection textController.selection; if (selection.start selection.end selection.start 0) return; final start selection.start selection.end ? selection.start - 1 : selection.start; final newText text.replaceRange(start, selection.end, ); textController.value TextEditingValue( text: newText, selection: TextSelection.collapsed(offset: start), ); } override void dispose() { textController.dispose(); focusNode.dispose(); super.dispose(); } }这个控制器不依赖任何 UI可以单独测试。我一般会写几个测试用例空文本删除、中间插入、选中替换、光标移动后插入确保逻辑正确。4.2 键盘组件的布局与按键实现键盘组件接收控制器和一个onDone回调。布局上我用Container包裹底部加安全区内部用Column分四行。每行用Row加Expanded均分。class NumberKeyboard extends StatefulWidget { final NumberInputController controller; final VoidCallback onDone; const NumberKeyboard({ super.key, required this.controller, required this.onDone, }); override StateNumberKeyboard createState() _NumberKeyboardState(); } class _NumberKeyboardState extends StateNumberKeyboard { Timer? _deleteTimer; override Widget build(BuildContext context) { final bottomPadding MediaQuery.of(context).padding.bottom; final keyHeight MediaQuery.of(context).size.width / 4 * 0.6; return Positioned( left: 0, right: 0, bottom: 0, child: Container( padding: EdgeInsets.only(bottom: bottomPadding), decoration: const BoxDecoration( color: Color(0xFFF5F5F5), border: Border(top: BorderSide(color: Color(0xFFE0E0E0))), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ _buildRow([1, 2, 3], keyHeight), _buildRow([4, 5, 6], keyHeight), _buildRow([7, 8, 9], keyHeight), _buildLastRow(keyHeight), ], ), ), ); } Widget _buildRow(ListString keys, double height) { return Row( children: keys .map((key) Expanded( child: _NumberKey( label: key, height: height, onTap: () widget.controller.insert(key), ), )) .toList(), ); } Widget _buildLastRow(double height) { return Row( children: [ Expanded( child: _NumberKey( label: ., height: height, onTap: () widget.controller.insert(.), ), ), Expanded( child: _NumberKey( label: 0, height: height, onTap: () widget.controller.insert(0), ), ), Expanded( child: _DeleteKey( height: height, onTap: () widget.controller.delete(), onLongPressStart: _startDeleteTimer, onLongPressEnd: _stopDeleteTimer, ), ), Expanded( child: _NumberKey( label: 完成, height: height, onTap: widget.onDone, ), ), ], ); } void _startDeleteTimer() { _deleteTimer Timer.periodic(const Duration(milliseconds: 100), (_) { widget.controller.delete(); }); } void _stopDeleteTimer() { _deleteTimer?.cancel(); _deleteTimer null; } override void dispose() { _deleteTimer?.cancel(); super.dispose(); } }按键组件_NumberKey用GestureDetector包裹按下时改变背景色。这里有个细节GestureDetector的onTapDown和onTapUp在快速点击时可能不触发onTapUp所以背景色恢复要同时监听onTapCancel。4.3 输入框与键盘的联动接入输入框这边我用一个GestureDetector包裹TextField点击时显示键盘。TextField本身要设置readOnly: true防止系统键盘弹出来。showCursor设为true让光标可见。GestureDetector( onTap: () { controller.showKeyboard(); controller.focusNode.requestFocus(); }, child: AbsorbPointer( child: TextField( controller: controller.textController, focusNode: controller.focusNode, readOnly: true, showCursor: true, decoration: const InputDecoration( hintText: 请输入金额, border: OutlineInputBorder(), ), ), ), )AbsorbPointer的作用是拦截TextField自身的点击事件让外层GestureDetector接管。如果不加点击输入框会触发TextField的焦点逻辑可能和我们的键盘显示逻辑冲突。4.4 键盘的显示与隐藏动画动画部分我在键盘组件外层包一个AnimatedSlide根据控制器的keyboardVisible状态决定偏移量。显示时偏移Offset.zero隐藏时偏移Offset(0, 1)。AnimatedSlide( offset: controller.keyboardVisible ? Offset.zero : const Offset(0, 1), duration: const Duration(milliseconds: 200), curve: Curves.easeOut, child: NumberKeyboard( controller: controller, onDone: controller.hideKeyboard, ), )但这里有个问题OverlayEntry插入时keyboardVisible已经是true动画不会播放。解决办法是在插入后延迟一帧再设置keyboardVisible或者用AnimationController手动控制。我一般用WidgetsBinding.instance.addPostFrameCallback在插入后触发状态更新。5. 常见问题与排查技巧实录5.1 键盘被 Dialog 或 BottomSheet 遮挡这是最常见的问题。原因在于Overlay.of(context)拿到的Overlay是当前路由的如果键盘插入到当前路由的Overlay而Dialog是另一个路由层级就会乱。解决办法有两个。一是把键盘插入到根Overlay用Overlay.of(context, rootOverlay: true)。二是把键盘的OverlayEntry插入到Dialog所在的Overlay也就是在Dialog的context里调用Overlay.of。我一般用第一种简单直接。注意rootOverlay: true会让键盘浮在所有路由之上包括Dialog。如果你的键盘需要在某些页面隐藏记得在路由切换时手动移除。5.2 快速点击时按键状态不恢复前面提到过GestureDetector在快速点击时可能不触发onTapUp。我的处理方式是同时监听onTapDown、onTapUp、onTapCancel三个回调里都做状态恢复。另外按键的背景色变化用setState控制不要用AnimatedContainer的隐式动画因为快速点击时动画会堆积。5.3 长按删除时定时器泄漏长按删除用Timer.periodic如果页面在长按过程中被销毁定时器还在跑就会报错。解决办法是在dispose里取消定时器同时在onLongPressEnd和onLongPressCancel里也取消。我还会在定时器回调里判断mounted双重保险。5.4 光标位置在插入后跳到末尾这个问题前面提过根源是用textController.text newText而不是textController.value TextEditingValue(...)。textsetter 内部会把selection重置为TextSelection.collapsed(offset: newText.length)也就是末尾。所以一定要用valuesetter手动指定selection。5.5 键盘高度在不同机型上不一致如果键盘高度写死在小平板上会显得很矮在大屏手机上又显得很高。我的做法是按屏幕宽度算keyHeight screenWidth / 4 * 0.6。这个系数是我在多个项目里调出来的按键的宽高比接近 1.6:1手指点击区域足够视觉上也舒服。如果设计稿有明确高度那就按设计稿来但记得加安全区。5.6 常见问题速查表问题现象可能原因排查方向解决方案键盘被 Dialog 遮挡Overlay 层级不对检查 Overlay.of 的 context用 rootOverlay: true按键按下不恢复onTapUp 未触发检查 GestureDetector 回调同时监听 onTapCancel长按删除报错定时器未取消检查 dispose 逻辑dispose 里 cancel 定时器光标跳到末尾用了 text setter检查文本更新方式改用 value setter键盘高度异常写死高度检查高度计算按屏幕宽度比例算键盘闪一下消失焦点与插入时机冲突检查 FocusNode 监听插入前判断 mounted键盘不显示Overlay 未插入检查 insert 调用确认 context 有效5.7 几个我踩过的坑第一个坑是OverlayEntry重复插入。如果用户在输入框之间快速切换_showKeyboard可能被调用两次导致同一个OverlayEntry被插入两次Flutter 会报错。解决办法是在_showKeyboard开头判断_overlayEntry ! null如果已经存在就直接返回。第二个坑是键盘隐藏后FocusNode没释放。如果只remove了OverlayEntry没有unfocus下次点击输入框时hasFocus还是true监听回调不触发键盘就不显示了。所以隐藏逻辑里一定要focusNode.unfocus()。第三个坑是键盘在页面返回时没移除。如果用户在键盘显示状态下按了返回键页面销毁了但OverlayEntry还在会报setState after dispose。解决办法是在dispose里调用_hideKeyboard确保OverlayEntry被移除。6. 性能优化与可扩展性思考6.1 减少不必要的重建键盘组件本身不应该频繁重建。我把键盘的build方法里的计算尽量提前比如keyHeight和bottomPadding在build开头算一次不要在每次按键点击时重算。按键的onTap回调用widget.controller.insert直接调用不要在里面setState因为控制器的notifyListeners会触发输入框更新键盘本身不需要重建。如果键盘上有需要根据状态变化的元素比如“完成”按钮的可用状态那就用ValueListenableBuilder局部刷新不要整个键盘setState。6.2 键盘的复用设计这套方案最大的好处是键盘可以复用。控制器是独立的键盘组件只依赖控制器和onDone回调。所以同一个键盘可以用在金额输入、密码输入、数量输入等不同场景。密码输入只需要把按键的insert逻辑改成往密码缓冲区里塞字符显示用圆点代替。我一般会把键盘组件抽成一个独立的文件控制器也抽成独立文件然后在需要的页面里组合。这样改键盘样式只改一个文件改输入逻辑只改控制器互不影响。6.3 后续可以扩展的方向如果要做更复杂的键盘比如带“”“-”“×”“÷”的计算器键盘可以在KeyType枚举里加类型在控制器里加对应的处理分支。如果要支持语音输入或者手写那就要换一套方案但 Overlay 的挂载思路是一样的。还有一个方向是键盘的震动反馈。Flutter 可以用HapticFeedback.lightImpact()在按键点击时触发震动提升手感。这个 API 在 Android 和 iOS 上都支持但要注意不要每个按键都震否则用户会烦。我一般在删除键和完成键上加震动数字键不加。6.4 内存与生命周期管理OverlayEntry持有键盘组件的引用键盘组件持有控制器的引用控制器持有TextEditingController和FocusNode。这条引用链在页面销毁时必须断开否则会内存泄漏。我的做法是在页面的dispose里依次调用_hideKeyboard、controller.dispose确保所有资源释放。另外OverlayEntry的remove是同步的但键盘组件的dispose是异步的。如果remove后立即dispose控制器键盘组件的dispose可能还没执行会报错。所以我在_hideKeyboard里先remove然后在WidgetsBinding.instance.addPostFrameCallback里再dispose控制器确保顺序正确。7. 一些实操心得与建议这套方案我在三个项目里用过累计覆盖了收银、记账、库存盘点几个场景稳定性没问题。最大的体会是自定义键盘的难点不在键盘本身而在输入框和键盘的状态同步。文本、光标、焦点、键盘可见性这四个状态必须严格一致任何一个不同步都会出 bug。我的建议是先把控制器写出来写几个单元测试跑通文本操作逻辑再写键盘 UI。UI 部分先用最简单的Column加TextButton搭出来确认通信没问题再换成正式的按键样式。这样调试成本最低。另外安全区一定要在真机上测。模拟器上安全区可能显示为 0真机上底部手势条会占掉一部分高度。我一般会在键盘容器底部加一个Container(height: bottomPadding)作为占位确保按键不被遮挡。最后分享一个小技巧如果键盘需要在多个页面复用可以把OverlayEntry的管理逻辑封装成一个KeyboardManager单例页面只需要调用KeyboardManager.show(controller)和KeyboardManager.hide()。这样页面代码更干净也不用每个页面都写一遍OverlayEntry的插入移除逻辑。不过要注意单例的生命周期页面销毁时记得调用hide否则键盘会一直挂在根Overlay上。