Flutter布局指南之约束和尺寸

1,945 阅读10分钟

Flutter布局总纲——向下传递约束,向上传递尺寸。

Box约束

约束是Flutter布局的核心,在Flutter中,约束的表现形式是通过Constraints类来实现的,所有的非滚动布局模型,都通过BoxConstraints来进行约束,它的代码如下。
image.png
从上面的代码可以看出,约束本质上就是「宽」「高」上的「最大」「最小」范围。

BoxConstraints具有传递性,约束会在组件树上传递,当前Widget会受到来自父级的约束,同时也会将约束传递给它的子Widget。

通过一个例子,我们来理解下约束是如何进行传递的。

void main() {
  runApp(
    Container(
      color: Colors.cyan.shade200,
      width: 10,
      height: 10,
      child: Center(
        child: Container(
          color: Colors.red.shade200,
          width: 300,
          height: 300,
          child: FlutterLogo(size: 1000),
        ),
      ),
    ),
  );
}

我们先提出这样几个问题:

  • 第一个Container的10x10能否生效
  • 第二个Container的300x300能否生效
  • FlutterLogo的1000x1000能否生效

运行结果如下。
image-20220303225220651.png
从运行效果来看,第一个Container的尺寸被无视了,第二个Container的尺寸生效了,FlutterLogo的尺寸也被无视了。那么为什么会这样呢?一图胜千言,随着下面这张图的线路,我们可以好好理解下约束是如何进行传递的。
image-20220303225459685.png
在Flutter中,每个组件都有自己的布局行为:

  • Root,传递紧约束,即它的子元素,必须是设备的尺寸,不然Root根本不知道未被撑满的内容该如何显示
  • Container,在有Child的时候,传递紧约束,即子元素必须和它一样大,否则Container也不知道该怎么放置Child
  • Center,将紧约束转换为松约束,Center可以将父级的紧约束,变松,这样它的子元素可以选择放置在居中的位置,而子元素具体有多大?只要不超过父容器大小都可以

这就是Flutter布局的核心思想。

父容器一层层向下先传递约束,即最大最小宽高,子元素根据父元素的约束,修改自己的约束,并继续向下传递,到根子节点之后,将根据约束修正后得到的尺寸,返回给父级,直到根节点。

这也是为什么有些元素设置的尺寸,会被约束吃掉的原因。在Flutter中,元素的尺寸,在不同的父级组件下,会展示出不同的约束效果,从而展示出不同的样式,这是和Android View非常不同的一点。

更多的示例,大家可以参考下面几篇文章。
Flutter布局综述
Flutter布局指南之深入理解BoxConstraints
Flutter布局指南之Box套盒子

调试约束

不同组件的约束行为不一样,我们平时可以通过下面两种方法来调试,获取组件当前的约束。

LayoutBuilder

首先我们可以通过LayoutBuilder来打印当前Widget的约束,示例代码如下。

runApp(
  LayoutBuilder(
    builder: (BuildContext context, BoxConstraints constraints) {
      print('Root constraints $constraints');
      return Container(
        color: Colors.cyan.shade200,
        width: 10,
        height: 10,
        child: Center(
          child: LayoutBuilder(builder: (BuildContext context, BoxConstraints constraints) {
            print('Center constraints $constraints');
            return Container(
              color: Colors.red.shade200,
              width: 300,
              height: 300,
              child: FlutterLogo(size: 1000),
            );
          }),
        ),
      );
    },
  ),
);

输出:

flutter: Root constraints BoxConstraints(w=390.0, h=844.0)
flutter: Center constraints BoxConstraints(0.0<=w<=390.0, 0.0<=h<=844.0)

借助它,我们就可以很方便的查到当前约束具体是什么约束,以及到底是多少约束。但是这样做还是有点麻烦,所以我们可以借助Flutter的调试工具。

Flutter Inspector

在Flutter Inspector中,我们可以查看当前Widget Tree的约束情况,在Layout Explorer中,可以看到约束的具体数值,如下所示。
image.png
在Widget Detail Tree中,我们还可以看到具体的BoxConstraints对象,如下所示。
image.png
这种方式比前面打log的方式更加直观方便。

约束的松与紧

BoxConstraints定义了最大最新范围之后,还定义了两个语义名词——「松约束」「紧约束」。

  • 松约束:minWidth和minHeight都为0的约束
  • 紧约束:minWidth和maxWidth相等,而且minHeight和maxHeight相等的约束

松约束和紧约束并不是相对的,它们是可以同时存在的。

对于Child来说,它无法违法父级的布局约束,就像下面这个例子。

void main() => runApp(
      Container(
        color: Colors.cyan.shade200,
        width: 10,
        height: 10,
        child: FlutterLogo(size: 1000),
      ),
    );

Container�虽然对Child施加了紧约束,但由于Root对Container施加的也是紧约束,所以Container的约束失效了。

不同的Widget,在不同的场景下所产生的约束是不一样的,对于Container来说:

  • 有Child就选择Child的尺寸(有设置alignment时会将约束放松)
  • 没有Child就撑满父级空间(父级空间为Unbound时,尺寸为0)

打破约束限制

由于父组件的紧约束会强制子组件也施加紧约束,这种限制在某些场景下不太灵活,所以Flutter提供了UnconstrainedBox�来解除这种限制,还是上面的例子,我们加上UnconstrainedBox�。

void main() => runApp(
  UnconstrainedBox(
        child: Container(
          color: Colors.cyan.shade200,
          width: 10,
          height: 10,
          child: FlutterLogo(size: 1000),
        ),
      ),
);

�这个时候,Container施加的新的紧约束(10,10)就可以生效了。
除此之外,还有一些组件,例如——Align。

void main() => runApp(
  Align(
        alignment: Alignment.topLeft,
        child: Container(
          color: Colors.cyan.shade200,
          width: 100,
          height: 100,
          child: FlutterLogo(size: 1000),
        ),
      ),
);

�这些类似的组件,也会将父Widget的紧约束放松为松约束,从它们的使用方式上就可以理解它们的行为,因为如果不能去除紧约束的话,类似对齐的需求就没有办法实现了。

Flex约束

前面看的都是单个Child的容器布局,这类布局方式,我们称之为Box布局,相对而言,类似Column和Row这样的布局方式,我们称之为Flex布局。

Row本质上是direction: Axis.horizontal的Flex Widget,Column本质上是direction: vertical的Flex Widget。

在Column和Row中,有两类约束组件,一种是明确知道自身尺寸的Widget,例如Text、Button,有约束的Container等,还有一种是弹性组件,例如Expanded和Flexible等组件。

所以Column和Row在布局时,采用的是Flex约束进行布局,布局按照下面的规则进行。

  • 先按照unbound约束,计算所有非Flex布局的组件的尺寸
  • 再对Flex组件进行布局,布局根据flex属性来分配剩余空间(Flex组件向下传递紧约束)

上面的布局规则是针对主轴来说的,Flex的主轴约束为unbound,Flex约束在交叉轴上会设置为松约束(如果crossAxisAlignment设置为stretch,那么会变成紧约束)。

以Row为例,Row对child的约束会修改为松约束,从而不会限制child在主轴方向上的尺寸,所以当Row内的Child宽度大于屏幕宽度时,就会产生内容溢出的警告。

所以我们通常会在Flex组件中使用Expanded组件来避免内容的溢出。Expanded组件会将主轴方向上的Child施加紧约束,从而避免溢出。我们查看下Expanded的源码。
image.png
可以发现,Expanded其实就是Flexible�的封装,只是将fit设置为了FlexFit.tight�。所以,下面的代码是等价的。

Expanded(child: ColoredBox(color: Colors.yellow)),
Flexible(child: ColoredBox(color: Colors.cyan), fit: FlexFit.tight),

我们再来看下面的这个例子。

Center(
  child: SizedBox(
    height: 100,
    child: Row(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        Container(width: 50, color: Colors.red),
        Expanded(child: ColoredBox(color: Colors.yellow)),
        Flexible(child: ColoredBox(color: Colors.cyan), fit: FlexFit.loose),
        Container(width: 50, color: Colors.purple),
      ],
    ),
  ),
)

在上面的代码中,我们得到了下面的结果。
image.png
如果把Flexible中的fit改为FlexFit.tight�,那么效果如下。
image.png
结合这个例子,我们可以更好的理解Flex的布局模型(先计算非Flex Widget的尺寸,再将剩余空间按照Flex进行拆分,所以不论fit如何,其尺寸是固定的)。

Expanded�组件就是基于FlexFit.tight的封装,它用于撑满剩余空间,FlexFit.loose虽然没有封装的组件,但它的使用也很常见,我们可以很容易的实现Android约束布局中的ConstraintedWidth这样的效果。

Container(width: 50, color: Colors.red),
Expanded(child: ColoredBox(color: Colors.yellow)),
Flexible(child: ColoredBox(color: Colors.cyan, child: Text('data')), fit: FlexFit.loose),
Container(width: 50, color: Colors.purple),

image.png
当内容变长时,会限制其最大宽度,如下所示。
image.png

Wrap约束

Wrap组件与Flex组件有些类似,但又有些不同,拿前面的例子来说,Row中的child组件如果超过了屏幕宽度,就会导致内容溢出,因为Flex组件其主轴上的约束为unbound,而Wrap组件,其主轴上的约束会被修改为松约束,交叉轴上的约束会被改为unbound,这样就可以实现流式的布局效果。

所以Wrap组件和Flex组件在本质上是相反的两种布局行为。

Stack布局约束

Stack是一类比较特殊的层叠组件,它的约束方式和Column、Row相似,但又不完全一样,在Stack中,同样也分为两类组件,一类是Positioned组件,一类是非Positioned组件,然后Stack会按照下面的布局方式进行。

  • 先按照非Positioned组件的尺寸进行计算,将自身尺寸设置为非Positioned组件的最大值
  • 再对Positioned组件进行布局,按照位置约束进行布局,但不能再改变Stack的尺寸

特殊场景下:如果全部是Positioned组件,那么Stack将获得父容器的最大约束,如果全部是非Positioned组件,那么Stack将获得子元素的最大尺寸

从约束上来说,Stack同样会放松父布局的紧约束,其行为和Align是类似的。

Stack的Fit属性

Stack有个Fit属性,需要特别注意,它可以设置为:

  • StackFit.loose:向下传递送约束(默认行为)
  • StackFit.expand:向下传递紧约束
  • StackFit.passthrough:将父级的约束向下传递

Stack设置Fit属性后,并不会对自身尺寸有影响,它改变的是Child的尺寸,通过修改约束是紧约束还是松约束,来影响Child的尺寸,从而改变自己的尺寸。所以Stack的Fit属性默认是loose,即松约束。如果设置为expand,那么Stack将向Child传递一个紧约束。

IntrinsicHeight与IntrinsicWidth

这两个组件在Flutter中的使用非常少,但在某些极端的场景下,却是非常有用的,它的主要功能,就是为了实现类似Android约束布局中的Barrier的功能。

我们以IntrinsicWidth为例,来看看它的作用。

Column(
  mainAxisSize: MainAxisSize.min,
  mainAxisAlignment: MainAxisAlignment.center,
  children: [
    Container(height: 100, width: 50, color: Colors.red),
    Container(height: 100, color: Colors.blue),
  ],
)

�这个布局效果如下所示。
image.png
由于蓝色的Container没有width约束,所以它在交叉轴方向上的大小是父布局最大尺寸,这时候,我们给它加上IntrinsicWidth�。

IntrinsicWidth(
  child: Column(
    mainAxisSize: MainAxisSize.min,
    mainAxisAlignment: MainAxisAlignment.center,
    children: [
      Container(height: 100, width: 50, color: Colors.red),
      Container(height: 100, color: Colors.blue),
    ],
  ),
)

�这时候效果如下所示。
image.png
可以发现,蓝色Container被强制加上了红色Container的尺寸约束,这就是IntrinsicWidth的作用——在宽度或者高度上施加紧约束来限制Child的尺寸,其约束来自于Child的固有宽度或者高度。

借助这个特性,我们可以很方便的实现一些效果。

Column(
  mainAxisSize: MainAxisSize.min,
  mainAxisAlignment: MainAxisAlignment.center,
  crossAxisAlignment: CrossAxisAlignment.stretch,
  children: const [
    OutlinedButton(onPressed: null, child: Text('btn1')),
    OutlinedButton(onPressed: null, child: Text('btn222')),
    OutlinedButton(onPressed: null, child: Text('btn333333')),
  ],
)

�这个例子,展示了3个不同长度的Button,通过stretch�属性将其交叉轴的长度设置为父布局的最大尺寸,通过增加IntrinsicWidth,我们可以实现下面的效果。

IntrinsicWidth(
  child: Column(
    mainAxisSize: MainAxisSize.min,
    mainAxisAlignment: MainAxisAlignment.center,
    crossAxisAlignment: CrossAxisAlignment.stretch,
    children: const [
      OutlinedButton(onPressed: null, child: Text('btn1')),
      OutlinedButton(onPressed: null, child: Text('btn222')),
      OutlinedButton(onPressed: null, child: Text('btn333333')),
    ],
  ),
)

�这样的效果如下所示。
image.png
再例如这个例子。

Column(
  mainAxisAlignment: MainAxisAlignment.center,
  children: [
    Container(color: Colors.red, height: 50),
    Container(color: Colors.blue, height: 50, child: Text('data' * 8)),
    Container(width: 100, color: Colors.yellow, height: 50),
  ],
)

效果如下。
image.png
就是因为红色Container没有宽度限制,所以撑满了,我们现在想让红色Container跟随蓝色Container的宽度而变化,那就可以使用IntrinsicWidth。
image.png

更多「Flutter」「Android」「Kotlin」内容,请关注我的微信公众号——【群英传】