[flutter] 防止 stack 布局的组件被软键盘顶起导致的 overflow 错误
解决方法:
- 将
Scaffold
中的resizeToAvoidBottomInset
属性设为false
,防止软键盘弹起时自动调整 stack 布局。 - 为中间的登录表单添加
SingleChildScrollView
包裹,使其overflow
时可滚动。 - 使用
WidgetsBindingObserver
mixins 中的didChangeMetrics()
方法获取软键盘的高度。 - 在第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);
}
}
}
}