从Container设置宽高无效看其布局约束

232 阅读9分钟

Container是什么

首先需要明确一点Container 是一个组合类容器组件,它本身没有特定的RenderObject 与其对应。观察期build 函数:

class Container {
  final BoxConstraints? constraints;
	
	/*
	** 初始化约束信息
	*/
  Container({
    double? width,
    double? height,
    BoxConstraints? constraints,
  }) : constraints = (width != null || height != null)
            ? constraints?.tighten(width: width, height: height) ??
                BoxConstraints.tightFor(width: width, height: height)
            : constraints;

  Widget build(BuildContext context) {
    Widget? current = child;
    if (child == null && (constraints == null || !constraints!.isTight)) {
      current = LimitedBox(
        maxWidth: 0.0,
        maxHeight: 0.0,
        child: ConstrainedBox(constraints: const BoxConstraints.expand()),
      );
    } else if (alignment != null) {
      current = Align(alignment: alignment!, child: current);
    }

    if (effectivePadding != null) {
      current = Padding(padding: effectivePadding, child: current);
    }

    if (color != null) {
      current = ColoredBox(color: color!, child: current);
    }

    if (clipBehavior != Clip.none) {
      assert(decoration != null);
      current = ClipPath(
        clipper: _DecorationClipper(
          textDirection: Directionality.maybeOf(context),
          decoration: decoration!,
        ),
        clipBehavior: clipBehavior,
        child: current,
      );
    }

    if (decoration != null) {
      current = DecoratedBox(decoration: decoration!, child: current);
    }

    if (constraints != null) {
      current = ConstrainedBox(constraints: constraints!, child: current);
    }

    if (margin != null) {
      current = Padding(padding: margin!, child: current);
    }

    if (transform != null) {
      current = Transform(
          transform: transform!, alignment: transformAlignment, child: current);
    }

    return current!;
  }
}

Container会根据传入的不同参数,使用DecoratedBoxColoredBoxPaddingAlignClipPath 、ConstrainedBox等组件进行组合的一个多功能组件“生成器”。Container 借助约束constraints 实现高度和宽度的控制。

约束

约束实际上就是 4 个浮点类型的集合:最大和最小宽度,以及最大和最小高度。它有两种类型,严格约束和宽松约束:

  • 严格约束**(Tight)**:最大/最小宽度是一致的,高度也一样,提供一种确切大小
  • 宽松约束**(loose)**:约束的最小宽度/高度为0,在最大宽/高度内,允许其子 Widget 获得比它更小的任意大小

Container 构造函数会根据传入的宽高和约束信息重新组合一个新的约束:

// Container 约束初始化
constraints = (width != null || height != null)
            ? constraints?.tighten(width: width, height: height) ??
                BoxConstraints.tightFor(width: width, height: height)
            : constraints;

class BoxConstraints extends Constraints {
	final double minWidth;

  final double maxWidth;
  
  final double minHeight;
  
  final double maxHeight;
  
  /// width和height有一个不为空,且设置了constraints
  /// width为空时,minWidth为minWidth,maxWidth
  /// height为空时,minHeight为minHeight,maxHeight
  /// width不为空时,与minWidth和maxWidth比较,确保新的width在[minWidth, maxWidth]范围内
  /// height不为空时,与minHeight和maxHeight比较,确保新的height在[minHeight, maxHeight]范围内
  BoxConstraints tighten({ double? width, double? height }) {
    return BoxConstraints(
      minWidth: width == null ? minWidth : clampDouble(width, minWidth, maxWidth),
      maxWidth: width == null ? maxWidth : clampDouble(width, minWidth, maxWidth),
      minHeight: height == null ? minHeight : clampDouble(height, minHeight, maxHeight),
      maxHeight: height == null ? maxHeight : clampDouble(height, minHeight, maxHeight),
    );
  }

	/// width和height有一个不为空,且没有设置constraints
  /// width为空时,minWidth为0.0,maxWidth为无限大
  /// height为空时,minHeight为0.0,maxHeight为无限大
  const BoxConstraints.tightFor({
    double? width,
    double? height,
  }) : minWidth = width ?? 0.0,
        maxWidth = width ?? double.infinity,
        minHeight = height ?? 0.0,
        maxHeight = height ?? double.infinity;
}

double clampDouble(double x, double min, double max) {
  assert(min <= max && !max.isNaN && !min.isNaN);
  if (x < min) {
    return min;
  }
  if (x > max) {
    return max;
  }
  if (x.isNaN) {
    return max;
  }
  return x;
}

一旦Container 设置了宽高,其约束模式就将变为严格模式。然而为Container 设置了宽高或者constraints,却并不总是会生效的。如下面的两种情况

void main() {
  runApp(
    Container(
      color: Colors.red,
      width: 100,
      height: 100,
    ),
  );
}

void main() {
  setPathUrlStrategy();
  runApp(
    Container(
      color: Colors.red,
      constraints: const BoxConstraints(
        minWidth: 100,
        maxHeight: 100,
        minHeight: 100,
        maxWidth: 100
      ),
    ),
  );
}

明明设置了宽高都是100,但是实际却是如下面左图填满屏幕的一片红:

image.png image2.png

如果将Container放在Align 中,此时的效果如上面右图所示。

Align(
  child: Container(
    color: Colors.red,
    width: 100,
    height: 100,
  ),
)

造成这种差异的根源是Flutter的布局约束规则。

约束的传递方式和继承规则

约束会沿着Widget树从父级传递到子级,父级决定子级的位置,而子级会向父级传递大小信息。

这种约束的传递和大小位置的确定是借助抽象类RenderObjectlayouperformLayout 函数实现的:

abstract class RenderObject {
	
	Constraints? _constraints;
	Constraints get constraints {
    if (_constraints == null) {
      throw StateError('A RenderObject does not have any constraints before it has been laid out.');
    }
    return _constraints!;
  }
  
	/// 由父级调用,并接受父级的约束信息
	/// 主要职责是计算自身的尺寸和位置偏移
  void layout(Constraints constraints, { bool parentUsesSize = false }) {
	  _constraints = constraints;
    performLayout();
  }
  
  @protected
  void performLayout();
}

以单子元素为例,RenderObject 的实现类如果包含有子级,它必须调用子级的layout 以便能正常的走完UI的测量布局和绘制流程,如下:

 Size size;
 
 @override
 void performLayout() {
    if (child != null) {
      child.layout(constraints, parentUsesSize: true);
      size = child.size;
    } else {
      performResize();
    }
 }

执行流程就是父级将自身的约束条件传递给子级,在子级完成layout 之后,父级根据子级确定自己的尺寸。在performLayout 执行时,父组件完全有能力对constraints 进行调整已适配不同的业务需求。

Container 为例,每个设置了width/height或者constraints的Container 都会创建一个ConstrainedBox 作为父级对child进行约束,其有专门的RenderObject 实现类RenderConstrainedBox ,其中对constraints 的控制和调整逻辑如下:

class Container {
  final BoxConstraints? constraints;
	
  Widget build(BuildContext context) {
    /// ...
    if (constraints != null) {
      current = ConstrainedBox(constraints: constraints!, child: current);
    }
    /// ...
    return current!;
  }
}

class ConstrainedBox extends SingleChildRenderObjectWidget {

  ConstrainedBox({
    super.key,
    required this.constraints,
    super.child,
  }) : assert(constraints.debugAssertIsValid());

  final BoxConstraints constraints;

  @override
  RenderConstrainedBox createRenderObject(BuildContext context) {
    return RenderConstrainedBox(additionalConstraints: constraints);
  }
  
}

class RenderConstrainedBox  {
  BoxConstraints get additionalConstraints => _additionalConstraints;
  BoxConstraints _additionalConstraints;
  
  RenderConstrainedBox({
    RenderBox? child,
    required BoxConstraints additionalConstraints,
  }) : _additionalConstraints = additionalConstraints;

  @override
  void performLayout() {
    final BoxConstraints constraints = this.constraints;
    if (child != null) {
      child!.layout(_additionalConstraints.enforce(constraints), parentUsesSize: true);
      size = child!.size;
    } else {
      size = _additionalConstraints.enforce(constraints).constrain(Size.zero);
    }
  }
}

RenderConstrainedBoxperformLayout 函数中涉及到两个BoxConstraints 变量。由上文中的约束传递规则和ConstrainedBox 创建流程可知:

  • _additionalConstraints就是Container 传递过来的约束,它可能是在Container 创建时直接传入的,也可能是根据widthheight计算后生成的。也就是RenderConstrainedBox 自身的约束条件。
  • constraints 也就是this.constraints,它就是沿着绘制数传递过来的父级约束

RenderConstrainedBox 会根据当自身的约束条件和父级约束条件重新计算新的约束约束向下传递——这里就是造成Container 真实尺寸和传入尺寸的不一致的本质原因!

附上BoxConstraints.enforce 代码:

class BoxConstraints extends Constraints {
  
  const BoxConstraints({
    this.minWidth = 0.0,
    this.maxWidth = double.infinity,
    this.minHeight = 0.0,
    this.maxHeight = double.infinity,
  });

  BoxConstraints enforce(BoxConstraints constraints) {
    return BoxConstraints(
      minWidth: clampDouble(minWidth, constraints.minWidth, constraints.maxWidth),
      maxWidth: clampDouble(maxWidth, constraints.minWidth, constraints.maxWidth),
      minHeight: clampDouble(minHeight, constraints.minHeight, constraints.maxHeight),
      maxHeight: clampDouble(maxHeight, constraints.minHeight, constraints.maxHeight),
    );
  }
}

double clampDouble(double x, double min, double max) {
  assert(min <= max && !max.isNaN && !min.isNaN);
  if (x < min) {
    return min;
  }
  if (x > max) {
    return max;
  }
  if (x.isNaN) {
    return max;
  }
  return x;
}

enforce 会在根据当前约束的规则,重新创建一个符合给定约束规则的新约束——_additionalConstraints 所定义的规则必须符合constraints 。也就是说,Container 的约束必须符合其父级约束所定义的规则。

以开头的设置宽高无效为例,Container 所接受的约束为:


self = const BoxConstraints(
  minWidth: 100,
  maxHeight: 100,
  minHeight: 100,
  maxWidth: 100
);

而如果父级约束为:

parent = const BoxConstraints(
  minWidth: 300,
  maxWidth: 300
  maxHeight: 800,
  minHeight: 800
);

那么,在RenderConstrainedBox 执行performLayout 时会做如下计算,并生成新的约束:

self.enforce(parent);
/// =>
/// 新的约束如下:
newConstraints = const BoxConstraints(
  minWidth: 300,
  maxWidth: 300
  maxHeight: 800,
  minHeight: 800
);

由于Container 自身的约束规则完全不在父级规则的要求之内,其自身的约束规则完全会被抛弃并采用父级的约束规则newConstraintsContainer 的所有子级都会根据newConstraints 进行布局和绘制,而它自身的尺寸完全由子级决定。因此Container 的最终尺寸完全等于newConstraints 所规定的宽高。

约束的来源

而在Flutter中,所有组件的最终父级为View:

image.png

它的直接子级_RawView负责创建最顶级的RenderObject 。在 *RenderView*挂载时,会为*RenderView* 更新包括屏幕宽高在内的配置信息:

void _attachView([PipelineOwner? parentPipelineOwner]) {
  RendererBinding.instance.addRenderView(renderObject);
}

void addRenderView(RenderView view) {
  view.configuration = createViewConfigurationFor(view);
}

ViewConfiguration createViewConfigurationFor(RenderView renderView) {
  final FlutterView view = renderView.flutterView;
  final double devicePixelRatio = view.devicePixelRatio;
  return ViewConfiguration(
    size: view.physicalSize / devicePixelRatio,
    devicePixelRatio: devicePixelRatio,
  );
}

RenderViewperformLayout 则会根据屏幕宽高创建一个严格约束作为最顶层的约束:

void performLayout() {
  assert(_rootTransform != null);
  _size = configuration.size;
  assert(_size.isFinite);

  if (child != null) {
    child!.layout(BoxConstraints.tight(_size));
  }
}

 BoxConstraints.tight(Size size)
    : minWidth = size.width,
      maxWidth = size.width,
      minHeight = size.height,
      maxHeight = size.height;

此时,子级接受到的约束最大最小宽度都等于屏幕宽度,最大最小高度都等于屏幕高度。

enforce和loosen两种不同的约束继承规则

Container 使用ConstrainedBox 进行约束管理,使用BoxConstraints.enforce 重新计算约束。这样无论如何,计算出来的新约束规则的都将是最大最小宽度都等于屏幕宽度,最大最小高度都等于屏幕高度。

@override
void performLayout() {]
    child!.layout(_additionalConstraints.enforce(constraints), parentUsesSize: true);
}

BoxConstraints enforce(BoxConstraints constraints) {
  return BoxConstraints(
    minWidth: clampDouble(minWidth, constraints.minWidth, constraints.maxWidth),
    maxWidth: clampDouble(maxWidth, constraints.minWidth, constraints.maxWidth),
    minHeight: clampDouble(minHeight, constraints.minHeight, constraints.maxHeight),
    maxHeight: clampDouble(maxHeight, constraints.minHeight, constraints.maxHeight),
  );
}

可见,如果使用enforce计算新的规则,如果父级约束为严格约束,那么新的约束也依旧是严格约束。

那么,为什么使用了Center或者Align 作为父级的Container 宽高就会生效呢?

Center 是对Align 的进一步包装,其对应的RenderObject都是 RenderPositionedBox 。而它管理约束的方式和RenderConstrainedBox 有所不同:

@override
void performLayout() {
  final BoxConstraints constraints = this.constraints;
  child!.layout(constraints.loosen(), parentUsesSize: true);
}

class BoxConstraints extends Constraints {

  const BoxConstraints({
    this.minWidth = 0.0,
    this.maxWidth = double.infinity,
    this.minHeight = 0.0,
    this.maxHeight = double.infinity,
  });
  
  BoxConstraints loosen() {
    assert(debugAssertIsValid());
    return BoxConstraints(
      maxWidth: maxWidth,
      maxHeight: maxHeight,
    );
  }
}

RenderPositionedBox 会以0作为最小宽高度。因此在上文中的场景中,只要为Container 设置的宽高在0~屏幕大小范围内,都将会正确的生效。

enforce 不同,使用loosen计算新的约束,新的约束会是宽松约束,其最小值为0,最大值为父约束的最大值。

自定义约束规则

参照AlignRenderConstrainedBox 对约束的计算方法,完全可以通过重写performLayout 函数实现对约束的自定义,如要设置一个尺寸必须在100~200之间的约束:

class CustomBox extends SingleChildRenderObjectWidget {
  const CustomBox({
    super.key,
    super.child,
  });

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

class CustomRender extends RenderBox with RenderObjectWithChildMixin<RenderBox> {

	/// 设置约束 
  final  _customConstraints = const BoxConstraints(
    minWidth: 100,
    maxWidth: 200,
    minHeight: 100,
    maxHeight: 200,
  );
  @override
  void performLayout() {
    /// 向子级传递约束
    child!.layout(_customConstraints, parentUsesSize: true);
    size = constraints.constrain(child!.size);
  }

  @override
  void paint(PaintingContext context, Offset offset) {
    var childSize = child!.size;
    /// 绘制子级,确定子组件的位置
    context.paintChild(child!, Offset((size.width - childSize.width) / 2, 100));
  }
}

我们可以通过自定义 RenderBox 的实现类完成自定义布局约束,官方提供了很多默认实现,且大致可分为三类:

  • 尽可能地撑满。例如 Center 和 Align 在调用子级的layout时,通过BoxConstraints.loosen 创建一个最小宽高为0的宽松约束供子级使用。

  • 尽可能地保持与子节点一致。例如 Transform 和 Opacity 在调用子级的layout时直接传递父级约束而不作任何操作:child?.layout(constraints, parentUsesSize: true)

  • 尽可能地布局为指定大小。例如 Image ,它会使用自身宽高和父约束进行计算以确定自身尺寸:

    constraints = BoxConstraints.tightFor(
    	width: _width,
    	height: _height,
    ).enforce(constraints);
    
    const BoxConstraints.tightFor({
        double? width,
        double? height,
      }) : minWidth = width ?? 0.0,
           maxWidth = width ?? double.infinity,
           minHeight = height ?? 0.0,
           maxHeight = height ?? double.infinity;
    

总结

一定要牢记一条重要的规则:

Widget不能任意设置尺寸,仅在给定尺寸满足父级约束规则前提下才会生效!

Flutter官方已经对组件的约束做了详细的说明和总结,大致内容如下:

  • 上层 widget 向下层 widget 传递约束条件;

    • Widget 会通过它的 父级 获得自身的约束。之后逐个遍历它的 children 列表。
    • 紧接着向子级传递 约束(子级之间的约束可能会有所不同),
    • 然后询问它的每一个子级需要用于布局的大小,并逐进行布局。
  • 下层 widget 向上层 widget 传递大小信息。

    • widget 将会把它的大小信息向上传递至父 widget
  • 上层 widget 决定下层 widget 的位置

    • 在最终绘制子级时,父级会根据子级大小决定子级所在的位置

像 Container 这样的 widget 会根据不同的参数进行不同的布局。 Container 的默认构造会让其尽可能地撑满大小限制,但如果你设置了 width,它就会尽可能地遵照你设置的大小。

最后附上官方教程深入理解Flutter布局约束,其中提供了三十个布局例子供大家学习。从Contain设置宽高无效看其布局约束