You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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组件因引用变化被强制重建,进一步加剧了焦点问题。

解决方案

  1. 修正LoginState中控制器的状态更新逻辑

    • 确保passwordController在LoginState初始化时创建,而非每次build时重新实例化。
    • 仅当控制器内容真正需要同步到全局状态时,才调用notifyListeners(),避免无意义的状态更新触发页面重建。
  2. 优化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方法,方便更新参数时保留原有实例引用。
  3. 缩小Consumer的作用范围

    • 不要用Consumer包裹整个登录页,只将其作用于真正依赖LoginState的部分(比如控制器),避免全量重建:
      Input(
        param: passwordParam.copyWith(
          controller: context.watch<LoginState>().passwordController,
        ),
        onPressed: () {},
      )
      
  4. 确保FocusNode的生命周期正确

    • 在State的dispose方法中释放FocusNode,避免内存泄漏:
      @override
      void dispose() {
        usernameNode.dispose();
        passwordNode.dispose();
        super.dispose();
      }
      

内容的提问来源于stack exchange,提问作者Abdullah Azzam

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.13 15:24:53