Flutter TextField设置obscure为true时失去焦点问题排查
Flutter密码输入框设置
obscure: true后输入首字符失焦问题 问题现象
用户名输入框功能正常,但密码输入框设置obscure: true后,输入第一个字符就会失去焦点;将obscure设为false则恢复正常。
已尝试的解决措施
- 新旧设备均测试,问题复现
- 清理构建文件、.gradle、pub缓存后重新构建,问题仍未解决
相关代码
input.dart
class Input extends StatelessWidget { final VoidCallback? onPressed; final InputParameter param; const Input({super.key, required this.onPressed, required this.param}); @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { return SizedBox( width: param.size?.width ?? constraints.maxWidth, child: Container( height: param.size?.height ?? 48, padding: EdgeInsets.only( left: param.padding[0], right: param.padding[1] ), decoration: BoxDecoration( color: !(param.static ?? false) ? param.color ?? Color(0xFFE2E2E2) : AppColors.transparentMain, border: param.withBorder ? Border.all( width: 1, color: param.borderColor ?? Colors.transparent ) : null, borderRadius: BorderRadius.circular(param.borderRadius ?? 10), boxShadow: [ BoxShadow( offset: const Offset(1, 1), color: param.shadow ?? false ? Colors.black12 : Colors.transparent, spreadRadius: 2, blurRadius: 2 ) ] ), child: Container( padding: param.expands ?? false ? const EdgeInsets.symmetric(vertical: 10) : null, height: param.inputFieldSize?.height ?? 48, child: Align( alignment: Alignment.centerLeft, child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ if(param.icon != null) SizedBox( width: param.iconSize?.width ?? 20, height: param.iconSize?.height ?? 20, child: Center( child: Icon( param.icon ?? Icons.search, color: param.iconColor ?? Colors.grey ) ), ), const SizedBox(width: 5), Expanded( child: TextField( expands: param.expands ?? false, minLines: param.expands ?? false ? null : 1, maxLines: param.expands ?? false ? null : 1, autofocus: false, enabled: !(param.static ?? false), cursorHeight: param.cursorHeight ?? 23, cursorWidth: 1.5, cursorColor: Colors.black, focusNode: param.node, controller: param.controller, textInputAction: param.textInputAction, obscureText: param.obscure ?? false, onEditingComplete: param.onEditingComplete, style: AppTextStyle.mainStyle( fontSize: param.fontSize ?? 13.5, height: 1.5, fontWeight: FontWeight.w500, color: param.fontColor ?? Colors.black, decoration: TextDecoration.none ), decoration: InputDecoration( contentPadding: const EdgeInsets.only(bottom: 2, top: 0), alignLabelWithHint: true, isDense: true, focusColor: param.fontColor, hintStyle: AppTextStyle.mainStyle( color: param.hintColor ?? Colors.grey, fontSize: param.fontSize ?? 13.5, fontWeight: FontWeight.w500, height: 1.5, ), border: InputBorder.none, focusedBorder: InputBorder.none, enabledBorder: InputBorder.none, errorBorder: InputBorder.none, disabledBorder: InputBorder.none, hintText: param.name.tr, ), onSubmitted: (value) async { (onPressed ?? (() {}))(); }, keyboardType: param.isNumber ?? false ? TextInputType.number : TextInputType.text, ), ), const SizedBox(width: 10), if(param.isLoading ?? true) const SizedBox( height: 20, width: 20, child: CircularProgressIndicator( color: Colors.black26, ) ), ], ) ), ), ), ); }, ); } }
登录页代码片段
class LoginPage extends StatefulWidget { LoginPage({super.key}); @override State<LoginPage> createState() => _LoginPageState(); } class _LoginPageState extends State<LoginPage> { final GlobalKey<FormState> _formKey = GlobalKey<FormState>(); final FocusNode usernameNode = FocusNode(); final FocusNode passwordNode = FocusNode(); @override Widget build(BuildContext context) { return Consumer<LoginState>( builder: (context, state, child) { debugPrint("D543 rebuild ..."); return WillPopScope( onWillPop: () async { debugPrint("SS75465E65 pop detected"); return false; }, child: Scaffold( backgroundColor: Colors.white, body: Form( key: _formKey, child: LayoutBuilder( builder: (context, constraints) { double deviceWidth = constraints.maxWidth; double deviceHeight = constraints.maxHeight; double topPadding = MediaQuery.of(context).padding.top; double defaultWidth = deviceWidth - 70; return Container( height: deviceHeight, width: max(/*min(deviceWidth, 450)*/ deviceWidth, 300), color: Colors.white, child: SingleChildScrollView( child: SizedBox( height: max(deviceHeight, 600), width: max(deviceWidth, 300), child: Stack( children: [ Container( constraints: BoxConstraints( maxWidth: max( deviceWidth, 300), ), child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: [ const SizedBox(height: 15), Padding( padding: const EdgeInsets.symmetric(vertical: 5.5), child: Container( height: 43, width: defaultWidth, decoration: BoxDecoration( border: Border( bottom: BorderSide( width: 1, color: Color(0xFF000080) ) ) ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.center, children: [ Container( color: Colors.white, width: 14, child: Image.asset( "assets/ic_username.png", fit: BoxFit.fitWidth ) ), Input( param: InputParameter( name: "Username", id: 0, padding: [0, 0], fontSize: 15, color: Colors.white, size: Size(defaultWidth - 20, 40), node: usernameNode, controller: state.usernameController, isRed: false, static: false, ), onPressed: () { //will be implemented soon }, ) ], ) ), ), const SizedBox(height: 3), Padding( padding: const EdgeInsets.symmetric(vertical: 5.5), child: Container( height: 43, width: defaultWidth, decoration: BoxDecoration( border: Border( bottom: BorderSide( width: 1, color: Color(0xFF000080) ) ) ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.center, children: [ Container( color: Colors.white, width: 14, child: Image.asset( "assets/ic_password.png", fit: BoxFit.fitWidth ) ), Input( param: InputParameter( name: "Password", id: 1, padding: [0, 0], fontSize: 15, color: Colors.white, size: Size(defaultWidth - 20, 40), node: passwordNode, controller: state.passwordController, obscure: true, isRed: false, static: false, ), onPressed: () { //will be implemented soon }, ) ], ) ), ), ...
问题分析及解决方案
根本原因
核心问题是登录页在输入密码字符时触发了不必要的全量重建,导致TextField的焦点状态异常。
从代码中的debugPrint("D543 rebuild ...");可以确认,每次输入字符时整个页面都会重建。当obscure: true时,TextField的文本变化会触发UI更新,如果LoginState中的passwordController在输入时错误地触发了notifyListeners(),会导致Consumer强制重建整个登录页面。此时即便FocusNode是全局对象,页面重建时Input组件被重新实例化,TextField重新关联FocusNode的过程中会出现焦点丢失的情况。
另外,登录页每次build都会新建InputParameter实例,导致Input组件因引用变化被强制重建,进一步加剧了焦点问题。
解决方案
修正LoginState中控制器的状态更新逻辑
- 确保
passwordController在LoginState初始化时创建,而非每次build时重新实例化。 - 仅当控制器内容真正需要同步到全局状态时,才调用
notifyListeners(),避免无意义的状态更新触发页面重建。
- 确保
优化InputParameter的创建方式
- 在登录页的State中提前创建InputParameter实例,而非在build方法中每次新建,保持引用稳定性:
// 在_LoginPageState中添加 late InputParameter usernameParam; late InputParameter passwordParam; @override void initState() { super.initState(); final loginState = context.read<LoginState>(); usernameParam = InputParameter( name: "Username", id: 0, padding: [0, 0], fontSize: 15, color: Colors.white, node: usernameNode, controller: loginState.usernameController, isRed: false, static: false, ); passwordParam = InputParameter( name: "Password", id: 1, padding: [0, 0], fontSize: 15, color: Colors.white, node: passwordNode, controller: loginState.passwordController, obscure: true, isRed: false, static: false, ); } // 在build中仅更新需要动态变化的size参数 usernameParam = usernameParam.copyWith(size: Size(defaultWidth - 20, 40)); passwordParam = passwordParam.copyWith(size: Size(defaultWidth - 20, 40)); - 给
InputParameter添加copyWith方法,方便更新参数时保留原有实例引用。
- 在登录页的State中提前创建InputParameter实例,而非在build方法中每次新建,保持引用稳定性:
缩小Consumer的作用范围
- 不要用Consumer包裹整个登录页,只将其作用于真正依赖
LoginState的部分(比如控制器),避免全量重建:Input( param: passwordParam.copyWith( controller: context.watch<LoginState>().passwordController, ), onPressed: () {}, )
- 不要用Consumer包裹整个登录页,只将其作用于真正依赖
确保FocusNode的生命周期正确
- 在State的
dispose方法中释放FocusNode,避免内存泄漏:@override void dispose() { usernameNode.dispose(); passwordNode.dispose(); super.dispose(); }
- 在State的
内容的提问来源于stack exchange,提问作者Abdullah Azzam
相关产品推荐
相关产品推荐

