[Flutter] infinity与可滚动布局

[Flutter] 为什么我的 ListView 又双叒叕崩了 - 掘金中,我们从一个问题引入,并介绍了布局和约束之间的关系,关于Flutter布局实现最核心的一个点,其实就在于约束。

约束一般是一个四元组构成的两个区间。即:

[minHeightValue,maxHeightValue],[minWidthValue,maxWidthValue]

我们使用LayoutBuilder可以通过打印它的参数:constraints来直观地查看一个组件所受到的约束:

我们可以通过扩展方法,封装一个extension工具,来快速地打印一个组件的BoxConstaint约束:

dart 复制代码
extension DebugUtil on Widget {
  Widget printLayoutInformation() {
    return LayoutBuilder(builder: (ctx, constraints) {
      print(
          "flutter_experiment_log: ${this.runtimeType}'s constraints:$constraints");
      return this;
    });
  }
}

使用时,我们只需要:

scss 复制代码
Column(...).printLayoutInformation();

我们就可以在日志中,看到Column所受到的外部约束了:

bash 复制代码
I/flutter (26629): flutter_experiment_log: Column's constraints:BoxConstraints (0.0<=w<=392.7, 0.0<=h<=734.5) 

一、常见的一些约束

1.1 屏幕(RenderView)约束

一如Android的Activity会接受来自屏幕的尺寸约束,例如1920x1080的像素分辨率;

Flutter的最外层组件一样会受到一个屏幕外部的约束,通常情况下我们会在最外层使用一个MateriApp组件,通过打印它的约束,我们可以看到,这个约束的数值是:

ini 复制代码
I/flutter (31221): flutter_experiment_log: MaterialApp's constraints:BoxConstraints(w=392.7, h=825.5)

因此,最外层的组件会被「屏幕」设置一个强制约束,宽度被强制限制为392.7,高度被强制限制为825.5。

通过堆栈分析,我们可以看到这个强制约束最早来自RenderView.performLayout,其中通过BoxConstraints.tight(_size)方法提供了一个以_size尺寸为数值的强制约束。而_size则是在Flutter初始化的时候,通过createViewConfiguration方法创建的ViewConfiguration,其中的size就是设备的物理分辨率 / 缩放比例得到的结果:

rendering/binding.dart中:

总之,屏幕会给最外层一个强制约束,约束为屏幕尺寸本身对应的DPI数值。

1.2 Scaffold

在如下的结构中:

rust 复制代码
MaterialApp -> Scaffold -> AppBar
                        -> Column

Scaffold会受到来自MaterialApp传递的约束,与此同时,又会对Column进行约束

属实是一个承上启下的过程。

1. MaterialApp

MateriApp直接受到来自屏幕的强制约束,宽度/高度数值分别为:392.7/825.5,它并不会改变布局的约束行为,而是将所受到的约束完整地传递给下一层:Scaffold。

2. Scaffold

Scaffold受到来MaterialApp的约束,显然也是一个强制约束,392.7/825.5,这会让Scaffold被强制撑满整个屏幕,重点来了,此时Scaffold向子Widget传递的将不再是强制约束,而是一个宽松约束,例如AppBar所受到的约束是:392.7和[0,90.9]。

这个约束表明AppBar的宽度一定是392.7,即撑满整个屏幕宽度;而高度可以在0到90.0之间任意取值和浮动。

而Scaffold的body部分,则受到了来自水平和垂直轴两个方向的宽松约束,其内部组件可以在

  • 水平轴:[0,392.7]的尺寸约束,它的宽度可以在这个区间内任意取值;
  • 垂直轴:[0,734.5]的尺寸约束,它的高度可以在这个区间内任意取值;

两个约束内进行设置尺寸。

3.Column

从中,我们可以知道,Column收到了来自:

  • 水平轴:[0,392.7]的尺寸约束,它的宽度可以在这个区间内任意取值;
  • 垂直轴:[0,734.5]的尺寸约束,它的高度可以在这个区间内任意取值;

而Column对子组件,则给予了如下的约束:

  • 水平轴:[0,392.7]的尺寸约束,它的宽度可以在这个区间内任意取值;
  • 垂直轴:[0,Infinity]的尺寸约束,它的高度可以在这个区间内任意取值;

言下之意是,水平轴上,Column并没有什么特殊的处理,而是选择照搬父Widget给与的约束,所以一个Widget,如果是文中Column的子Widget,它所受到宽度约束其实是来自父布局Scaffold的宽松约束,该Widget可以任意在[0,392.7]这个约束区间内取值作为宽度。

而在垂直轴上,Column本身为线性布局,理论上可以容纳无限的高度(参考Android中的LinearLayout),因此,Column会给予子Widget一个无限制的高度约束。

二、组件约束分类

显然,从上面的例子中,我们看到了几种不同的约束传递行为:

  1. MaterialApp ,显然这一类组件和布局、尺寸并没有直接的关系,例如StatefulWidget、StatelessWidget这一类功能型的组件(甚至包括Container ),它们所做的就是将自己受到的约束,原封不动地传递给下一层。

  2. Scaffold ,Scaffold会受到来自父布局的约束,并向下传递一个宽松约束,Scaffold受到的外层约束可能是: a.宽松约束[0,a],[0,b],此时Scaffold会向下一个宽松约束:[0,a],[0,b] b.强制约束:[a],[b],此时Scaffold会向下一个宽松约束:[0,a],[0,b]

所以Scaffold在这里的行为就是将约束,转为一个宽松约束。不光是Scaffold,绝大多数的定位辅助组件,例如Align、Center等等,它们自身无论是受到强制约束,还是宽松约束,向下传递的都是一个宽松约束,但不会超过所受到约束的最大值。

例如一个Center本身所受的约束是:[20,40],[20,40],即宽高所受到的约束都是20,40的松约束,此时Center内部的组件收到的约束的值将会是:[0,40]和[0,40],Center不会将左端点值20也强制限制到child上:

less 复制代码
        ConstrainedBox(
          constraints: BoxConstraints(
              minWidth: 20, minHeight: 20, maxHeight: 40, maxWidth: 40),
          child: Center(child: commonText().printLayoutInformation()),
        )

        // output
        Center's constraints:BoxConstraints(20.0<=w<=40.0, 20.0<=h<=40.0) 
        Text's constraints:BoxConstraints ( 0.0 <=w<=40.0 , 0.0 <=h<=40.0 ) 
  1. Column,这一类的组件因为特殊的场景,它们天然可能会很长,所以不会限制子Widget在mainAxis(主轴)上加以约束,但是也因此增加了不确定性,但是我们要分清楚两个东西:

首先是Column自身受到的约束; 它会影响到mainAxisSize属性的表现。 如果设置为mainAxisSize : MainAxisSize.min,那么Column尽可能地缩小,直到约束[a,b]的左端点,也就是最小值a,如果Column的内容高度很短,甚至比约束的左端点a还短,这个时候Column的高度仍然会是a:

红色区域的尺寸是宽高为30的正方形,也就是Column的布局位置,外部包裹了一个ConstrainedBox,提供了一个[30,300],[30,300]的松约束。

而Container是透明的蓝色,位于Column当中,显然Column不会强行缩小到和它唯一子Widget的高度一致,只会缩小到它的最小约束30.如果设置为mainAxisSize : MainAxisSize.max,那么Column将会至少撑开到右端点b,如果Column的内容很长,已经超过b了,那么我们就会看到这么个东西:

同理,红色区域的尺寸是宽高为300的正方形,也就是Column的布局宽度,外部包裹了一个ConstrainedBox,提供了一个[30,300],[30,300]的松约束。

蓝色区域则是一个320x320的正方形,它在纵轴超出了Column的高度20个单位;而横轴则没有,这是因为强制约束的原因,划定的320宽度在强制约束下失效了,被Column强制设置成了300。

三、Column子Widget约束与infinity

Column和Row并没有给与主轴上一个确定的约束,而是给了一个Infinity,这对一些其他的组件来说,可能会导致问题。

3.1 Expanded

我们通常会使用Expanded来占满一个Row或者Column的剩余的空间,如果有多个Expanded那么则默认会平分Row、Column的空间。

以Column为例,单个的Expanded会撑满Column所剩余的空间,这里要注意的一点是 ,我们前面提到了,Column会给予子Widget一个无限制的高度约束,但是理想情况下,Column本身又会受到来自它父Widget的约束,在如下的一棵Widget树中:

rust 复制代码
MaterialApp -> Scaffold -> Column -> Expanded

Column会受到Scaffold下发的非强制约束,例如:

ini 复制代码
Scaffold's constraints:BoxConstraints(w=384.0, h=592.0)
Column's constraints:BoxConstraints(0.0<=w<=384.0, 0.0<=h<=592.0)

这里的592.0就是Column的剩余空间,Expanded默认会撑满这个剩余空间,如果Expanded有其余的同级别Widget,例如一个高度为20的Text,那么这个剩余空间就剩下592.0-20 = 572.0,Expanded则会撑满这个剩余高度,即572。

但是,并非所有情况都是这种理想情况,如果Column本身收到一个infinity的约束,就会导致Column内的Expanded无法正确获得剩余高度,例如如下的结构:

less 复制代码
Column(
  children: [
    Column(
      children: [
        Expanded(child: commonText().printLayoutInformation()),
      ],
    ).printLayoutInformation(),
  ],
).printLayoutInformation()

我们会获得这样的一个报错:

如果我们把Expanded删掉,直接用Text来展示,我们就可以看到内层的Column的高度约束了:

vbnet 复制代码
Scaffold's constraints:BoxConstraints(w=384.0, h=592.0)
Column's constraints:BoxConstraints(0.0<=w<=384.0, 0.0<=h<=592.0)
Column's constraints:BoxConstraints(0.0<=w<=384.0, 0.0<=h<=Infinity)
Text's constraints:BoxConstraints(0.0<=w<=384.0, 0.0<=h<=Infinity)

然而,不光是Column套Column会出现这种情况,ListView嵌套Column也会出现这种情况,内层的Column受到来自ListView的Infinity的高度约束时,此时Expanded对于受到的垂直轴方向上的约束会被认为是unbounded的,无法正确地去获取剩余高度,处理Expanded行为。

总而言之,使用Expanded的时候,必须要保证其父组件,以Column为例,它的主轴高度必须是可知的,不可以是infinity。

其次,在思考一些布局 & 尺寸相关问题的时候,需要注意这个问题究竟是由Widget的约束决定的,还是子Widget所受到的约束决定的。

3.2 ListView

[Flutter] 为什么我的 ListView 又双叒叕崩了 - 掘金中,我们提到过:

arduino 复制代码
Vertical viewport was given unbounded height.

翻译过来,就是:垂直的Viewport被赋予了未确定的高度

结合「被赋予」、「高度」和之前的内容,我们可以大胆地猜测原因:ListView没有被赋予一个确定的纵向约束所导致的,所以,我们可以在外层套一个SizedBox并设置数值,这是可行的;也可以套一个Expanded,给与一个尽可能撑开的约束,这也是可行的。

回顾一下问题本身,是因为出现了这种层次的结构嵌套导致的:

rust 复制代码
Column -> ListView

ListView组件有一个很重要的概念,那就是Viewport。

什么是Viewport?

如果你熟悉Android开发,在使用ScrollView和LinearLayout进行组合构建可滚动布局的时候,你一定会发现,ScrollView的高度一般是固定的,而LinearLayout的高度会特别长,我们实际上是在ScrollView提供的一个确定的区域内,滑动显示LinearLayout的内容。

所以,Viewport的尺寸必须是确定的。

但是在Column中,直接嵌套一个ListView,则无法直接确定Viewport的高度

而如果我们使用了shrinkWrap会发生什么呢?

php 复制代码
if (shrinkWrap) {
  return ShrinkWrappingViewport(
    axisDirection: axisDirection,
    offset: offset,
    slivers: slivers,
    clipBehavior: clipBehavior,
  );
}
return Viewport(...);

显然,ShrinkWrappingViewport是一个特殊的Viewport,和普通Viewport不同之处在于,这种动态的Viewport会去测量其子Widget们的尺寸,并收缩到子Widget的尺寸。

但是子Widget的高度随时可能发生改变,如果滚动过程中需要对Viewport中展示的Header头进行折叠(类似SliverHeader的行为),就会导致Viewport的尺寸也改变,是一种潜在的耗费性能的操作。

而普通Viewport则需要一开始就确定高度,相比之下它更加高效,但是也具有一定的局限性,必须显式地去确定内容的高度。

ShrinkWrap不是万能的

ShrinkWrap本身只是确定了Viewport的类型为一种动态测量子Widget高度的可变的昂贵Viewport,但是ShrinkWrap提供的ShrinkWrappingViewport之后,ListView.children的子Widget,在垂直轴方向上,受到的约束仍然是Infinity。

这就意味着,之前提到的在ListView中,使用Column的场景下,即使设置了ShrinkWrap,我们仍然无法使用Expanded,相关的报错依然会存在:

道理很简单,shrinkWrap并不改变高度约束的值。一个Column,只要在ListView当中,那么它受到的高度约束就是[0,infinity],Column内的Expanded就无法知道它的高度最大可以是多少。

ListView的替代者

  • 问题分析

那么在这种场景下,我们既要滑动,又要撑满屏幕空间,我们该如何处理呢?

其实,这里的核心矛盾点就在于,Column直接受到了来自ListView的Infinity约束,导致Expanded无法获取到bounded的高度,如果我们要处理这种场景,我们只需要在Column外面套一层SizedBox,高度设置为具体的数值即可:

less 复制代码
ListView(
children:[
    SizedBox(
        height:200,
        Column(...)
    )
]);

这样一来,SizedBox会受到来自ListView的[0,infinifty]高度约束,并且,SizedBox会将这个高度上的宽松约束,强制转换成一个强制约束,即SizedBox内部的高度约束变成了:[200,200],也就是说Column的高度只能是200,此时Column内的Expanded就可以取到Column的最大高度了。

但这种强制赋值的处理方案总让人感觉差点意思。事实也是不可能每次都预先知道SizedBox的长度,也就是说,一定会有一种场景,是需要我们的可滑动布局的内部组件,一个特殊的Widget ,能够「再向上探一探」,撑满可滑动布局的可用空间,例如ListView所受到的高度约束为[0,a],其中a不为Infinity,这个特殊的Widget的高度就可以自动设置为a,而不需要我们再手动去摸索它的高度应该是多少。

换句话说,就对应着Column中的Expanded。

  • CustomScrollView与SliverFillRemaining

我们通过**CustomScrollView SliverFillRemaining**的配合,就可以实现问题分析中,提到的效果。

less 复制代码
Widget _buildShrinkWrap() => ColoredBox(
      color: Colors.black12,
      child: CustomScrollView(
        slivers: [
          SliverFillRemaining(
            child: Column(
              children: [
                Expanded(child: commonText())
              ],
            ),
          )
        ],
      ),
    );

由于篇幅的原因,这里只给出代码。关键之处就在SliverFillRemaining,它能够撑满CustomScrollView所能够触达的最大约束,但是和Expanded + Column之间的特性上还是有一些区别,但是在一些场景下,我们还是可以用这个组件来实现我们的需求。

而具体是如何实现的,在下一章我们将会引入一个和Flutter滚动相关的新概念:Sliver。

相关推荐
江上清风山间明月16 小时前
Flutter开发的应用页面非常多时如何高效管理路由
android·flutter·路由·页面管理·routes·ongenerateroute
Zsnoin能1 天前
flutter国际化、主题配置、视频播放器UI、扫码功能、水波纹问题
flutter
早起的年轻人1 天前
Flutter CupertinoNavigationBar iOS 风格导航栏的组件
flutter·ios
HappyAcmen1 天前
关于Flutter前端面试题及其答案解析
前端·flutter
coooliang2 天前
Flutter 中的单例模式
javascript·flutter·单例模式
coooliang2 天前
Flutter项目中设置安卓启动页
android·flutter
JIngles1232 天前
flutter将utf-8编码的字节序列转换为中英文字符串
java·javascript·flutter
B.-2 天前
在 Flutter 中实现文件读写
开发语言·学习·flutter·android studio·xcode
freflying11192 天前
使用jenkins构建Android+Flutter项目依赖自动升级带来兼容性问题及Jenkins构建速度慢问题解决
android·flutter·jenkins
机器瓦力2 天前
Flutter应用开发:对象存储管理图片
flutter