[flutter] 防止 stack 布局的组件被软键盘顶起导致的 overflow 错误

解决方法:

  1. Scaffold 中的 resizeToAvoidBottomInset 属性设为 false,防止软键盘弹起时自动调整 stack 布局。
  2. 为中间的登录表单添加 SingleChildScrollView 包裹,使其 overflow 时可滚动。
  3. 使用 WidgetsBindingObserver mixins 中的 didChangeMetrics() 方法获取软键盘的高度。
  4. 在第2点中的 SingleChildScrollView 包裹的表单底部添加一个高度为软键盘高度的占位的 Sizedbox

参考代码:

class LoginHomePage extends StatefulWidget {
  const LoginHomePage({super.key});

  @override
  State<LoginHomePage> createState() => _LoginHomePageState();
}

class _LoginHomePageState extends LxState<LoginHomePage>
    with WidgetsBindingObserver {
  final FocusNode _focusNode = FocusNode();
  final FocusNode _focusNode2 = FocusNode();
  TextEditingController? _usernameCtrl;
  TextEditingController? _passwordCtrl;

  String _username = "";
  String _password = "";

  double _keyboardHeight = 0;

  @override
  void initState() {
    super.initState();
    _usernameCtrl = TextEditingController(text: _username);
    _passwordCtrl = TextEditingController(text: _password);
    WidgetsBinding.instance.addObserver(this);
  }

  @override
  void didChangeMetrics() {
    var pageHeight = context.screenHeight;
    if (pageHeight <= 0) {
      return;
    }
    
    // 获取软键盘高度
    final double keyboardHeight = MediaQuery.of(context).viewInsets.bottom;
    setState(() {
      _keyboardHeight = keyboardHeight;
    });
    super.didChangeMetrics();
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    super.build(context);
    return Scaffold(
      extendBodyBehindAppBar: true,
      // 防止 stack 布局被软键盘顶起
      resizeToAvoidBottomInset: false,
      backgroundColor: Colors.white,
      body: _buildBody(context),
    );
  }

  Widget _buildBody(BuildContext context) {
    return Stack(
      // 让非定位组件尽可能大(为了设置背景图片和遮罩)
      fit: StackFit.expand,
      alignment: Alignment.center,
      children: [
        // 背景图片
        Image(
          image: AssetImage("assets/images/bg_login.png"),
          width: context.screenWidth,
          // height: context.screenHeight,
          fit: BoxFit.cover,
          filterQuality: FilterQuality.high,
          opacity: AlwaysStoppedAnimation(1.0),
        ),

        // 遮罩
        Container(color: Colors.white.withAlpha(180)),

        // Logo 及 登入表单(重点!)
        Center(
          child: SingleChildScrollView(
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                // Logo
                Image(
                  width: 240,
                  image: AssetImage(
                     "assets/images/logo.png",
                  ),
                ),

                // 登入表单
                _buildLoginForm(context),

                // 占位(重点!)
                SizedBox(height: _keyboardHeight),
              ],
            ),
          ),
        ),

        // App 版本
        Align(
          alignment: Alignment.topRight,
          child: Padding(
            padding: EdgeInsets.symmetric(
              horizontal: 16.0,
              vertical: context.statusBarHeight,
            ),
            child: Text("v${Constants.appVersionName()}"),
          ),
        ),

        // Copyright
        Align(
          alignment: Alignment.bottomCenter,
          child: Padding(
            padding: const EdgeInsets.symmetric(vertical: 4.0),
            child: Text(
              S.current.powered_by,
            ),
          ),
        ),
      ],
    );
  }

  Widget _buildLoginForm(BuildContext context) {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 36.0, vertical: 16.0),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Listener(
            onPointerDown: (event) {
              FocusScope.of(context).requestFocus(_focusNode);
            },
            child: TextField(
              controller: _usernameCtrl,
              focusNode: _focusNode,
              onChanged: (value) {
                _username = value;
              },
              onTapUpOutside: (event) {
                _focusNode.unfocus();
              },
              onSubmitted: (value) {
                _focusNode.unfocus();
                _focusNode2.requestFocus();
              },
              decoration: InputDecoration(
                border: OutlineInputBorder(),
                label: Text(S.current.login_name),
                prefixIcon: Padding(
                  padding: const EdgeInsets.symmetric(vertical: 10),
                  child: Icon(Icons.person_outlined),
                ),
              ),
            ),
          ),

          const Padding(padding: EdgeInsets.only(top: 16)),

          Listener(
            onPointerDown: (event) {
              FocusScope.of(context).requestFocus(_focusNode2);
            },
            child: TextField(
              controller: _passwordCtrl,
              onChanged: (value) {
                _password = value;
              },
              focusNode: _focusNode2,
              keyboardType: TextInputType.visiblePassword,
              obscureText: true,
              onTapUpOutside: (event) {
                _focusNode2.unfocus();
              },
              onSubmitted: (value) {
                _focusNode2.unfocus();
                _login(context);
              },
              decoration: InputDecoration(
                label: Text(S.current.login_pass),
                border: OutlineInputBorder(),
                prefixIcon: Padding(
                  padding: const EdgeInsets.symmetric(vertical: 10),
                  child: Icon(Icons.lock_outline),
                ),
              ),
            ),
          ),

          Container(
            margin: EdgeInsets.only(bottom: 0, top: 30),
            padding: EdgeInsets.symmetric(vertical: 16),
            width: double.maxFinite,
            child: MaterialButton(
              color: MyColors.styleGreen,
              height: 50,
              child: Text(
                S.current.login,
                style: TextStyle(color: Colors.white, fontSize: 18),
              ),
              onPressed: () {
                _login(context);
              },
            ),
          ),
        ],
      ),
    );
  }

  void _login(BuildContext context) async {
    if (_username.isEmpty || _password.isEmpty) {
      toast(S.current.login_not_null);
      return;
    }
    LoadingDialog.show(
      context: context,
      progressColor: MyColors.styleColorLight,
    );
    try {
      await UserDao.login(_username, _password);
    } catch (e) {
      print(e);
    }

    LoadingDialog.hide();

    if (UserManager.isLoggedIn()) {
      toast(S.current.login_success);
      if (context.mounted) {
        context.push(Routes.home);
      }
    }
  }
}

发表评论


*