likes
comments
collection
share

[Flutter] infinity与可滚动布局

作者站长头像
站长
· 阅读数 1

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

[minHeightValue,maxHeightValue],[minWidthValue,maxWidthValue]

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

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

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

使用时,我们只需要:

Column(...).printLayoutInformation();

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

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组件,通过打印它的约束,我们可以看到,这个约束的数值是:

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中:

[Flutter] infinity与可滚动布局

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

1.2 Scaffold

在如下的结构中:

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上:

        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: [Flutter] infinity与可滚动布局

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

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

[Flutter] infinity与可滚动布局

同理,红色区域的尺寸是宽高为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树中:

MaterialApp -> Scaffold -> Column -> Expanded

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

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无法正确获得剩余高度,例如如下的结构:

Column(
  children: [
    Column(
      children: [
        Expanded(child: commonText().printLayoutInformation()),
      ],
    ).printLayoutInformation(),
  ],
).printLayoutInformation()

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

[Flutter] infinity与可滚动布局

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

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

Vertical viewport was given unbounded height.

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

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

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

Column -> ListView

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

什么是Viewport?

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

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

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

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

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,相关的报错依然会存在:

[Flutter] infinity与可滚动布局

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

ListView的替代者

  • 问题分析

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

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

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

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

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

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

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