Flutter 中键盘弹起时,Scaffold 发生了什么变化

最近刚好有网友咨询一个问题,那就顺便借着这个问题给大家深入介绍下 Flutter 中键盘弹起时,Scaffold 的内部发生了什么变化,让大家更好理解 Flutter 中的输入键盘和 Scaffold 的关系。

如下图所示,当时的问题是:当界面内有 TextField 输入框时,点击键盘弹起后,界面内底部的按键和 FloatButton 会被挤到键盘上面,有什么办法可以让底部按键和 FloatButton 不被顶上来吗?

image

其实解决这个问题很简单,那就是只要ScaffoldresizeToAvoidBottomInset 配置为 false ,结果如下图所示,键盘弹起后底部按键和 FloatButton 不会再被顶上来,问题解决。那为什么键盘弹起会和 resizeToAvoidBottomInset 有关系?

image

Scaffold 的 resize

Scaffold 是 Flutter 中最常用的页面脚手架,前面知道了通过 resizeToAvoidBottomInset ,我们可以配置在键盘弹起时页面的底部按键和 FloatButton 不会再被顶上来,其实这个行为是因为 Scaffoldbody 大小被 resize 了。

那这个过程是怎么发生的呢?首先如下图所示,我们在 Scaffold 的源码里可以看到,当resizeToAvoidBottomInset 为 true 时,会使用 mediaQuery.viewInsets.bottom 作为 minInsets 的参数,也就是可以确定:键盘弹起时的界面 resizemediaQuery.viewInsets.bottom 有关系

image

而如下图所示, Scaffold 内部的布局主要是靠 CustomMultiChildLayoutCustomMultiChildLayout 的布局逻辑主要在 MultiChildLayoutDelegate 对象里。

前面获取到的 minInsets 会被用到 _ScaffoldLayout 这个 MultiChildLayoutDelegate 里面,也就是说 Scaffold 的内部是通过 CustomMultiChildLayout 实现的布局,具体实现逻辑在 _ScaffoldLayout 这个 Delegate

image

关于 CustomMultiChildLayout 的详细使用介绍在之前的文章 《详解自定义布局实战》 里可以找到。

接着看 _ScaffoldLayout , 在 _ScaffoldLayout 进行布局时,会通过传入的
minInsets 来决定 body 显示的 contentBottom , 所以可以看到事实上传入的 minInsets 改变的是 Scaffold 布局的 bottom 位置

image

上图代码中使用的 _ScaffoldSlot.body 这个枚举其实是作为 LayoutId 的值,MultiChildLayoutDelegate 在布局时可以通过 LayoutId 获取到对应 child 进行布局操作,详细可见: 《详解自定义布局实战》

image

那么 Scaffoldbody 是什么呢? 如上图代码所示,其实 Scaffoldbody 是一个叫 _BodyBuilder 的对象,而这个 _BodyBuilder 内部其实是一个 LayoutBuilder。(注意,在 widget.appbar 不为 null 时,会 removeTopPadding

所以如下图代码所示 body 在添加时,它父级的MediaQueryData 会被重载,特别是 removeTopPadding 会被清空,viewInsets.bottom 也是会被重置

image

最后如下代码所示,_BodyBuilderLayoutBuilder 里会获取到一个 topbottom 的参数,这两个参数都通过前面在 _ScaffoldLayout 布局时传入的 constraints 去判断得到,最终 copyWith 得到新的 MediaQuery

image

这里就涉及到一个有意思的点,在 _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();
  }
}

如上代码所示:

  • 代码中定义了 MainWidgetCustomWidget 两个控件;
  • MainWidget 里使用了 Scaffold ,并且 CustomWidgetMainWidget 里被使用;
  • 分别在这两个 Widget 的build 方法里打印出对应的 MediaQuery.of(context).paddingMediaQuery.of(context).viewInsets.bottom 的值;

如下图所示,在键盘弹起和不弹起时可以看到 padding 值是不同的,而 viewInsets.bottom 都为 0。

image

为什么 padding 值的 top 会不一致,自然是因为 CustomWidgetMainWidget获取到的 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 内被重载清除了。
image

总结一下:ScaffoldresizeToAvoidBottomInset 会通过 MediaQueryData 影响 body 的布局,同时在 ScaffoldMediaQuery 会被重载,所以使用的 context 位置不同,获取到的 MediaQueryData 也不同,如果需要获取键盘高度和状态栏高度的话,最好使用 Scaffold 外的 context

image

这里讲了 MediaQueryMediaQueryData 的内容,为什么 MediaQuery 通过嵌套就可以重载?为什么通过 context 可以往上获取到离 context 最近的 MediaQueryData?因为 MediaQuery 是一个 InheritedWidget : 《全面理解State》

键盘如何影响 Scaffold

前面我们聊了 ScaffoldresizeToAvoidBottomInset 会通过 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 ,最终改变了 Scaffodbody 大小。

image

那么到这里,你知道如何在 Flutter 里正确地去获取键盘的高度了吧?

最后

从一个简单的 resizeToAvoidBottomInset 去拓展到 Scaffod 的内部布局和 MediaQueryData 与键盘的关系,其实这也是学习框架过程中很好的知识延伸,通过特定的问题去深入理解框架的实现原理,最后再把知识点和问题关联起来,这样问题在此之后便不再是问题,因为入脑了~

image
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容