Skip to main content

第4章 - 布局类组件



4.1 布局类组件简介

Widget说明用途
LeafRenderObjectWidget非容器类组件基类Widget树的叶子节点,用于没有子节点的widget,通常基础组件都属于这一类,如Image.
SingleChildRenderObjectWidget单子组件基类包含一个子Widget,如: ConstrainedBox、DecoratedBox等
MultiChildRenderObjectWidget多子组件基类包含多个子Widget,一般都有一个children参数,接受一个Widget数组. 如Row、Column、Stack等

组件继承关系 Widget > RenderObjectWidget > (Leaf/SingleChild/MultiChild)RenderObjectWidget

RenderObjectWidget 类中定义了创建、更新RenderObject的方法,子类必须实现他们,关于RenderObject我们现在只需要知道它是最终布局、渲染UI界面的对象即可,也就是说,对于布局类组件来说,其布局算法都是通过对应的RenderObject对象来实现的,所以读者如果对接下来介绍的某个布局类组件的原理感兴趣,可以查看其对应的RenderObject的实现,比如Stack(层叠布局)对应的RenderObject对象就是RenderStack,而层叠布局的实现就在RenderStack中.

4.2 布局原理与约束(constraints)

尺寸限制类容器用于限制容器大小,Flutter中提供了多种这样的容器,如ConstrainedBoxSizedBoxUnconstrainedBoxAspectRatio 等.

Flutter 中有两种布局模型:

  • 基于 RenderBox 的盒模型布局.
  • 基于 Sliver ( RenderSliver ) 按需加载列表布局.

两种布局方式在细节上略有差异,但大体流程相同,布局流程如下:

  • 上层组件向下层组件传递约束(constraints)条件.
  • 下层组件确定自己的大小,然后告诉上层组件. 注意下层组件的大小必须符合父组件的约束.
  • 上层组件确定下层组件相对于自身的偏移和确定自身的大小(大多数情况下会根据子组件的大小来确定自身的大小).

比如,父组件传递给子组件的约束是“最大宽高不能超过100,最小宽高为0”,如果我们给子组件设置宽高都为200,则子组件最终的大小是100*100,因为任何时候子组件都必须先遵守父组件的约束,在此基础上再应用子组件约束(相当于父组件的约束和自身的大小求一个交集).

盒模型布局组件有两个特点:

  • 组件对应的渲染对象都继承自 RenderBox 类. 在本书后面文章中如果提到某个组件是 RenderBox,则指它是基于盒模型布局的,而不是说组件是 RenderBox 类的实例.
  • 在布局过程中父级传递给子级的约束信息由 BoxConstraints 描述.

BoxConstraints

BoxConstraints 是盒模型布局过程中父渲染对象传递给子渲染对象的约束信息,子组件大小需要在约束的范围内, BoxConstraints 默认的构造函数如下:

const BoxConstraints({
this.minWidth = 0.0, // 最小宽度
this.maxWidth = double.infinity, //最大宽度
this.minHeight = 0.0, //最小高度
this.maxHeight = double.infinity //最大高度
})

BoxConstraints 还定义了一些便捷的构造函数:

  • BoxConstraints.tight(Size size) 它可以生成固定宽高的限制
  • BoxConstraints.expand() 可以生成一个尽可能大的用以填充另一个容器的 BoxConstraints

ConstrainedBox

ConstrainedBox 用于对子组件添加额外的约束, 比如:

ConstrainedBox(
constraints: BoxConstraints(minWidth: double.infinity, minHeight: 50),
child: Container(
height: 10,
child: DecoratedBox(
decoration: BoxDecoration(color: Colors.red)
)
)
);

上面的代码会产生一个高度50、宽度100%的红色色块.

我们虽然将 Container 的高度设置为10像素,但是最终却是50像素,这正是ConstrainedBox的最小高度限制生效了. 如果将Container的高度设置为80像素,那么最终红色区域的高度也会是80像素,因为ConstrainedBox只限制了最小高度50像素,并未限制最大高度(最大高度默认是 double.infinity).

SizedBox

SizedBox 用于给子元素指定固定的宽高, 实际上SizedBox只是ConstrainedBox的一个定制:

SizedBox(
width: 80.0,
height: 80.0,
child: DecoratedBox(
decoration: BoxDecoration(color: Colors.red)
)
)
// 等价于
ConstrainedBox(
constraints: BoxConstraints.tightFor(width: 80.0,height: 80.0),
child: DecoratedBox(
decoration: BoxDecoration(color: Colors.red)
),
)
// 最终等价于
ConstrainedBox(
constraints: BoxConstraints(minHeight: 80.0,maxHeight: 80.0,minWidth: 80.0,maxWidth: 80.0),
child: DecoratedBox(
decoration: BoxDecoration(color: Colors.red)
),
)

多重限制

如果某一个组件有多个父级ConstrainedBox限制时,对于minWidthminHeight来说,是取父子中相应数值最大的. 而对于maxWidthmaxHeight取多个ConstrainedBox约束中最小的. 只有这样才能保证父限制与子限制不冲突.

UnconstrainedBox

假如有一个组件 A,它的子组件是B,B 的子组件是 C,则 C 必须遵守 B 的约束,同时 B 必须遵守 A 的约束,也就是说正常情况下组件的约束会向下传递. 但是 A 的约束不会直接约束到 C,除非B将A对它自己的约束透传给了C, 利用这个原理, 可以实现一个这样的 B 组件:

  • B 组件中在布局 C 时不约束C(可以为无限大).
  • C 根据自身真实的空间占用来确定自身的大小.
  • B 在遵守 A 的约束前提下结合子组件的大小确定自身大小.

而这个B组件就是 UnconstrainedBox 组件,也就是说 UnconstrainedBox 的子组件将不再受到约束,大小完全取决于自己. 比如:

ConstrainedBox(
constraints: BoxConstraints(minWidth: 60.0, minHeight: 100.0), //父
child: UnconstrainedBox( //去除父级限制
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: 90.0, minHeight: 20.0), //子
child: DecoratedBox(
decoration: BoxDecoration(color: Colors.red),
),
),
),
)

如果没有中间的 UnconstrainedBox,那么根据上面所述的多重限制规则,那么最终将显示一个90×100的红色框. 但是由于UnconstrainedBox “去除”了父ConstrainedBox的限制,则最终会按照子ConstrainedBox的限制来绘制DecoratedBox,即90×20.

但是,UnconstrainedBox对父组件限制的“去除”并非是真正的去除: 上面例子中虽然红色区域大小是90×20,但上方仍然有80的空白空间. 也就是说父限制的minHeight(100.0)仍然是生效的,只不过它不影响最终子元素DecoratedBox的大小,但仍然还是占有相应的空间,可以认为此时的父ConstrainedBox是作用于子UnconstrainedBox上,而DecoratedBox只受子ConstrainedBox限制.

任何时候子组件都必须遵守其父组件的约束, 没有方法可以彻底去除父ConstrainedBox的限制.

note

UnconstrainedBox 虽然在其子组件布局时可以取消约束(子组件可以为无限大),但是 UnconstrainedBox 自身是受其父组件约束的,所以当 UnconstrainedBox 随着其子组件变大后,如果UnconstrainedBox 的大小超过它父组件约束时,也会导致溢出报错,比如把上面那段代码里父BoxConstraints设置一个maxWidth小于子ConstrainedBox的最小宽,就会出现导致溢出报错:

ConstrainedBox(
constraints: BoxConstraints(minWidth: 60.0, maxWidth: 80, minHeight: 100.0), //父
child: UnconstrainedBox( //去除父级限制
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: 90.0, minHeight: 20.0), // 这里的最小宽超过了父级约束的最大宽
child: DecoratedBox(
decoration: BoxDecoration(color: Colors.red),
),
),
),
)

其它约束类容器

  • AspectRatio: 它可以指定子组件的长宽比
  • LimitedBox: 用于指定最大宽高
  • FractionallySizedBox: 可以根据父容器宽高的百分比来设置子组件宽高

4.3 线性布局(Row和Column)

Flutter 中通过Row和Column来实现线性布局, 都继承自Flex.

主轴和纵轴

对于线性布局, 有主轴和纵轴之分:

  • 如果布局是沿水平方向,那么主轴就是指水平方向,而纵轴即垂直方向
  • 如果布局沿垂直方向,那么主轴就是指垂直方向,而纵轴就是水平方向. 在线性布局中

有两个定义对齐方式的枚举类MainAxisAlignmentCrossAxisAlignment,分别代表主轴对齐和纵轴对齐.

Row

Row可以沿水平方向排列其子widget. 定义如下:

Row({
...
TextDirection textDirection,
MainAxisSize mainAxisSize = MainAxisSize.max,
MainAxisAlignment mainAxisAlignment = MainAxisAlignment.start,
VerticalDirection verticalDirection = VerticalDirection.down,
CrossAxisAlignment crossAxisAlignment = CrossAxisAlignment.center,
List<Widget> children = const <Widget>[],
})

textDirection

表示水平方向子组件的布局顺序(是从左往右还是从右往左),默认为系统当前Locale环境的文本方向(如中文、英语都是从左往右,而阿拉伯语是从右往左)

mainAxisSize

表示Row在主轴(水平)方向占用的空间

  • 默认是 MainAxisSize.max, 表示尽可能多的占用水平方向的空间, 此时无论子 widgets 实际占用多少水平空间, Row的宽度始终等于水平方向的最大宽度;
  • 而 MainAxisSize.min 表示尽可能少的占用水平空间, 当子组件没有占满水平剩余空间 , 则Row的实际宽度等于所有子组件占用的的水平空间;

mainAxisAlignment

表示子组件在Row所占用的水平空间内对齐方式.

如果mainAxisSize值为MainAxisSize.min, 子组件的宽度等于Row的宽度, 此属性无意义. 只有当mainAxisSize的值为MainAxisSize.max时, 此属性才有意义.

  • MainAxisAlignment.start表示沿textDirection的初始方向对齐, 如textDirection取值为TextDirection.ltr时, 则MainAxisAlignment.start表示左对齐, textDirection取值为TextDirection.rtl时表示从右对齐.
  • MainAxisAlignment.endMainAxisAlignment.start正好相反
  • MainAxisAlignment.center表示居中对齐

读者可以这么理解: textDirectionmainAxisAlignment的参考系.

verticalDirection

表示Row纵轴(垂直)的对齐方向,默认是VerticalDirection.down,表示从上到下。

crossAxisAlignment

表示子组件在纵轴方向的对齐方式,Row的高度等于子组件中最高的子元素高度,它的取值和MainAxisAlignment一样(包含start、end、 center三个值),不同的是crossAxisAlignment的参考系是verticalDirection,即verticalDirection值为VerticalDirection.down时crossAxisAlignment.start指顶部对齐,verticalDirection值为VerticalDirection.up时,crossAxisAlignment.start指底部对齐;而crossAxisAlignment.end和crossAxisAlignment.start正好相反;

Column

Column可以在垂直方向排列其子组件。参数和Row一样,不同的是布局方向为垂直,主轴纵轴正好相反.

Row(主轴mainAxis为水平方向)和Column(主轴mainAxis为垂直方向), 都只会在主轴方向占用尽可能大的空间, 而纵轴的长度则取决于他们最大子元素的长度.

比如:

Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Text("hi"),
Text("world"),
],
)

效果如下:

https://gitee.com/alanway/resources/raw/master/files/flutter-practice/IMG_20220405_202153.jpg

虽然我们指定纵轴居中对齐:crossAxisAlignment: CrossAxisAlignment.center(Column的纵轴为水平方向), 但是两行文本并未在屏幕水平方向的中间, 是因为Column和Row一样, 默认只会在主轴方向占尽可能大的空间, 而纵轴的长度则取决于他们最大子元素的长度, 对于上述代码, Colum的宽度实际上等同于第二行文字的宽度, 所以第一行文字位于第二行文本宽度的中间. 借助ConstrainedBox或SizedBox可以强制更改Column纵轴方向的宽度:

ConstrainedBox(
constraints: BoxConstraints(minWidth: double.infinity),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Text("hi"),
Text("world"),
],
),
);

效果如下:

https://gitee.com/alanway/resources/raw/master/files/flutter-practice/IMG_20220405_202123.jpg

特殊情况

如果Row里面嵌套Row,或者Column里面再嵌套Column,那么只有最外面的Row或Column会占用尽可能大的空间,里面Row或Column所占用的空间为实际大小,下面以Column为例说明:

Container(
color: Colors.green,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.max, //有效,外层Colum高度为整个屏幕
children: <Widget>[
Container(
color: Colors.red,
child: Column(
mainAxisSize: MainAxisSize.max,//无效,内层Colum高度为实际高度
children: <Widget>[
Text("hello world "),
Text("I am Jack "),
],
),
)
],
),
),
);

效果如下:

https://gitee.com/alanway/resources/raw/master/files/flutter-practice/IMG_20220405_203119.jpg

如果要让里面的Column占满外部Column,可以使用 Expanded 组件:

Container(
color: Colors.green,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.max, //有效,外层Colum高度为整个屏幕
children: <Widget>[
Expanded(
child: Container(
color: Colors.red,
child: Column(
mainAxisSize: MainAxisSize.max, //无效,内层Colum高度为实际高度
children: <Widget>[
Text("hello world "),
Text("I am Jack "),
],
),
)
)
],
),
),
)
https://gitee.com/alanway/resources/raw/master/files/flutter-practice/IMG_20220405_203421.jpg

4.4 弹性布局(Flex)

Flex

Flex组件可以沿着水平或垂直方向排列子组件,如果你知道主轴方向,使用Row或Column会方便一些,因为Row和Column都继承自Flex,参数基本相同,所以能使用Flex的地方基本上都可以使用Row或Column:

Flex({
...
required this.direction, //弹性布局的方向, Row默认为水平方向,Column默认为垂直方向
List<Widget> children = const <Widget>[],
})

Expanded

Expanded 只能作为 Flex 的孩子(否则会报错),它可以按比例“扩伸”Flex子组件所占用的空间。因为 Row和Column 都继承自 Flex,所以 Expanded 也可以作为它们的孩子:

const Expanded({
int flex = 1,
required Widget child,
})

flex

flex参数为弹性系数,如果为 0 或null,则child是没有弹性的,即不会被扩伸占用的空间。如果大于0,所有的Expanded按照其 flex 的比例来分割主轴的全部空闲空间。

Spacer

Spacer的功能是占用指定比例的空间,实际上它只是Expanded的一个包装类,Spacer的源码如下:

class Spacer extends StatelessWidget {
const Spacer({Key? key, this.flex = 1})
: assert(flex != null),
assert(flex > 0),
super(key: key);

final int flex;

@override
Widget build(BuildContext context) {
return Expanded(
flex: flex,
child: const SizedBox.shrink(),
);
}
}

4.5 流式布局

Flutter中通过Wrap和Flow来支持流式布局

Wrap

Wrap的很多属性在Row(包括Flex和Column)中也有,这些参数意义是相同的:

Wrap({
...
this.direction = Axis.horizontal,
this.alignment = WrapAlignment.start,
// 主轴方向子widget的间距
this.spacing = 0.0,
// 纵轴方向的对齐方式
this.runAlignment = WrapAlignment.start,
// 纵轴方向的间距
this.runSpacing = 0.0,
this.crossAxisAlignment = WrapCrossAlignment.start,
this.textDirection,
this.verticalDirection = VerticalDirection.down,
List<Widget> children = const <Widget>[],
})

Flow

4.6 层叠布局

层叠布局和 Web 中的绝对定位、Android 中的 Frame 布局是相似的,子组件可以根据距父容器四个角的位置来确定自身的位置。层叠布局允许子组件按照代码中声明的顺序堆叠起来。

Stack

Stack({
this.alignment = AlignmentDirectional.topStart,
this.textDirection,
this.fit = StackFit.loose,
this.clipBehavior = Clip.hardEdge,
List<Widget> children = const <Widget>[],
})

alignment

此参数决定如何去对齐没有定位(没有使用Positioned)或部分定位的子组件。

部分定位是指没有在某一个轴上定位:left、right为横轴,top、bottom为纵轴,只要包含某个轴上的一个定位属性就算在该轴上有定位。

textDirection

和Row、Wrap的textDirection功能一样,都用于确定alignment对齐的参考系,即:textDirection的值为TextDirection.ltr,则alignment的start代表左,end代表右,即从左往右的顺序;textDirection的值为TextDirection.rtl,则alignment的start代表右,end代表左,即从右往左的顺序。

fit

此参数用于确定没有定位的子组件如何去适应Stack的大小。StackFit.loose表示使用子组件的大小,StackFit.expand表示扩伸到Stack的大小。

Stack 的子元素是堆叠的, children后面的元素会覆盖children前面的元素.

clipBehavior

此属性决定对超出Stack显示空间的部分如何剪裁,Clip枚举类中定义了剪裁的方式,Clip.hardEdge 表示直接剪裁,不应用抗锯齿,更多信息可以查看源码注释。

Positioned

const Positioned({
Key? key,
this.left,
this.top,
this.right,
this.bottom,
this.width,
this.height,
required Widget child,
})

left、top 、right、 bottom分别代表离Stack左、上、右、底四边的距离。width和height用于指定需要定位元素的宽度和高度。注意,Positioned的width、height 和其它地方的意义稍微有点区别,此处用于配合left、top 、right、 bottom来定位组件,举个例子,在水平方向时,你只能指定left、right、width三个属性中的两个,如指定left和width后,right会自动算出(left+width),如果同时指定三个属性则会报错,垂直方向同理。

示例:

ConstrainedBox(
constraints: BoxConstraints.expand(),
child: Stack(
// 表示子元素默认 顶部居中 显示(topCenter, 指的是竖轴方向顶部对齐: top, 横轴方向居中对齐: Center)
alignment: Alignment.topCenter,
children: [
/**
* 非 Positioned 组件, 定位遵循 Stack.alignment,
* 也就是 顶部居中 显示
*/
Container(
child: Text(
"Container",
style: TextStyle(color: Colors.red),
),
),
/**
* 部分定位的 Positioned,
* 由于未指定纵轴方向(top/bottom)的距离, 遵循 Alignment.topCenter 中的 top,
* 由于指定了横轴方向的left值, 所以覆盖了 Alignment.topCenter 中的 Center,
* 最终显示效果是距左 20 像素, 距离顶部 0 像素
*/
Positioned(
child: Text(
"Position with left 20",
style: TextStyle(color: Colors.blue),
),
left: 20,
),
/**
* 部分定位的 Positioned,
* 由于指定了纵轴方向的top值, 所以覆盖了 Alignment.topCenter 中的 top
* 由于未指定了横轴方向(left/right)的距离, 所以遵循 Alignment.topCenter 中的 Center
* 最终显示效果是距顶部 20 像素, 然后横轴方向居中
*/
Positioned(
child: Text(
"Position with top 20",
style: TextStyle(color: Colors.green),
),
top: 20,
),
// 完全定位的 Positioned, 忽略 Stack.alignment 指定的 Alignment.topCenter
Positioned(
child: Text("Position with top and left"),
top: 40,
left: 40,
)
],
),
)

效果如下:

https://gitee.com/alanway/resources/raw/master/files/flutter-practice/IMG_20220406_213351.jpg

4.7 对齐与相对定位

Align

Align 组件可以调整子组件的位置,定义如下:

Align({
Key key,
/**
* 需要一个AlignmentGeometry类型的值,表示子组件在父组件中的起始位置。AlignmentGeometry 是一个抽象类,它有两个常用的子类:Alignment和 FractionalOffset
*/
this.alignment = Alignment.center,
this.widthFactor,
this.heightFactor,
Widget child,
})
  • alignment 需要一个AlignmentGeometry类型的值,表示子组件在父组件中的起始位置。AlignmentGeometry 是一个抽象类,它有两个常用的子类: AlignmentFractionalOffset
  • widthFactorheightFactor 是用于确定 Align 组件本身宽高的属性;它们是两个缩放因子,会分别乘以子元素的宽、高,最终的结果就是 Align 组件的宽高。如果值为 null ,则组件的宽高将会占用尽可能多的空间。

比如:

Container(
height: 120.0,
width: 120.0,
color: Colors.bule.shade50,
child: Align(
alignment: Alignment.topRight,
child: FlutterLogo(
size: 60,
),
),
)

上面的代码中, 蓝色背景的容器Container宽高各120像素, 由于Align没有设置widthFactorheightFactor, 所以Align撑满Container的空间, FlutterLogo位于Align的右上角, 同时也是Container的右上角.

上面代码的效果也等同于如下:

Container(
color: Colors.bule.shade50,
child: Align(
widthFactor: 2,
heightFactor: 2,
alignment: Alignment.topRight,
child: FlutterLogo(
size: 60,
),
),
)

AlignwidthFactor为2, 其子元素的宽为60, Align元素的实际宽就等于 2 * 60 = 120px, 效果如下:

https://gitee.com/alanway/resources/raw/master/files/flutter-practice/IMG_20220417_213004.jpg

FractionalOffset

FractionalOffset 继承自 Alignment ,它和 Alignment 唯一的区别就是坐标原点不同. Aligment 的坐标系原点是在父控件的中间, 而 FractionalOffset 的坐标原点为矩形的左侧顶点:

FractionalOffset(dx, dy)
  • dxdy 是一个倍数而不是距离值
  • dx 表示子控件从父容器的最左边移动到最右边的距离, 值为 dx * (parentWidth - childWidth)
  • dy 表示子控件从父容器最顶部移动到最底部的距离, 值为 dy * (parentHeight - childHeight)