在[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一个无限制的高度约束。
二、组件约束分类
显然,从上面的例子中,我们看到了几种不同的约束传递行为:
-
MaterialApp ,显然这一类组件和布局、尺寸并没有直接的关系,例如StatefulWidget、StatelessWidget这一类功能型的组件(甚至包括Container ),它们所做的就是将自己受到的约束,原封不动地传递给下一层。
-
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 )
- 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 又双叒叕崩了 - 掘金中,我们提到过:
arduinoVertical 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。