一、初识布局

1.1 约束(Terminology)的向下传递

​ Flutter从一个runApp函数开始运行,例如:

void main() {
  runApp(//1.向下Container传递紧约束:屏幕尺寸
    Container(//2.向下Center传递约束,由于自身的w和h无效,因此原封不动地传递
      width: 0.04,//1.来自App的约束使width失效,w=页面宽度
      height: 0.08,//1.来自App的约束使height失效,h=页面高度
      color: Colors.red[200],
      child: Center(//3.向下Container传递松约束,下限设为0
        child: Container(//4.向下FlutterLogo传递紧约束:200<=h,w<=200
          color: Colors.white,
          width: 200,//3.满足Center的约束,w=200
          height: 200,//3.满足Center的约束,h=200
          child: FlutterLogo(
            size: 10//不满足约束条件,失效,size=200
          ),
        ),
      )
    ));
}

上面这段代码的运行结果如下图:

截屏2021-11-03 上午10.33.40

​ 通过读取LayoutBuilder的constrants参数,我们可以得到来自父组件的约束区间:

LayoutBuilder(builder: (context, constrants) {
  print("constrants: $constrants");
  return FlutterLogo(size: 10);
}),

​ 我们可以通过Align系列控件来放松约束,例如使用Center

1.2 SizedBox和FractionallySizedBox

​ 我们可以使用SizedBoxFractionallySizedBox来确定组件的大小,其中后者使用一个比例关系来约束。

​ 但如果SizedBox本身受到一个紧约束,那么它也无法突破。

​ 目前我们只能使用Align系列Widgets(比如Center)来将紧约束转换为松约束,例如将200<=h,w<=200转化为0<=h,w<=200

Container(
  //向下SizedBox传递紧约束:200<=h,w<=200
  width: 200,
  height: 200,
  child: SizedBox(  //收到Container的紧约束
    height: 100,//失效
    width: 100,//失效
    child: FlutterLogo(size: 10), //不满足约束条件,失效,size=200
  )

1.3 ConstrainedBox

Container( //向下传递紧约束:h,w=200
  color: Colors.black,
  width: 200,
  height: 500,
  child: Center(//向下传递松约束:0<=h,w<=200
    child: ConstrainedBox(//重新定义约束区间(同样,收到上级约束的限制)
      constraints: const BoxConstraints(
        minWidth: 60,
        maxWidth: double.infinity,//无限大,被上级限制为200
        minHeight: 60,
        maxHeight: 120,
      ),
      child: LayoutBuilder(builder: (context, constraints) {
        print("$constraints");
        //flutter: BoxConstraints(60.0<=w<=120.0, 60.0<=h<=120.0)
        return const FlutterLogo(size: 150);//150被限制回120
      }),
    ),
  ),
),

ConstrainedBox可以重新定义约束区间,但新定义的约束区间必须在父约束之内。我们可以使用loosen()函数取消minWidthminHeight,也就是将它们设置为0.

Container( //向下传递紧约束:h,w=200
  color: Colors.black,
  width: 200,
  height: 500,
  child: Center(//向下传递松约束:0<=h,w<=200
    child: ConstrainedBox(//重新定义约束区间(同样,收到上级约束的限制)
      constraints: const BoxConstraints(
        minWidth: 60,
        maxWidth: double.infinity,//无限大,被上级限制为200
        minHeight: 60,
        maxHeight: 120,
      ).loosen(),	//变为松约束(下限为0)
      child: LayoutBuilder(builder: (context, constraints) {
        print("$constraints");
        //flutter: BoxConstraints(0.0<=w<=120.0, 0.0<=h<=120.0)
        return const FlutterLogo(size: 150);//150被限制回120
      }),
    ),
  ),
),
  • 紧约束:最大值等于最小值的约束;
  • 松约束:最小值等于0的约束;
  • 既是松约束,又是紧约束的约束:最大值与最小值都是0的约束。

同时,

  • 有界约束:有设置最大值的约束;
  • 无界约束:最大值是无限大的约束,例如无限长的ListView。

1.4 Padding

Padding组件可以收缩Container的紧约束,但仍向下传递一个紧约束。

Container(
  color: Colors.black,
  width: 300,
  height: 300,
  child: Padding(
    padding: const EdgeInsets.all(20.0),
    //上下同时设置20的边框,收缩了Container的紧约束
    child: LayoutBuilder(builder: (context, constraints) {
      print("$constraints");
      //BoxConstraints(w=260.0, h=260.0),这里仍然是一个紧约束
      return const FlutterLogo(size: 150);//150仍然失效
    }),
  ),
),

二、弹性布局——Column和Flexible

2.1 Column和Directionality

Scaffold(
  body: Center(
    child: Container(
      color: Colors.red[200],
      width: 300,
      child: Column(
        mainAxisAlignment: MainAxisAlignment.spaceAround,
        //主轴环绕留白
        crossAxisAlignment: CrossAxisAlignment.start,
        //交叉轴:从起始处(一般从左到右)
        //某些语言的阅读顺序是从右向左,可以使用Directionality调整
        children: const [
          FlutterLogo(size:150),
          FlutterLogo(size: 50),
          FlutterLogo(size: 150),
        ],)),
  ),
)

截屏2021-11-03 上午11.34.26

Column还有一些其他属性,如mainAxisSizeCrossAxisAlignment.stretch

Scaffold(
  body: Center(
    child: Container(
      color: Colors.red[200],
      child: Column(
        mainAxisSize: MainAxisSize.min,//设置主轴方向的长度,默认max
        mainAxisAlignment: MainAxisAlignment.spaceAround,
        crossAxisAlignment: CrossAxisAlignment.stretch,
        //交叉轴:拉伸至与父限制一致(类型交叉轴的expanded)
        children: const [
          FlutterLogo(size:50),
          FlutterLogo(size: 50),
          FlutterLogo(size: 50),
        ],)),
  ),
)

截屏2021-11-03 上午11.38.30

2.2 Expanded

使用Expanded组件尽可能多地填满剩余空间:

Center(
  child: Container(
    color: Colors.red[200],
    child: Column(
      //交叉轴:拉伸至与父限制一致(类型交叉轴的expand)
      children: [
        FlutterLogo(size:50),
        FlutterLogo(size: 50),
        Expanded(child: Container(color: Colors.black)),
        FlutterLogo(size: 50),
      ],)),
),

通过指定flex参数设置两个Expanded之间的比例:

Center(
  child: Container(
    color: Colors.red[200],
    child: Column(
      //交叉轴:拉伸至与父限制一致(类型交叉轴的expand)
      children: [
        FlutterLogo(size:50),
        Expanded(flex:1, child: Container(color: Colors.green)),
        FlutterLogo(size: 50),
        Expanded(flex:2, child: Container(color: Colors.green)),
        FlutterLogo(size: 50),
      ],)),
)

截屏2021-11-03 上午11.46.01

Expanded组件的本质是Flexible组件,例如:

Expanded(
  child: Container(color: Colors.green)
),
//等价于
Flexible(
  flex:1,
  child: Container(color: Colors.green)
),
  • 使用Flexible时,Flutter首先会渲染Column中固定的项目,再用剩余的空间分比例占取。

  • Flutter在布局其中的非弹性控件时(例如FlutterLogo),会先假装自己是一个无界约束,例如

    Column(
      children: [
        LayoutBuilder(builder: (_, con) {
          print('$con');
          //flutter: BoxConstraints(0.0<=w<=375.0, 0.0<=h<=Infinity)
          return FlutterLogo(size: 50);
        }),
        Expanded(flex: 1, child: Container(color: Colors.green)),
        FlutterLogo(size: 50),
        Expanded(flex: 2, child: Container(color: Colors.green)),
        FlutterLogo(size: 50),
      ],
    )
    

    这是由于Column没有办法将自己受到的限制提前告知FlutterLogo,为了尽可能地不限制FlutterLogo,Column会传入一个Infinity“假装”自己是一个无界控件,但如果真的超出了限制,则会提示错误。

2.3 Column中的ListView

倘若我们直接在Column中插入ListView,则会提示错误:

Column(
  children: [
    FlutterLogo(size: 50),
    ListView(children: [	//报错!
      for(int i=0;i<10;i++) Text("ListView item $i")
    ]),
    FlutterLogo(size: 50),
  ],
)

正如我们上面提到的那样,Column在渲染时会假装自己是一个无界控件。Column的渲染分成两个阶段:

  1. 渲染固定组件(非Flexible组件)

  2. 将剩余的空间分配给children中的各个弹性(也就是Flexible)组件

​ 而在上面的代码中,ListView不是一个Flexible组件,所以它参与第一个阶段的渲染,它发现自己受到的约束是Infinity(请参考2.2节的最后一段代码)。而ListView本身也是一个Infinity组件,于是它就真的使用Infinity的空间,超出了Column实际受到的限制(比如:屏幕尺寸),于是就会产生内容溢出的错误。

​ 因此,如果我们需要在Column中使用ListView,就需要给予ListView限制,可以用Expanded包裹ListView:

Column(
  children: [
    FlutterLogo(size: 50),
    Expanded(	//使用Expanded包裹ListView
      child: ListView(children: [
        for(int i=0;i<100;i++) Text("ListView item $i")
      ]),
    ),
    FlutterLogo(size: 50),
  ],
)

截屏2021-11-03 下午12.12.23

三、层叠组件——Stack

3.1 Stack组件

用下面一段代码展示Stack的作用:

Center(
  child: Container(
    color: Colors.grey, //可以看到Stack本身的大小,受到children的影响
    child: Stack(
      alignment: Alignment(0,0),//等价于Alignment.center
      children: [
        FlutterLogo(size: 100),
        Text("111",style: TextStyle(fontSize: 150)),
        Positioned(
          left: 0,
          top: 0,
          child: Container(color: Colors.red,width: 30,height: 30)
        ),
      ],),
  ),
)

3.2 Stack的渲染过程

​ 与Column组件类似,Stack中的成员分成两类:有位置的(也就是Positioned)和没有位置的。

​ 在渲染的过程中Stack如何确定其自身的大小呢?

  1. Stack会首先将没有位置的组件全部渲染出来,并以此确定其自身的大小;
  2. 然后再按照位置渲染Positioned组件。

​ 我们再看一个例子:

Container(
  color: Colors.grey,
  child: Stack(
    alignment: Alignment(0,0),
    children: [
      Positioned(	//Stack本身的大小与Positioned组件无关!
        left: 0, bottom: 0,
        child: FlutterLogo(size: 300)
      ),
      Text("111",style: TextStyle(fontSize: 150)),
      Positioned(
        left: 0,
        top: 0,
        child: Container(color: Colors.red,width: 30,height: 30)
      ),
    ],),
)

也因此,FlutterLogo没有被完整渲染出来:

​ 我们可以指定fit属性来将父组件的约束传给下级组件。不是让Stack本身扩展,而是让其中的children受到Stack上级组件的约束,Stack一般也会受到影响,但那只是一个副作用:

Stack(
  fit: StackFit.expand,
  ...
),
Container(
	constraints: BoxConstraints(
  	minWidth:30,
  ),
  child: Stack(
  	fit: StackFit.passthrough,
    //直接将上级的非紧约束传给children
  )
)

3.3 Positioned组件

首先,Positioned会强行要求其中的位置信息达成,也就是间接地确定了其中控件的大小:

Container(
  color: Colors.grey,
  child: Stack(
    alignment: Alignment(0,0),
    children: [
      Positioned(
        left: 0,	//假如我们既设置了left,又设置了right,会怎么样呢?
        right: 0,
        child: Container(color: Colors.blue, width: 30, height: 30)
        		//答案是:Container本身的w和h将会失效!
      ),
      Text("111",style: TextStyle(fontSize: 150)),
      Positioned(
        left: 0,
        top: 0,
        child: Container(color: Colors.red,width: 30,height: 30)
      ),
    ],),
),

截屏2021-11-03 下午12.43.16

​ 另外,如果我们定义了Positioned,但却没有给它传递任何信息,那么Stack就会将Positioned忽略,此时Positioned里面的组件将会参与第一阶段的渲染,也就是会影响Stack本身的大小。

​ 我们可以通过指定clipBehavior属性来确定超出部分是否会被裁减:

Container(
  color: Colors.grey, //可以看到Stack本身的大小,受到children的影响
  child: Stack(
    clipBehavior: Clip.none,//默认Clip.hardEdge
    //等价于旧版本的overflow: Overflow.visible,
    alignment: Alignment(0,0),//等价于Alignment.center
    children: [
      Positioned(
        left: 0,
        width: 300, //溢出边框啦!
        child: Container(color: Colors.blue, width: 30, height: 30)
      ),
      Text("111",style: TextStyle(fontSize: 150)),
      Positioned(
        left: 0,
        top: 0,
        child: Container(color: Colors.red,width: 30,height: 30)
      ),
    ],),
),

截屏2021-11-03 下午12.52.04

​ 需要注意的是,如果控件的尺寸超出了Stack本身的尺寸,那么在Stack外的点击事件将不会生效。因为那里的点击事件不会发送给Stack,那么也就不会传送给Stack的children。

​ 另外,即使设置了Clip.hardEdge,对于Transform指定的变换位移,超出部分依然不会被裁减,这也是因为Flutter的优化机制导致的。

四、再谈Container

4.1 Container语法糖

​ 一般来说,Container会传递一个紧约束,我们可以通过Align系列组件放松约束:

//很繁琐
Container(
  color: Colors.orange,
  width: 200,
  height: 200,
  child: const Padding(
    padding: EdgeInsets.all(24.0),
    child: Align(
      alignment: Alignment.topLeft,
      child: FlutterLogo(),
    ),
  ),
)

​ 这样做实在太繁琐,我们可以直接使用Container中的paddingalignment属性,这是Flutter提供的语法糖:

//与上面的代码等价
Container(
  color: Colors.orange,
  width: 200,
  height: 200,
  padding: const EdgeInsets.all(24.0),
  alignment: Alignment.topLeft,
  child: const FlutterLogo(),
)

另外,还有一些其他的语法糖,例如colordecorationforegroundDecorationconstraints对应ColoredBoxDecoratedBoxConstrainedBoxheight/width对应SizedBox

  • color => ColoredBox
  • decoration / foregroundDecoration => DecoratedBox
  • constraints => ConstrainedBox
  • clipBehavior => Clip
  • height/width => SizedBox
  • margin => Padding 添加在Container外
  • padding => Padding 添加在child外,Container内
  • transform => Transform组件

4.2 Container行为

​ 一般来说,如果给Container指定一个child,那么Container的大小就会与其child一致,除非您设置了alignment属性。否则就会直接占满父组件。

​ 而如果Container在诸如Column这样主轴无界的组件内,那么它的该轴上的长度会被调整为0。

​ 使用margin属性时也会影响Container的大小。

Container如何知道自己所在的组件是否有界呢?这里可以参考LimitedBox组件,当出现某个尺寸无穷大的情况,会自动缩减成maxHeight,而如果不是infinity的情况,该轴的尺寸则可以超过maxHeight,也就是LimitedBox此时无任何效果,因此,这里的maxHeight其实可以等同于Placeholder中的fallbackHeight属性。

​ 更多信息,可以参考Container的源代码。

五、CustomMultiChildLayout

5.1 制作一个自己的Stack

我们可以使用CustomMultiChildLayout组件自制一个简易的Stack:

CustomMultiChildLayout(
  delegate: MyDelegate(),
  children: [
    //标记其中的组件,使得MyDelegate中可以找到这些组件
    //LayoutID与Positioned类似,只能在这里使用
    //* 这里的Z-Order由children顺序决定,与MyDelegate无关!
    LayoutId(id: 1, child: FlutterLogo()),
    LayoutId(id: 2, child: FlutterLogo()),
  ],
)

自定义一个MyDelegate类作为代理:

class MyDelegate extends MultiChildLayoutDelegate {
  @override
  void performLayout(Size size) {
    if (hasChild(1)) {
      final size1 = layoutChild(
          1, //1号组件layout,并得到它的size(会想:向下传递限制,向上传递大小)
          BoxConstraints.tight(const Size(100, 100)) //向下传递限制
          );
      positionChild(1, Offset.zero);  //将1号组件放在左上角
    }
    if (hasChild(2)) {
      final size2 = layoutChild(
          2, //2号组件layout,并得到它的size(会想:向下传递限制,向上传递大小)
          BoxConstraints.tight(const Size(200, 200)) //向下传递限制
          );
      positionChild(2, Offset.zero); //将2号组件放在左上角
    }
  }

//返回true表示永远需要重新更新,这个函数用作优化,这里暂不考虑
  @override
  bool shouldRelayout(_) => true;
}

截屏2021-11-03 下午3.28.34

5.2 简易Column

我们可以通过类似方法自制一个简易的Column:

positionChild(2, Offset(0,size1.height)); //将2号组件放在1号组件下面:类似Column

同样,我们可以设置BoxConstraints.loost(size)来传递松约束:

void performLayout(Size size) {
  if (hasChild(1)) {
    final size1 = layoutChild(
      1, //1号组件layout,并得到它的size(会想:向下传递限制,向上传递大小)
      BoxConstraints.loost(size) //向下传递限制
    );
    positionChild(1, Offset.zero);  //将1号组件放在左上角
  }
  if (hasChild(2)) {
    final size2 = layoutChild(
      2, //2号组件layout,并得到它的size(会想:向下传递限制,向上传递大小)
      BoxConstraints.tight(const Size(200, 200)) //向下传递限制
    );
    positionChild(2, Offset.zero); //将2号组件放在左上角
  }
}

5.3 MyDelegate的局限性

  1. 首先,MyDelegate无法在绘制时获得其子组件的大小。通过继承MultiChildLayoutDelegateSize getSize(BoxConstraints c)方法,我们可以得到上级组件给我们的限制,也可以向上传递我们需要的Size大小,但我们并不能通过这个函数获得子组件的大小,也就是说我们无法使得MyDelegate自适应子组件的大小。
  2. 我们无法在performLayout函数中“无中生有”,我们只能在其中画出children数组中已经存在的组件,不能在这里创造出一个新的组件。

为了解决上述两个问题,我们可以使用RenderObject

六、布局原理——RenderObject

6.1 简述

​ 我们知道,平常我们在写的Stateless、Stateful组件,其实并没有将自己画在屏幕上的能力,它们只是定义了一个build方法而已。真正将内容写在屏幕上的,其实是RenderObject

​ 我们举一个例子来说明:

Container(
  color: Colors.grey,
  child: MyRenderBox(
    child: const FlutterLogo(size: 200),
  ),
),
class MyRenderBox extends SingleChildRenderObjectWidget {
  MyRenderBox({required Widget child}) : super(child: child);

  @override
  RenderObject createRenderObject(BuildContext context) {
    return RenderMyRenderBox();
  }
}

class RenderMyRenderBox extends RenderBox with RenderObjectWithChildMixin {
  //渲染pipeline:performLayout、paint、composition
  @override
  void performLayout() {
    //处理布局
    child!.layout(constraints, //也可以自定义紧约束
        parentUsesSize: true); //作为父组件需要使用子组件的size,默认是false
            //优化用:如果不需要,子组件改动时就可以不必继续遍历渲染父组件
    //size = const Size(300, 300);
    size = (child as RenderBox).size;
  }

  @override
  void paint(PaintingContext context, Offset offset) {
    //处理绘制
    context.paintChild(child!, offset + Offset(120,120));
    	//FlutterLogo跑到MyRenderBox外面去了!
    //context.canvas.draw** //可以在这里画任意图形!
    //context.pushOpacity		//Opacity原理:使用push增加一个图层(资源耗费多)
  }
}

截屏2021-11-03 下午4.39.08

​ 在上述代码中,performLayout和paint是两次不同的遍历过程(flutter一般是深度优先),因此这里的FlutterLogo可以跑到MyRenderBox外面去!

​ paintChild函数是非常自由的,我们可以选择canvas.draw或者push系列函数做更多想要的事情,也可以干脆不做任何事。

6.2 Hot Reload原理——updateRenderObject

通过自制一个ShadowRenderBox,我们可以实现阴影效果:

Container(
  color: Colors.grey,
  child: ShadowBox(
    distance: 20.0,
    child: const Icon(Icons.category, size: 80),
  ),
),
class ShadowBox extends SingleChildRenderObjectWidget {
  final double distance;
  ShadowBox({required Widget child, required this.distance})
      : super(child: child);

  @override
  RenderObject createRenderObject(BuildContext context) {
    return RenderShadowBox(distance: distance);
  }

  @override
  void updateRenderObject(
      BuildContext context, covariant RenderShadowBox renderObject) {
    //如果不定义这个函数,热重载时会根据key和类型判断元素是否改变,如果没有改变,则继续使用原来的renderObject,distance的值不会改变
    renderObject.distance = distance;
  }
}

/*
	也可以使用 class RenderShadowBox extends RenderProxyBox
	此时,不再performLayout()和paint函数不再必须,只需要写我们需要的那个函数即可
*/
class RenderShadowBox extends RenderBox with RenderObjectWithChildMixin {
  double distance;
  RenderShadowBox({required this.distance});
  @override
  void performLayout() {
    //处理布局
    child!.layout(constraints, parentUsesSize: true);
    size = (child as RenderBox).size;
  }

  @override
  void paint(PaintingContext context, Offset offset) {
    context.paintChild(child!, offset);
    //插入一个新的半透明图层
    context.pushOpacity(offset, 127, (context, offset) {
      context.paintChild(child!, offset + Offset(distance, distance));
    });
  }
}

截屏2021-11-03 下午5.01.29

上面的RenderShadowBox可以简单地写成下面的形式:

class RenderShadowBox extends RenderProxyBox {
  double distance;
  RenderShadowBox({required this.distance});

  @override
  void paint(PaintingContext context, Offset offset) {
    context.paintChild(child!, offset);
    context.pushOpacity(offset, 127, (context, offset) {
      context.paintChild(child!, offset + Offset(distance, distance));
    });
  }
}

​ 另外,也可以设置DebugOverflowIndicatorMixin来定义超出界限后的那个黑黄相间的错误框。那个overflow组件也只是通过paintOverflowIndicator()手动绘制的而已。