问题现状
有时候我们需要在唤起键盘的时候,输入框不是刚好在键盘上面,比如输入框下面还有按钮。像这样(最终UI效果截图示例):
(当然这个截图比较简单,因为他头部内容不多,键盘升起可能本身也挡不住,这里只是一个示例,代码可以支持头部内容较多情况)我们不希望键盘遮挡到下面的按钮,需要怎么处理呢?
首先我们需要了解键盘弹起改变了什么(这个网上搜也会有很多,可以自行搜索也可以,我也是搬迁了一个自认为写的不错的博文:https://www.jianshu.com/p/71978b132a89)。
键盘弹起时,Scaffold 发生了什么
Scaffold 的 resize
Scaffold
是 Flutter 中最常用的页面脚手架,前面知道了通过 resizeToAvoidBottomInset
,我们可以配置在键盘弹起时页面的底部按键和 FloatButton 不会再被顶上来,其实这个行为是因为 Scaffold
的 body
大小被 resize
了。
那这个过程是怎么发生的呢?首先如下图所示,我们在 Scaffold
的源码里可以看到,当resizeToAvoidBottomInset
为 true 时,会使用 mediaQuery.viewInsets.bottom
作为 minInsets
的参数,也就是可以确定:键盘弹起时的界面 resize
和 mediaQuery.viewInsets.bottom
有关系。
而如下图所示, Scaffold
内部的布局主要是靠 CustomMultiChildLayout
,CustomMultiChildLayout
的布局逻辑主要在 MultiChildLayoutDelegate
对象里。
前面获取到的 minInsets
会被用到 _ScaffoldLayout
这个 MultiChildLayoutDelegate
里面,也就是说 Scaffold
的内部是通过 CustomMultiChildLayout
实现的布局,具体实现逻辑在 _ScaffoldLayout
这个 Delegate
里。
关于
CustomMultiChildLayout
的详细使用介绍在之前的文章 《详解自定义布局实战》 里可以找到。
接着看 _ScaffoldLayout
, 在 _ScaffoldLayout
进行布局时,会通过传入的minInsets
来决定 body
显示的 contentBottom
, 所以可以看到事实上传入的 minInsets
改变的是 Scaffold
布局的 bottom 位置。
上图代码中使用的
_ScaffoldSlot.body
这个枚举其实是作为LayoutId
的值,MultiChildLayoutDelegate
在布局时可以通过LayoutId
获取到对应 child 进行布局操作,详细可见: 《详解自定义布局实战》
那么 Scaffold
的 body
是什么呢? 如上图代码所示,其实 Scaffold
的 body
是一个叫 _BodyBuilder
的对象,而这个 _BodyBuilder
内部其实是一个 LayoutBuilder
。(注意,在 widget.appbar
不为 null
时,会 removeTopPadding
)
所以如下图代码所示 body
在添加时,它父级的MediaQueryData
会被重载,特别是 removeTopPadding
会被清空,viewInsets.bottom
也是会被重置。
最后如下代码所示,_BodyBuilder
的 LayoutBuilder
里会获取到一个 top
和 bottom
的参数,这两个参数都通过前面在 _ScaffoldLayout
布局时传入的 constraints
去判断得到,最终 copyWith
得到新的 MediaQuery
。
这里就涉及到一个有意思的点,在 _BodyBuilder
里的通过 copyWith
得到新的 MediaQuery
会影响什么呢?如下代码所示,这里用一个简单的例子来解释下。
class MainWidget extends StatelessWidget {
final TextEditingController controller =
new TextEditingController(text: "init Text");
@override
Widget build(BuildContext context) {
print("Main MediaQuery padding: ${MediaQuery.of(context).padding} viewInsets.bottom: ${MediaQuery.of(context).viewInsets.bottom}");
return Scaffold(
appBar: AppBar(
title: new Text("MainWidget"),
),
extendBody: true,
body: Column(
children: [
new Expanded(child: InkWell(onTap: (){
FocusScope.of(context).requestFocus(FocusNode());
})),
///增加 CustomWidget
CustomWidget(),
new Container(
margin: EdgeInsets.all(10),
child: new Center(
child: new TextField(
controller: controller,
),
),
),
new Spacer(),
],
),
);
}
}
class CustomWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
print("Custom MediaQuery padding: ${MediaQuery.of(context).padding} viewInsets.bottom: ${MediaQuery.of(context).viewInsets.bottom}\n \n");
return Container();
}
}
如上代码所示:
- 代码中定义了
MainWidget
和CustomWidget
两个控件; MainWidget
里使用了Scaffold
,并且CustomWidget
在MainWidget
里被使用;- 分别在这两个 Widget 的
build
方法里打印出对应的MediaQuery.of(context).padding
和MediaQuery.of(context).viewInsets.bottom
的值;
如下图所示,在键盘弹起和不弹起时可以看到 padding
值是不同的,而 viewInsets.bottom
都为 0。
为什么 padding
值的 top
会不一致,自然是因为 CustomWidget
和 MainWidget
获取到的 MediaQuery.of(context)
对象不是同一个数据。
MainWidget
使用的MediaQuery.of(context)
得到的MediaQueryData
是上级往下传递的,里面包含了top:47
的状态栏高度和bottom:34
的底部安全区域高度。
CustomWidget
里面MediaQuery.of(context)
得到的MediaQueryData
,自然就是前面分析过的_BodyBuilder
里的通过copyWith
得到新的MediaQuery
,所以CustomWidget
得到的MediaQueryData
其实在Scaffold
内部已经被重置了,所以它的top:0
,获取不到状态栏高度。
事实上这就是大家为什么有时候
MediaQuery.of( context)
可以获取到状态栏高度,有时候又获取不到的原因,因为你的context
获取到的是Scaffold
之外的MediaQueryData
, 还是Scaffold
内被重载过的MediaQueryData
,自然会得到不一样的结果。
如下图所示,键盘弹起因为被 resize 了,所以界面的 bottom
安全区域变成了 0 ,而
- 在
MainWidget
中可以获取到viewInsets.bottom
也就是键盘的高度; - 在
CustomWidget
获取不到viewInsets.bottom
,因为在Scaffold
内被重载清除了。
总结一下:Scaffold
的 resizeToAvoidBottomInset
会通过 MediaQueryData
影响 body 的布局,同时在 Scaffold
内 MediaQuery
会被重载,所以使用的 context
位置不同,获取到的 MediaQueryData
也不同,如果需要获取键盘高度和状态栏高度的话,最好使用 Scaffold
外的 context
。
这里讲了
MediaQuery
和MediaQueryData
的内容,为什么MediaQuery
通过嵌套就可以重载?为什么通过context
可以往上获取到离context
最近的MediaQueryData
?因为MediaQuery
是一个InheritedWidget
: 《全面理解State》 。
键盘如何影响 Scaffold
前面我们聊了 Scaffold
的 resizeToAvoidBottomInset
会通过 MediaQueryData
影响 body 的布局,那是怎么影响的呢?
事实上这得从 MaterialApp
说起,在 MaterialApp
内部的深处嵌套着一个叫 _MediaQueryFromWindow
的 Widget ,它在内部通过 WidgetsBinding.instance.addObserver
对 App 的各种系统事件做了监听,并且对应都执行了 setState
。
所以如下源码所示,当键盘弹出时, build
方法会被执行, 而 MediaQueryData
就会通过MediaQueryData.fromWindow
获取到新的 MediaQueryData
数据。
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
// ACCESSIBILITY
@override
void didChangeAccessibilityFeatures() {
setState(() { });
}
// METRICS
@override
void didChangeMetrics() {
setState(() {});
}
@override
void didChangeTextScaleFactor() {
setState(() { });
}
// RENDERING
@override
void didChangePlatformBrightness() {
setState(() {});
}
@override
Widget build(BuildContext context) {
MediaQueryData data = MediaQueryData.fromWindow(WidgetsBinding.instance.window);
if (!kReleaseMode) {
data = data.copyWith(platformBrightness: debugBrightnessOverride);
}
return MediaQuery(
data: data,
child: widget.child,
);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
举个例子,如下图所示,从 Android 的 Java 层弹出键盘开始,会把改变后的视图信息传递给 C++ 层,最后回调到 Dart 层,从而触发 MaterialApp
内的 didChangeMetrics
方法执行 setState(() {});
,进而让 _MediaQueryFromWindow
内的 build
更新了 MediaQueryData
,最终改变了 Scaffod
的 body
大小。
那么到这里,我们可以了解到键盘升起的scaffold的变化内容。尤其注意MediaQuery.of( context)在scffold内外的内容可能会存在不一样的问题。
解决方案
我们在scaffold组件存在的widget中添加WidgetsBindingObserver,在页面尺寸改变的回调方法中监听键盘的升起,然后对内部的列表做滑动偏移操作(注:如果observer在子组件中的话这里的滑动也是根据最初键盘未弹起的时候的参数的,偏移执行的时候也会感觉无效的状况)。
代码示例如下:
class FamilyWifiSelectPage extends StatefulWidget {
const FamilyWifiSelectPage({Key? key}) : super(key: key);
@override
State<FamilyWifiSelectPage> createState() => _FamilyWifiSelectPageState();
}
class _FamilyWifiSelectPageState extends State<FamilyWifiSelectPage>
with WidgetsBindingObserver {
FamilyWifiSelectProvider? _familyWifiSelectProvider;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
/// 页面尺寸改变时回调
@override
didChangeMetrics() {
super.didChangeMetrics();
// 在页面重新渲染完成之后,获取软键盘高度
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
var isKeyboardOpen = MediaQuery.of(context).viewInsets.bottom > 0;
if (isKeyboardOpen) {
var scrollController = _familyWifiSelectProvider?.scrollViewController;
//渐少对其他不可scroll包裹页面的键盘唤起
var maxScrollExtent = (scrollController?.hasClients ?? false)
? scrollController?.position.maxScrollExtent
: null;
if (maxScrollExtent != null) {
scrollController?.jumpTo(scrollController.position.maxScrollExtent);
}
}
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
Widget _buildNextBtn() {
return HYXButton(
width: 351.ratio,
height: 39.ratio,
state: HYXButtonState.normal,
title: LocaleKeys.startConfig.tr,
onPressed: () async {
...
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: HYXAppBar(title: sn),
body: ColoredBox(
color: HYXColors.backGround,
child: Column(
children: [
Expanded(
child:
FamilyWifiSelectWidget(supperContext: context)),
_buildNextBtn(),
],
),
),
);
}
}
希望能给你遇到的问题能提供一些解题思路。