前言

本文的目的是为了让读者掌握不同布局类Widget的布局特点,分享一些在实际使用过程遇到的一些问题,在《Flutter实战》这本书中已经讲解的很详细了,本文主要是对其内容的浓缩及实际遇到的问题的补充。

什么是布局类Widget

布局类Widget就是指直接或间接继承(包含)MultiChildRenderObjectWidget的Widget,它们一般都会有一个children属性用于接收子Widget。在Flutter中Element树才是最终的绘制树,Element树是通过widget树来创建的(通过Widget.createElement()),widget其实就是Element的配置数据。它的最终布局、UI界面渲染都是通过RenderObject对象来实现的,这里的细节我就不详细描述了,因为我也不懂。不过感兴趣的小伙伴也可以看看本专栏的Flutter视图的Layout与Paint这篇文章。
Flutter中主要有以下几种布局类的Widget:

  • 线性布局Row和Column
  • 弹性布局Flex
  • 流式布局Wrap、Flow
  • 层叠布局Stack、Positioned

    本文Demo地址

线性布局Row和Column

线性布局其实是指沿水平或垂直方向排布子Widget,Flutter中通过Row来实现水平方向的子Widegt布局,通过Column来实现垂直方向的子Widget布局。他们都继承Flex,所以它们有很多相似的属性。
image.pngimage.png
在前端的Flex布局中,默认存在两根轴:水平的主轴(main axis)和垂直的交叉轴(cross axis)。主轴的开始位置(与边框的交叉点)叫做main start,结束位置叫做main end;交叉轴的开始位置叫做cross start,结束位置叫做cross end。与Flutter中MainAxisAlignment和CrossAxisAlignment类似,分别代表主轴对齐和纵轴对齐。

源码属性解读

  1. Row({
  2. .....
  3. MainAxisAlignment mainAxisAlignment = MainAxisAlignment.start,
  4. MainAxisSize mainAxisSize = MainAxisSize.max,
  5. CrossAxisAlignment crossAxisAlignment = CrossAxisAlignment.center,
  6. TextDirection textDirection,
  7. VerticalDirection verticalDirection = VerticalDirection.down,
  8. TextBaseline textBaseline,
  9. List<Widget> children = const <Widget>[],
  10. })
  11. Column({
  12. .....
  13. MainAxisAlignment mainAxisAlignment = MainAxisAlignment.start,
  14. MainAxisSize mainAxisSize = MainAxisSize.max,
  15. CrossAxisAlignment crossAxisAlignment = CrossAxisAlignment.center,
  16. TextDirection textDirection,
  17. VerticalDirection verticalDirection = VerticalDirection.down,
  18. TextBaseline textBaseline,
  19. List<Widget> children = const <Widget>[],
  20. })
  21. 复制代码
  • textDirection:表示水平方向子widget的布局顺序(是从左往右还是从右往左),默认为系统当前Locale环境的文本方向(如中文、英语都是从左往右,而阿拉伯语是从右往左)。
  • 主轴方向: Row即为水平方向,Column为垂直方向
  • mainAxisAlignment 主轴方向,对child起作用
    • center:将children放置在主轴的中心
    • start:将children放置在主轴的起点
    • end:将children放置在主轴的末尾
    • spaceAround:将主轴方向上的空白区域均分,使children之间的空白区域相等,但是首尾child的靠边间距为空白区域为1/2
    • spaceBetween:将主轴方向上的空白区域均分,使children之间的空白区域相等,首尾child靠边没有间隙
    • spaceEvenly:将主轴方向上的空白区域均分,使得children之间的空白区域相等,包括首尾child
  • mainAxisSize max表示尽可能占多的控件,min会导致控件聚拢在一起
  • crossAxisAlignment 交叉轴方向,对child起作用
    • baseline:使children baseline对齐
    • center:children在交叉轴上居中展示
    • end:children在交叉轴上末尾展示
    • start:children在交叉轴上起点处展示
    • stretch:让children填满交叉轴方向
  • verticalDirection ,child的放置顺序

    • VerticalDirection.down,在Row中就是从左边到右边,Column代表从顶部到底部
    • VerticalDirection.up,相反

      Row

      示例代码
      1. ListView(
      2. children: <Widget>[
      3. Row(
      4. mainAxisAlignment: MainAxisAlignment.start,
      5. children: <Widget>[
      6. Text("我是Row的子控件 "),
      7. Text("MainAxisAlignment.start")
      8. ],
      9. ),
      10. Row(
      11. mainAxisAlignment: MainAxisAlignment.center,
      12. children: <Widget>[
      13. Text("我是Row的子控件 "),
      14. Text("MainAxisAlignment.center")
      15. ],
      16. ),
      17. Row(
      18. mainAxisAlignment: MainAxisAlignment.end,
      19. children: <Widget>[
      20. Text("我是Row的子控件 "),
      21. Text("MainAxisAlignment.end")
      22. ],
      23. ),
      24. Row(
      25. crossAxisAlignment: CrossAxisAlignment.start,
      26. verticalDirection: VerticalDirection.up,
      27. children: <Widget>[
      28. Text(" Hello World ", style: TextStyle(fontSize: 30.0),),
      29. Text(" I am Jack "),
      30. ],
      31. ],
      32. )
      33. 复制代码
      代码运行效果
      image.png前3个Row很简单,只是设置了主轴方向的对齐方式;第四个Row测试的是纵轴的对齐方式,由于两个子Text字体不一样,所以其高度也不同,我们指定了verticalDirection值为VerticalDirection.up,即从低向顶排列,而此时crossAxisAlignment值为CrossAxisAlignment.start表示底对齐。大家可以参考上面Row和Column的主侧轴的示意图,看看布局是不是正确的,还有很多种情况就不一一列举了。

      Column

      示例代码
      1. ListView(children: <Widget>[
      2. Column(
      3. crossAxisAlignment: CrossAxisAlignment.start,
      4. children: <Widget>[
      5. Text("我是Colum的子控件"),
      6. Text("CrossAxisAlignment.start"),
      7. ],
      8. ),
      9. Column(
      10. crossAxisAlignment: CrossAxisAlignment.center,
      11. children: <Widget>[
      12. Text("我是Colum的子控件"),
      13. Text("CrossAxisAlignment.center"),
      14. ],
      15. ),
      16. Column(
      17. crossAxisAlignment: CrossAxisAlignment.end,
      18. children: <Widget>[
      19. Text("我是Colum的子控件"),
      20. Text("CrossAxisAlignment.end"),
      21. ],
      22. ),
      23. ],)
      24. 复制代码
      代码运行效果
      image.pngColumn和Row差不多,只是布局方向不一样而已,大家可以参考着看,这里就不再赘述了。

      实际使用

      由于篇幅有限,我就不详细讲解实际遇到的问题了,只说现象和解决办法:
  • 如果Row里面嵌套Row,或者Column里面再嵌套Column,那么只有对最外面的Row或Column会占用尽可能大的空间,里面Row或Column所占用的空间为实际大小,如果要让里面的Colum或Row占满外部Colum或Row,可以使用Expanded widget

  • 如果使用Column发现超范围,可用SingleChildScrollView包裹,scrollDirection属性设置滑动方向
  • 使用Column嵌套ListView/GridView的时候,会报异常信息【Viewports expand in the scrolling direction to fill their container…】,这种情况flutter已给出解决办法,将ListView/GridView的 shrinkWrap属性设为true
  • 有的时候修改Row/Column的verticalDirection会得到很好的效果,比如需要页面在底部需要几个按键,也可以用Stack来布局,但是相对麻烦,而且有时还需要知道控件的大小,没有verticalDirection方便

    弹性布局

    弹性布局是一种允许子widget按照一定比例来分配父容器空间的布局方式,如果你知道了它的主轴方向,那就可以用Row或Column了,一般情况下,可以用Flex的地方都可以用Row或者Column一起使用,通常配合Expanded Widget来使用,同样Expanded也不能脱离Flex单独创建。

    Expanded

    Expanded继承自Flexible,Flexible是一个控制Row、Column、Flex等子组件如何布局的组件,它可以按比例“扩伸”Row、Column和Flex子widget所占用的空间。

    1. const Expanded({
    2. int flex = 1,
    3. @required Widget child,
    4. })
    5. 复制代码

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

    示例代码
    1. Row(children: <Widget>[
    2. RaisedButton(
    3. onPressed: () {
    4. print('点击红色按钮事件');
    5. },
    6. color: Colors.red,
    7. child: Text('红色按钮'),
    8. ),
    9. Expanded(
    10. flex: 1,
    11. child: RaisedButton(
    12. onPressed: () {
    13. print('点击黄色按钮事件');
    14. },
    15. color: Colors.yellow,
    16. child: Text('黄色按钮'),
    17. ),
    18. ),
    19. RaisedButton(
    20. onPressed: () {
    21. print('点击粉色按钮事件');
    22. },
    23. color: Colors.green,
    24. child: Text('绿色按钮'),
    25. ),
    26. ])
    27. 复制代码

    代码运行效果

    image.png

    Flexible和 Expanded的区别
  • Flexible组件必须是Row、Column、Flex等组件的后裔,并且从Flexible到它封装的Row、Column、Flex的路径必须只包括StatelessWidgets或StatefulWidgets组件(不能是其他类型的组件,像RenderObjectWidgets)

  • Row、Column、Flex会被Expanded撑开,充满主轴可用空间,而Flexible不强制子组件填充可用空间,这是因为fit属性的值不同,该属性在Expanded中为FlexFit.tight,Flexible为FlexFit.loose,区别在于tight表示强制使子控件填充剩余可用空间,loose表示最多填满其在父控件所设置的比例,所以loose默认即为控件的大小

    流式布局

    流式布局(Liquid)的特点(也叫”Fluid”) 是页面元素的宽度按照屏幕分辨率进行适配调整,但整体布局不变。栅栏系统(网格系统),用户标签等。在Flutter中主要有Wrap和Flow两种Widget实现。

    Wrap

    在介绍Row和Colum时,如果子widget超出屏幕范围,则会报溢出错误,在Flutter中通过Wrap和Flow来支持流式布局,溢出部分则会自动折行。

    源码属性解读
    1. Wrap({
    2. ...
    3. this.direction = Axis.horizontal,
    4. this.alignment = WrapAlignment.start,
    5. this.spacing = 0.0,
    6. this.runAlignment = WrapAlignment.start,
    7. this.runSpacing = 0.0,
    8. this.crossAxisAlignment = WrapCrossAlignment.start,
    9. this.textDirection,
    10. this.verticalDirection = VerticalDirection.down,
    11. List<Widget> children = const <Widget>[],
    12. })
    13. 复制代码

    上述有很多属性和Row的相同,其意义其实也是相同的,这里我就不一一介绍了,主要介绍下不同的属性:

  • spacing:主轴方向子widget的间距

  • runSpacing:纵轴方向的间距
  • runAlignment:纵轴方向的对齐方式

    示例代码
    1. Wrap(
    2. spacing: 10.0,
    3. direction: Axis.horizontal,
    4. alignment: WrapAlignment.start,
    5. children: <Widget>[
    6. _card('关注'),
    7. _card('推荐'),
    8. _card('新时代'),
    9. _card('小视频'),
    10. _card('党媒推荐'),
    11. _card('中国新唱将'),
    12. _card('历史'),
    13. _card('视频'),
    14. _card('游戏'),
    15. _card('头条号'),
    16. _card('数码'),
    17. ],
    18. )
    19. Widget _card(String title) {
    20. return Card(child: Text(title),);
    21. }
    22. }
    23. 复制代码

    运行效果

    image.png

    小结
  • 使用Wrap可以很轻松的实现流式布局效果

  • Wrap支持设置流式布局是纵向显示或者是横向显示
  • 可以使用alignment属性来控制widgets的布局方式

    Flow

    我们一般很少会使用Flow,因为其过于复杂,需要自己实现子widget的位置转换,在很多场景下首先要考虑的是Wrap是否满足需求。Flow主要用于一些需要自定义布局策略或性能要求较高(如动画中)的场景。Flow有如下优点:

  • 性能好;Flow是一个对child尺寸以及位置调整非常高效的控件,Flow用转换矩阵(transformation matrices)在对child进行位置调整的时候进行了优化:在Flow定位过后,如果child的尺寸或者位置发生了变化,在FlowDelegate中的paintChildren()方法中调用context.paintChild 进行重绘,而context.paintChild在重绘时使用了转换矩阵(transformation matrices),并没有实际调整Widget位置。

  • 灵活;由于我们需要自己实现FlowDelegate的paintChildren()方法,所以我们需要自己计算每一个widget的位置,因此,可以自定义布局策略。 缺点:
  • 使用复杂.
  • 不能自适应子widget大小,必须通过指定父容器大小或实现TestFlowDelegate的getSize返回固定大小。

    示例代码

    我们对六个色块进行自定义流式布局:

    1. Flow(
    2. delegate: TestFlowDelegate(margin: EdgeInsets.all(10.0)),
    3. children: <Widget>[
    4. new Container(width: 80.0, height:80.0, color: Colors.red,),
    5. new Container(width: 80.0, height:80.0, color: Colors.green,),
    6. new Container(width: 80.0, height:80.0, color: Colors.blue,),
    7. new Container(width: 80.0, height:80.0, color: Colors.yellow,),
    8. new Container(width: 80.0, height:80.0, color: Colors.brown,),
    9. new Container(width: 80.0, height:80.0, color: Colors.purple,),
    10. ],
    11. )
    12. 复制代码

    实现TestFlowDelegate:

    1. class TestFlowDelegate extends FlowDelegate {
    2. EdgeInsets margin = EdgeInsets.zero;
    3. TestFlowDelegate({this.margin});
    4. @override
    5. void paintChildren(FlowPaintingContext context) {
    6. var x = margin.left;
    7. var y = margin.top;
    8. //计算每一个子widget的位置
    9. for (int i = 0; i < context.childCount; i++) {
    10. var w = context.getChildSize(i).width + x + margin.right;
    11. if (w < context.size.width) {
    12. context.paintChild(i,
    13. transform: new Matrix4.translationValues(
    14. x, y, 0.0));
    15. x = w + margin.left;
    16. } else {
    17. x = margin.left;
    18. y += context.getChildSize(i).height + margin.top + margin.bottom;
    19. //绘制子widget(有优化)
    20. context.paintChild(i,
    21. transform: new Matrix4.translationValues(
    22. x, y, 0.0));
    23. x += context.getChildSize(i).width + margin.left + margin.right;
    24. }
    25. }
    26. }
    27. getSize(BoxConstraints constraints){
    28. //指定Flow的大小
    29. return Size(double.infinity,200.0);
    30. }
    31. @override
    32. bool shouldRepaint(FlowDelegate oldDelegate) {
    33. return oldDelegate != this;
    34. }
    35. }
    36. 复制代码

    运行效果

    image.png
    可以看到我们主要的任务就是实现paintChildren,它的主要任务是确定每个子widget位置。由于Flow不能自适应子widget的大小,我们通过在getSize返回一个固定大小来指定Flow的大小,实现起来还是比较麻烦的。

    小结
  • 参数简单,不过需要自己定义delegate

  • delegate一般是为了实现child的绘制,就是位置的摆放,不同情况需要定义不同的delegate
  • 不同的delegate一般会提供实现的几个方法:
    • getConstraintsForChild: 设置每个child的布局约束条件,会覆盖已有的方式
    • getSize:设置控件的尺寸
    • shouldRelayout:表示是否需要重新布局
  • 尽可能的用Wrap,毕竟简单

    层叠布局

    层叠布局和Web中的绝对定位、Android中的Frame布局是相似的,子widget可以根据到父容器四个角的位置来确定本身的位置。绝对定位允许子widget堆叠(按照代码中声明的顺序)。Flutter中使用Stack和Positioned来实现绝对定位,Stack允许子widget堆叠,而Positioned可以给子widget定位(根据Stack的四个角)。

    Stack

    1. Stack({
    2. this.alignment = AlignmentDirectional.topStart,
    3. this.textDirection,
    4. this.fit = StackFit.loose,
    5. this.overflow = Overflow.clip,
    6. List<Widget> children = const <Widget>[],
    7. })
    8. 复制代码
  • alignment:此参数决定如何去对齐没有定位(没有使用Positioned)或部分定位的子widget。所谓部分定位,在这里特指没有在某一个轴上定位:left、right为横轴,top、bottom为纵轴,只要包含某个轴上的一个定位属性就算在该轴上有定位。

  • textDirection:和Row、Wrap的textDirection功能一样,都用于决定alignment对齐的参考系即:textDirection的值为TextDirection.ltr,则alignment的start代表左,end代表右;textDirection的值为TextDirection.rtl,则alignment的start代表右,end代表左。
  • fit:此参数用于决定没有定位的子widget如何去适应Stack的大小。StackFit.loose表示使用子widget的大小,StackFit.expand表示扩伸到Stack的大小。
  • overflow:此属性决定如何显示超出Stack显示空间的子widget,值为Overflow.clip时,超出部分会被剪裁(隐藏),值为Overflow.visible 时则不会。
    下面是我用Stack实现的一个简易的loading
    1. class Loading extends StatelessWidget {
    2. /// ProgressIndicator的padding,决定loading的大小
    3. final EdgeInsets padding = EdgeInsets.all(30.0);
    4. /// 文字顶部距菊花的底部的距离
    5. final double margin = 10.0;
    6. /// 圆角
    7. final double cornerRadius = 10.0;
    8. final Widget _child;
    9. final bool _isLoading;
    10. final double opacity;
    11. final Color color;
    12. final String text;
    13. Loading({
    14. Key key,
    15. @required child,
    16. @required isLoading,
    17. this.text,
    18. this.opacity = 0.3,
    19. this.color = Colors.grey,
    20. }) : assert(child != null),
    21. assert(isLoading != null),
    22. _child = child,
    23. _isLoading = isLoading,
    24. super(key: key);
    25. @override
    26. Widget build(BuildContext context) {
    27. List<Widget> widgetList = List<Widget>();
    28. widgetList.add(_child);
    29. if (_isLoading) {
    30. final loading = [
    31. Opacity(
    32. opacity: opacity,
    33. child: ModalBarrier(dismissible: false, color: color),
    34. ),
    35. _buildProgressIndicator()
    36. ];
    37. widgetList.addAll(loading);
    38. }
    39. return Stack(
    40. children: widgetList,
    41. );
    42. }
    43. Widget _buildProgressIndicator() {
    44. return Center(
    45. child: Container(
    46. padding: padding,
    47. child: Column(
    48. mainAxisSize: MainAxisSize.min,
    49. crossAxisAlignment: CrossAxisAlignment.center,
    50. children: <Widget>[
    51. CupertinoActivityIndicator(),
    52. Padding(
    53. padding: EdgeInsets.only(top: margin),
    54. child: Text(text ?? '加载中...')),
    55. ],
    56. ),
    57. decoration: BoxDecoration(
    58. borderRadius: BorderRadius.all(Radius.circular(cornerRadius)),
    59. color: Colors.white),
    60. ),
    61. );
    62. }
    63. }
    64. 复制代码
    显示效果
    image.png
    本控件使用Stack封装,你传入的主视图在最下面一层,背景层在中间,最上面一层为菊花和文字loading,用isLoading控制显示

    Positioned

    1. const Positioned({
    2. Key key,
    3. this.left,
    4. this.top,
    5. this.right,
    6. this.bottom,
    7. this.width,
    8. this.height,
    9. @required Widget child,
    10. })
    11. 复制代码
    left、top 、right、 bottom分别代表离Stack左、上、右、底四边的距离。width和height用于指定定位元素的宽度和高度,注意,此处的width、height 和其它地方的意义稍微有点区别,此处用于配合left、top 、right、 bottom来定位widget,举个例子,在水平方向时,你只能指定left、right、width三个属性中的两个,如指定left和width后,right会自动算出(left+width),如果同时指定三个属性则会报错,垂直方向同理。
    示例代码
    1. //通过ConstrainedBox来确保Stack占满屏幕
    2. ConstrainedBox(
    3. constraints: BoxConstraints.expand(),
    4. child: Stack(
    5. alignment:Alignment.center , //指定未定位或部分定位widget的对齐方式
    6. children: <Widget>[
    7. Container(child: Text("Hello world",style: TextStyle(color: Colors.white)),
    8. color: Colors.red,
    9. ),
    10. Positioned(
    11. left: 18.0,
    12. child: Text("I am Jack"),
    13. ),
    14. Positioned(
    15. top: 18.0,
    16. child: Text("Your friend"),
    17. )
    18. ],
    19. ),
    20. );
    21. 复制代码
    运行效果:
    Flutter布局详解 - 图9由于第一个子widget Text(“Hello world”)没有指定定位,并且alignment值为Alignment.center,所以,它会居中显示。第二个子widget Text(“I am Jack”)只指定了水平方向的定位(left),所以属于部分定位,即垂直方向上没有定位,那么它在垂直方向对齐方式则会按照alignment指定的对齐方式对齐,即垂直方向居中。对于第三个子widget Text(“Your friend”),和第二个Text原理一样,只不过是水平方向没有定位,则水平方向居中。

    转载说明

    再惠研发团队
    https://juejin.im/post/5c2458d6f265da613a541349