Flutter ConstraintLayout(约束布局)完全指南

我会用三篇文章来讲透 Flutter ConstraintLayout(约束布局),让你用起来能够得心应手。分别是《Flutter ConstraintLayout 完全指南》、《Flutter ConstraintLayout 原理解析》、《Flutter ConstraintLayout 最佳实践》。今天是第一篇。

https://github.com/hackware1993/Flutter_ConstraintLayout

前言

这是一份 Flutter ConstraintLayout 的完全指南,基于当前最新的 1.6.3-stable 版本。由于现在的 API 已经稳定,所以本文可能会长期适用,后期仅会有很小的变动。

如果你有 Android ConstraintLayout 的经验,那你能上手更快,但也希望你能认真看完全文,因为声明式 UI 下的用法和 XML 里的用法截然不同。并且它有很多 Android ConstraintLayout 所不具有的优秀特性。如果你是一名 iOSer,那就更应该看完了,据我的了解,不论是 Android 下的 ConstraintLayout,还是 Flutter ConstraintLayout,它们都和 AutoLayout 有很大的不同。

约束布局的本质

任何布局控件的核心都在于计算子控件的大小和位置。而约束布局的核心在于使用约束来计算子控件的大小和位置,而约束的本质是对齐。即指定子控件的上、下、左、右、基线分别和哪些子控件(或 parent)的哪些位置去对齐。这直接决定了子控件的位置,并可能决定子控件的大小(如果子控件大小指定为 matchConstraint 的话)。

用过约束布局的人几乎都回不去了,主要是因为它有两大杀手锏,一是它能让布局层次更加扁平化,这样能带来渲染性能的提升。二是强大灵活的布局(排版)能力能让我们更快的开发 UI。

我认为后者更重要,因为这种布局能力是一种所见即所写的能力,什么意思呢?意思是说设计同事在做设计的时候不会去考虑技术实现的细节,他们只关心哪个元素在哪个元素的下面,哪个元素在哪个元素的右边等等。对于他们来讲,没有嵌套这种说法。他们认为所有的元素都在一个平面内,他们做设计的时候只是在一个平面内去拖动、调整一些元素而已。

然而对于开发者来讲,事情就不一样了。

当不使用约束布局时,开发者必须要考虑实现方案,即怎么样结合 Row、Column、Stack 或自定义控件去达到设计师的效果。这个考虑的过程直接拖慢了开发进度。此外对于某些场景,开发需要的工作量也会进一步拖慢开发进度,而这些场景使用约束布局可能是分分钟就能搞定的事。

当使用约束布局时。所有的子元素都在一个平面内。我们几乎可以完全遵照视觉稿来布局,甚至不需要思考。哪个元素在哪个元素的下面,我们就把它约束在哪个元素的下面。哪个元素在哪个元素的右边,我们就把它约束在哪个元素的右边。这会大大地提高开发效率。

约束布局会在性能、灵活性、开发速度、可维护性方面全面超越传统嵌套写法,不信你可以试试。

基本用法

在 Flutter 中,父元素想给子元素施加布局参数,标准的做法是使用 ParentDataWidget 将子元素包起来。例如 Stack 中的子元素有时要用 Positioned 包住以定位它们。ParentDataWidget 机制就跟 Android 中的 LayoutParams 是一个意思,它的原理很简单,本文不做过多介绍,我后面会再写一篇文章深入剖析它(连同 ParentData 机制)。总之想给子元素施加 LayoutParams,就用 ParentDataWidget 包裹它,不同的布局会提供不同类型的 ParentDataWidget。Flutter ConstraintLayout 也提供了 ParentDataWidget,名为 Constrained。用法如下:

ConstraintLayout(
  children: [
    Constrained(
      child: Container(
        color: Colors.yellow,
      ),
      constraint: Constraint(
        size: matchParent,
      ),
    )
  ],
),

所有给子元素施加的约束都存储在 Constraint 中。除了内置的 Helper Widget(Guideline、Barrier)以外,所有其他 Widget 都需要使用 Constrained 包裹。当然这显得有点麻烦,因此我提供了基于扩展函数的简便写法,并推荐大家这么写:

Container(
  color: Colors.yellow,
).applyConstraint(
  size: 200,
  centerTo: parent,
)

平均下来 applyConstraint 中一般只需要两行代码,一行指定元素大小,一行指定元素位置。

基本约束(对齐属性)

Flutter ConstraintLayout 提供了两套约束系统,一套是基本约束,一套是图钉定位(Pinned Position)。所有的基本约束如下:

  1. left
    1. toLeft
    2. toCenter(默认偏移量为 0.5,代表中心)
    3. toRight
  2. right
    1. toLeft
    2. toCenter(默认偏移量为 0.5,代表中心)
    3. toRight
  3. top
    1. toTop
    2. toCenter(默认偏移量为 0.5,代表中心)
    3. toBottom
  4. bottom
    1. toTop
    2. toCenter(默认偏移量为 0.5,代表中心)
    3. toBottom
  5. baseline
    1. toTop
    2. toCenter(默认偏移量为 0.5,代表中心)
    3. toBaseline
    4. toBottom

示例:

Container(
  color: Colors.yellow,
).applyConstraint(
  size: 200,
  left: parent.left,
  top: title.bottom,
)

这些约束具有自解释能力,不做过多介绍。它们起到的作用是让子元素的哪个位置去和其它子元素(或 parent)的哪个位置去对齐。

相比 Android 这里额外多了 toCenter:

Container(
  color: Colors.yellow,
).applyConstraint(
  size: 200,
  left: parent.center,
  right: parent.center.bias(0.8),
)

center 可以指定偏移量,默认为 0.5,为 0 时效果和 left 等同,为 1 时效果和 right 等同。

任何子元素在横向和纵向都必须至少有一个约束。这样才能定位它们。如果你在某一方向施加了两个约束,那起到的效果就是居中。

Container(
  color: Colors.yellow,
).applyConstraint(
  size: 200,
  left: parent.left,
  right: parent.right,
  top: parent.center,
)
水平方向上居中,顶部和 parent 的中心对齐

如果你把左右或上下都约束到同一个位置,那子元素就会相对于这个位置居中。

Container(
  color: Colors.yellow,
).applyConstraint(
  id: anchor,
  size: 200,
  centerTo: parent,
),
Container(
  color: Colors.green,
).applyConstraint(
  size: 100,
  left: anchor.right,
  right: anchor.right,
  top: anchor.bottom,
)
水平方向和黄块的右侧对齐,顶部和黄块的底部对齐

值得注意的是,如果将子元素的宽或高设置为 matchParent,则不能再设置基本约束。因为内部会自动设置:

if (width == matchParent) {
  left = parent.left;
  right = parent.right;
}

if (height == matchParent) {
  top = parent.top;
  bottom = parent.bottom;
  baseline = null;
}

子元素大小设置

有 3 个属性来设置子元素的大小,分别是 width、height、size。它们可以取值为:

  1. matchParent: 撑满父布局
  2. wrapContent(默认值): 自己有多大就撑多大,可设置最小或最大大小
  3. matchConstraint: 大小由约束决定
  4. fixed_size (>= 0): 固定大小

当 width 和 height 相同时,给 size 赋值就行了,这样能少写一行代码。内部会做如下转换:

if (size != null) {
  width = size!;
  height = size!;
}

每一种取值需要的约束数量是不同的,比如取值为 matchConstraint 时,某一方向上必须要设置 2 个约束(完整约束),相关的规则如下:

int getMinimalConstraintCount(double size) {
  if (size == matchParent) {
    return 0;   // 不能再设置约束
  } else if (size == wrapContent || size >= 0) {
    return 1;   // 至少要设置 1 个约束
  } else {
    return 2;   // matchConstraint,必须要设置 2 个约束
  }
}

当子元素的宽或高为 wrapContent(默认) 时,可使用 minWidth、maxWidth、minHeight、maxHeight 设置最小、最大宽高。默认的最小值为 0,最大值为 matchParent。即默认情况下子元素的宽高不能超过 parent 的宽高,你可以赋其它值来突破这个限制。

id 与相对 id

如果子元素需要和某个子元素的某个位置对齐,可以给后者指定一个 id。先声明,再赋值和引用:

// 声明
ConstraintId anchor = ConstraintId('anchor');

Container(
  color: Colors.yellow,
).applyConstraint(
  id: anchor,   // 赋值
);
Container(
  color: Colors.green,
).applyConstraint(
  left: anchor.right,   // 引用
  top: anchor.bottom,   // 引用
);

这里需要保证字符串的唯一性。一般将 id 声明为 StatelessWidget 或 State 的成员变量,但也可即时声明:

Container(
  color: Colors.yellow,
).applyConstraint(
  id: cId('yellowArea'),
  size: 200,
  centerTo: parent,
),
Container(
  color: Colors.green,
).applyConstraint(
  size: 100,
  topLeftTo: cId('yellowArea'),
)
即时声明 id

这里的 id 都是绝对 id,与之对应的是相对 id:

  1. rId(3) 代表第三个子元素,以此类推
  2. rId(-1) 代表最后一个子元素
  3. rId(-2) 代表倒数第二个子元素,以此类推
  4. sId(-1) 代表上一个兄弟元素,以此类推
  5. sId(1) 代表下一个兄弟元素,以此类推

比如上例可以改造为:

Container(
  color: Colors.yellow,
).applyConstraint(
  size: 200,
  centerTo: parent,
),
Container(
  color: Colors.green,
).applyConstraint(
  size: 100,
  topLeftTo: rId(0),    // 引用第 0 个子元素
)

Container(
  color: Colors.yellow,
).applyConstraint(
  size: 200,
  centerTo: parent,
),
Container(
  color: Colors.green,
).applyConstraint(
  size: 100,
  topLeftTo: sId(-1),   //  引用上一个兄弟元素
)

相对 id 主要是为懒癌患者设计的,因为命名是个麻烦事。如果已经为子元素定义了绝对 id,则不能再使用相对 id 来引用他们。

包装约束

包装约束是为了简化使用而设计的,顾名思义它是对基本约束的包装,它在运行时会转化为基本约束,比如以下代码:

Container(
  color: Colors.yellow,
).applyConstraint(
  size: 200,
  topLeftTo: parent,
),

等价于:

Container(
  color: Colors.yellow,
).applyConstraint(
  size: 200,
  left: parent.left,
  top: parent.top,
),

再比如:

Container(
  color: Colors.yellow,
).applyConstraint(
  size: 200,
  centerTo: parent,
)

等价于:

Container(
  color: Colors.yellow,
).applyConstraint(
  size: 200,
  left: parent.left,
  top: parent.top,
  right: parent.right,
  bottom: parent.bottom,
)

一共有 27 个包装约束,分别是:

  1. topLeftTo
  2. topCenterTo
  3. topRightTo
  4. centerLeftTo
  5. centerTo
  6. centerRightTo
  7. bottomLeftTo
  8. bottomCenterTo
  9. bottomRightTo
  10. centerHorizontalTo
  11. centerVerticalTo
  12. outTopLeftTo
  13. outTopCenterTo
  14. outTopRightTo
  15. outCenterLeftTo
  16. outCenterRightTo
  17. outBottomLeftTo
  18. outBottomCenterTo
  19. outBottomRightTo
  20. centerTopLeftTo
  21. centerTopCenterTo
  22. centerTopRightTo
  23. centerCenterLeftTo
  24. centerCenterRightTo
  25. centerBottomLeftTo
  26. centerBottomCenterTo
  27. centerBottomRightTo

其中一部分是自解释的,另一部分可参考下图:

包装约束

或者进入 Flutter ConstraintLayout 在线示例(https://constraintlayout.flutterfirst.cn)去查看。

clickPadding

快速扩大子元素的点击区域而无需改变子元素的实际大小。这意味着你可以完全遵照视觉稿来布局,而不用为了考虑点击区域而做额外的事情,这会提升一定的开发效率。用法如下:

Container(
  color: Colors.red,
).applyConstraint(
  size: 200,
  centerTo: parent,
  clickPadding: const EdgeInsets.all(10), // 四周都扩大 10 dp,每个边都可分别扩大
)
扩大点击区域

深色区域为子元素的实际大小,浅色区域为扩大后的点击区域。浅色区域内的触摸事件会映射到深色区域。

这也意味着子元素之间可以在不增加嵌套的情况下共享点击区域,有时可能需要结合 e-index(后面会讲到) 使用。

可见性控制

visibility 属性用来控制子元素的可见性,可取值为 visible、invisible、gone。这个其实没什么可讲的,大家都懂。对于 gone 来讲,有时可能更好的办法是使用条件表达式来阻止 Widget 的创建。用 gone 的好处是可以保留状态(如果该 Widget 是 StatefulWidget 的话)。

有一点值得一提的是在 Flutter 里 RenderObject 任何时候都必须被 layout,但可以不 paint。因此 gone 的 Widget 也会被 layout,只不过它会缩小成一个点。依赖它的其他 Widget 的 goneMargin 会生效。gone 和 invisible 的 Widget 都不会被 paint。

margin

有三个属性来设置 margin,分别是 margin、goneMargin、percentageMargin。

margin 和 goneMargin 都可以为负数,使用方法为:

margin: const EdgeInsets.only(left: 10),

当依赖的元素的可见性为 gone 或者其某一边的实际大小为 0 时,goneMargin 就会生效,否则 margin 会生效,即便其自身的可见性为 gone。也就是说,当元素自身可见性为 gone 时,它自身的 margin 仍然会生效,因为它被 layout 了。这和 Android 是不同的。

percentageMargin 是为了支持 Guideline 而开发的一个内部功能,现把它暴露出来,兴许对你有用。其默认为 false,如果设置为 true,则 margin、goneMargin 的值只能在 [-1, 1] 的范围内。基准是 parent 的宽或高。

zIndex

搞过前端的应该都知道这个属性,它即视图层级,值越大子元素就越显示在上层。默认值是子元素的下标。一般来讲,越显示在上层就越先接收点击事件。

如果两个子元素的 zIndex 相同,则下标越大,越显示在上层。

translate

当需要对子元素进行平移时,除了可以使用 Flutter 自带的 Transform Widget,还可以使用约束布局内置的平移功能:

Container(
  color: Colors.yellow,
).applyConstraint(
  size: 200,
  centerTo: parent,
  id: anchor,
),
Container(
  color: Colors.green,
).applyConstraint(
  size: 100,
  topLeftTo: anchor,
  translate: const Offset(100, 100),
)
平移

默认情况下,平移只会移动自身,那些依赖自己的元素不会跟着被平移,如果也想让他们跟着移动,请将 translateConstraint 设置为 true。

和 margin 一样,平移也支持 percentageTranslate,但基准是自身的宽或高。

百分比布局

当大小被设置为 matchConstraint 时,就会启用百分比布局,默认的百分比是 1(100%)。相关的属性是 widthPercent,heightPercent,widthPercentageAnchor,heightPercentageAnchor。

PercentageAnchor 的取值有两种,parent 和 constraint(默认),当取值为前者时,代表以 parent 的宽或高为基准,因此只需要在对应方向添加一个约束即可。当取值为后者时,代表以约束的区间宽度为基准,因此需要在对应的方向添加两个约束(完整约束)。示例代码如下:

Container(
  color: Colors.redAccent,
  alignment: Alignment.center,
  child: const Text('width: 50% of parent\nheight: 200'),
).applyConstraint(
  width: matchConstraint,
  height: 200,
  widthPercent: 0.5,
  bottomCenterTo: parent,
),
Container(
  color: Colors.blue,
  alignment: Alignment.center,
  child: const Text('width: 300\nheight: 30% of parent'),
).applyConstraint(
  width: 300,
  height: matchConstraint,
  heightPercent: 0.3,
  topLeftTo: parent,
  heightPercentageAnchor: PercentageAnchor.parent,
)
百分比布局

偏移

当某一方向有两个约束时(完整约束),可使用 horizontalBias、verticalBias 调整偏移量,范围为 [0, 1],默认值为 0.5,代表居中。比如给上例中的红色区域添加 horizontalBias: 0 时,它就跑到最左边了。

偏移量

布局回调

如果你想做一些布局、绘制监听,那就使用 layoutCallback、paintCallback 吧,它们的定义如下:

typedef OnLayoutCallback = void Function(RenderBox renderBox, Rect rect);

typedef OnPaintCallback = void Function(RenderBox renderBox, Offset offset,
    Size size, Offset? anchorPoint, double? angle);

这两个回调是施加给局部的某个子元素的,而不是全局的 ConstraintLayout。

等比例布局

当需要等比例布局时,除了可以使用 Flutter 自带的 FractionallySizedBox,还可以使用约束布局提供的等比例布局功能。相关的属性是:

  1. widthHeightRatio: 1 / 3,
  2. ratioBaseOnWidth: true, (默认值是 null,代表自动推断,未确定边的大小会根据确定边的大小和 widthHeightRatio
    计算出来。未确定边的大小必须设置为 matchConstraint,确定边的大小可以为 matchParent,固定大小(>=0),matchConstraint)

示例如下:

Container(
  color: Colors.yellow,
).applyConstraint(
  width: 200,
  height: matchConstraint,
  widthHeightRatio: 2 / 1,
  centerTo: parent,
)
等比例布局

请不要把百分比布局和等比例布局搞混了,前者是宽高由外部决定,后者是宽高由自身决定。

eIndex

eIndex 是事件分发顺序,它的默认值是 zIndex。一般很少用到它。比如以下场景就需要用到它:

eIndex

图片中的 ListView item 布局追求了一层嵌套,白色圆角区域(一个 Container)和其他元素是平级的且位于最底层(zIndex 最小),但点击整个 item 需要跳转新页面。因此这里把点击事件设置到 Container,并让它的 eIndex 变大(比如赋值 1000)。这样就能在不增加嵌套的情况下整体响应事件了。

但这种追求一层嵌套的写法并不是在所有情况下都适用,比如按下 ListView item 时 item 内的背景、文本要变色,就必须得增加嵌套了。

栅栏(屏障)Barrier

搞过 Android 的都知道这个,它和 Android 中的栅栏完全一样。目的是为了在几个子元素之间生成一条虚拟的屏障,然后别的元素可以相对于这个屏障去布局,示例如下:

Container(
  color: const Color(0xFF005BBB),
).applyConstraint(
  id: leftChild,
  size: 200,
  topLeftTo: parent,
),
Container(
  color: const Color(0xFFFFD500),
).applyConstraint(
  id: rightChild,
  width: 200,
  height: matchConstraint,
  centerRightTo: parent,
  heightPercent: 0.5,
  verticalBias: 0,
),
Barrier(
  id: barrier,
  direction: BarrierDirection.bottom,   // 方向
  referencedIds: [leftChild, rightChild],   // 引用的子元素的 id,此处的 id 不能为相对 id
),
const Text(
  'Align to barrier',
  style: TextStyle(
    fontSize: 40,
    color: Colors.blue,
  ),
).applyConstraint(
  centerHorizontalTo: parent,
  top: barrier.bottom,
)
Barrier

引导线 Guideline

这个也和 Android 中的 Guideline 完全一样。示例如下:

Container(
  color: const Color(0xFF005BBB),
).applyConstraint(
  width: matchParent,
  height: matchConstraint,
  top: parent.top,
  bottom: guideline.top,
),
Guideline(
  id: guideline,
  horizontal: true, // 方向,true 为水平,false 为垂直
  guidelinePercent: 0.5,
),
Container(
  color: const Color(0xFFFFD500),
).applyConstraint(
  width: matchParent,
  height: matchConstraint,
  top: guideline.bottom,
  bottom: parent.bottom,
),
const Text(
  'Align to Guideline',
  style: TextStyle(
    fontSize: 40,
    color: Colors.white,
  ),
  textAlign: TextAlign.center,
).applyConstraint(
  centerHorizontalTo: parent,
  bottom: guideline.bottom,
)
Guideline

Guideline 有四个属性可以设置,分别是 horizontal、guidelinePercent、guidelineBegin、guidelineEnd。后三个属性都是相对于 parent 而言。

图钉定位

Flutter ConstraintLayout 提供了两套约束系统,一套是基本约束,一套是图钉定位(Pinned Position)。提供图钉定位主要是为了让布局更灵活一些。设想一下,要想定位一个元素,除了给它指定横向、纵向对齐到哪里以外,我认为还有一种办法是让它的哪个位置钉在哪里。把一个东西钉在哪里,从逻辑上来讲会产生两个孔,一个孔穿过元素自身,一个孔穿过目标位置。因此图钉定位的 API 主要是用来描述两个孔的位置,示例如下:

Container(
  color: Colors.yellow,
).applyConstraint(
  id: anchor,
  size: 200,
  centerTo: parent,
),
Container(
  color: Colors.cyan,
).applyConstraint(
  size: 100,
  pinnedInfo: PinnedInfo(
    anchor,
    Anchor(0.2, AnchorType.percent, 0.2, AnchorType.percent),
    Anchor(1, AnchorType.percent, 1, AnchorType.percent),
  ),
),
Container(
  decoration: const BoxDecoration(
    color: Colors.red,
    borderRadius: BorderRadius.all(Radius.circular(10)),
  ),
).applyConstraint(
  size: 10,
  centerBottomRightTo: anchor,
)
图钉定位

例子中的意思是青色区域横竖 20% 的点钉在黄色区域的右下角,红点即为孔的位置。PinnedInfo 类完整的构造函数如下:

PinnedInfo(
  this.targetId,
  this.selfAnchor,
  this.targetAnchor, {
  this.angle = 0.0,
});

targetId 和 targetAncnor 描述了目标孔的位置,selfAnchor 描述了自身孔的位置。一个物体被图钉钉住后,它就有了个转轴,就能旋转起来,因此 angle 代表旋转的角度,范围为 [0.0, 360.0]。

旋转

基本约束和图钉定位两套约束系统是互斥的,只能用其一。如果你使用基本约束时也想让元素转起来,可以使用 Pinned Translate:

translate: PinnedTranslate(PinnedInfo(
  null,
  Anchor(0.2, AnchorType.percent, 0.2, AnchorType.percent),
  null,
  angle: 90,
))

如果不设置目标孔的位置,则相对于自身孔的位置旋转。

随意定位(Arbitrary Position)

尽管 ConstraintLayout 的布局能力已经很灵活了,但我还想更进一步,让你能够自定义!因此我暴露了布局的接口给你:

typedef CalcSizeCallback = BoxConstraints Function(
    RenderBox parent, List<ConstrainedNode> anchors);

typedef CalcOffsetCallback = Offset Function(
    RenderBox parent, ConstrainedNode self, List<ConstrainedNode> anchors);

使用方法如下:

Container(
  color: Colors.orange,
).applyConstraint(
  size: matchConstraint, // 宽高必须设置为 matchConstraint
  anchors: [sId(-1)], // 依赖的元素,只有依赖的元素都布局好了,才会调用 callback
  calcSizeCallback: (parent, anchors) {
    // 动态返回子元素的大小
  },
  calcOffsetCallback: (parent, self, anchors) {
    // 动态返回子元素的 Offset
  },
)

具体可参考在线示例。基于此,你几乎可以为所欲为。

约束与 Widget 分离

约束和布局其实是可以分离的,这个特性借鉴了 Compose 的约束布局。

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: ConstraintLayout(
        childConstraints: [
          Constraint(
            id: cId('title'),
            size: 200,
            centerTo: parent,
          ),
        ],
        children: [
          Container(
            color: Colors.red,
          ).applyConstraintId(
            id: cId('title'),
          ),
        ],
      ),
    );
  }

分离后,你就可以动态地改变一个子元素的约束了。此外,可以在 childConstraints 中声明 Helper Widgets(Guideline、Barrier),这样可以避免创建 RenderObject。具体请看下文中的性能优化部分。

Flutter ConstraintLayout 提供了两个 ParentDataWidget,分别是 Constrained 和 UnConstrained。applyConstraint 是对 Constrained 的包装,applyConstraintId 是对 UnConstrained 的包装。前者声明完整的约束信息,后者只声明子元素和约束的对应关系。

布局调试

Flutter ConstraintLayout 提供了布局调试的功能,提供了以下的调试开关:

  1. showHelperWidgets:辅助 Widget 包含 Guideline 和 Barrier,默认情况下它们是不可见的,可开启此开关让它们可见
  2. showClickArea:当使用 clickPadding 扩大点击区域时,可开启此开关查看实际的点击区域
  3. showZIndex:可开启此开关查看各子元素的 z-Index
  4. showChildDepth:在后面的原理分析文章中再作介绍
  5. debugPrintConstraints:在后面的原理分析文章中再作介绍
  6. showLayoutPerformanceOverlay:开启此开关后,会将每一帧的 layout、paint 耗时绘制出来,单位为微秒(us,1ms 等于 1000us)

自身大小设置

Flutter ConstraintLayout 默认会撑满父布局,但你也可以自定义它的大小,我提供了 width、height、size 三个属性来设置约束布局自身的大小:

// fixed size、matchParent(默认值)、wrapContent
final double width;
final double height;

/// When size is non-null, both width and height are set to size
final double? size;

开放式语法(Open Grammar)

开放式语法是一个比较大的创新,有了它你可以使用任何 Dart 的语法来组织子元素,而不仅仅局限于集合中的 if集合中的 for这种简单表达式。示例如下:

@override
Widget build(BuildContext context) {
  return Scaffold(
    body: ConstraintLayout().open(() {
      int i = 0;
      while (i < 100) {
        Text("$i").applyConstraint(
          left: parent.left,
          top: i == 0 ? parent.top : sId(-1).bottom,
        );
        i++;
      }
    }),
  );

你需要调用 open 扩展函数,就可以在函数的作用域中使用任何语法组织子元素。上面的几行简单的代码就构造了一个列表:

开放式语法

约束提示

当前的版本有完善的约束缺失、非法、冗余提示。一旦约束有问题,要么会触发 assert 错误,要么会直接抛出异常。由于 Flutter 中异常并不会导致程序崩溃,因此即便抛出异常后也无法中断后续的布局、绘制流程,而在这个阶段会触发更多的异常。因此当你发现布局展示不符合预期时,大概率是内部抛异常了或 assert 出错了。你要去看看异常日志,一般翻到最顶部才能看到根本原因。

瀑布流、网格、列表

Flutter ConstraintLayout 可以当成一个比较通用的布局平台,你只需要生成约束,把布局、绘制交给它就好。我把这个称作扩展。瀑布流、网格、列表就是以扩展的形式提供的,具体请参考在线示例吧。比如上例中的列表就是个扩展,它生成约束充当了 Column 的能力。

圆形定位

和 Android 的约束布局一样,Flutter ConstraintLayout 也提供了圆形定位。但两者的实现方式截然不同,后者并没有做特殊的支持,只是增加了一个 Util 函数:

/// For circle position
Offset circleTranslate({
  required double radius,

  /// [0.0,360.0]
  required double angle,
}) {
  assert(radius >= 0 && radius != double.infinity);
  double xTranslate = sin((angle / 180) * pi) * radius;
  double yTranslate = -cos((angle / 180) * pi) * radius;
  return Offset(xTranslate, yTranslate);
}

并配合包装约束 centerTo 一起使用,示例如下:

for (int i = 0; i < 12; i++)
  Text(
    '${i + 1}',
    style: const TextStyle(
      fontWeight: FontWeight.bold,
      fontSize: 25,
    ),
  ).applyConstraint(
    centerTo: rId(0),
    translate: circleTranslate(
      radius: 205,
      angle: (i + 1) * 30,
    ),
  )
圆形定位

性能优化

1.当布局复杂时,如果子元素需要频繁重绘,可以考虑使用 RepaintBoundary。当然合成 Layer 也有开销,所以需要合理使用。

class OffPaintExample extends StatelessWidget {
  const OffPaintExample({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: ConstraintLayout(
          children: [
            Container(
              color: Colors.orangeAccent,
            ).offPaint().applyConstraint(
              width: 200,
              height: 200,
              topRightTo: parent,
            )
          ],
        ),
      ),
    );
  }
}

此处的 offPaint 扩展方法是对 RepaintBoundary 的简单封装。

2.尽量使用 const Widget。如果你没法将子元素声明为 const 而它自身又不会改变。可以使用内置的 OffBuildWidget 来避免子元素重复 build。

class OffBuildExample extends StatelessWidget {
  const OffBuildExample({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: ConstraintLayout(
          children: [
            // 子树不会改变
            Container(
              color: Colors.orangeAccent,
            ).offBuild(id: 'id').applyConstraint(
              width: 200,
              height: 200,
              topRightTo: parent,
            )
          ],
        ),
      ),
    );
  }
}

3.子元素会自动成为 RelayoutBoundary 除非它的宽或高是 wrapContent。可以酌情的减少 wrapContent 的使用,因为当 ConstraintLayout
自身的大小发生变化时(通常是窗口大小发生变化,移动端几乎不存在此类情况),所有宽或高为 wrapContent
的子元素都会被重新布局。而其他元素由于传递给它们的约束未发生变化,不会触发真正的布局。

4.如果你在 children 列表中使用 Guideline 或 Barrier, Element 和 RenderObject 将不可避免的被创建,它们会被布局但不会绘制。此时你可以使用
GuidelineDefine 或 BarrierDefine 来优化, Element 和 RenderObject 就不会再创建了。

class BarrierExample extends StatelessWidget {
  const BarrierExample({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    ConstraintId leftChild = ConstraintId('leftChild');
    ConstraintId rightChild = ConstraintId('rightChild');
    ConstraintId barrier = ConstraintId('barrier');
    return Scaffold(
      body: ConstraintLayout(
        childConstraints: [
          BarrierDefine(
            id: barrier,
            direction: BarrierDirection.bottom,
            referencedIds: [leftChild, rightChild],
          ),
        ],
        children: [
          Container(
            color: const Color(0xFF005BBB),
          ).applyConstraint(
            id: leftChild,
            width: 200,
            height: 200,
            topLeftTo: parent,
          ),
          Container(
            color: const Color(0xFFFFD500),
          ).applyConstraint(
            id: rightChild,
            width: 200,
            height: matchConstraint,
            centerRightTo: parent,
            heightPercent: 0.5,
            verticalBias: 0,
          ),
          const Text(
            'Align to barrier',
            style: TextStyle(
              fontSize: 40,
              color: Colors.blue,
            ),
          ).applyConstraint(
            centerHorizontalTo: parent,
            top: barrier.bottom,
          )
        ],
      ),
    );
  }
}

5.每一帧,ConstraintLayout 会比对参数并决定以下事情:

  1. 是否需要重新计算约束?
  2. 是否需要重新布局?
  3. 是否需要重新绘制?
  4. 是否需要重排绘制顺序?
  5. 是否需要重排事件分发顺序?

这些比对不会成为性能瓶颈,但会提高 CPU 占用率。如果你对 ConstraintLayout 内部原理足够了解(后面会写一篇原理分析的文章),你可以使用 ConstraintLayoutController 来手动触发这些操作,停止参数比对。

class ConstraintControllerExampleState extends State<ConstraintControllerExample> {
  double x = 0;
  double y = 0;
  ConstraintLayoutController controller = ConstraintLayoutController();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: const CustomAppBar(
        title: 'Constraint Controller',
        codePath: 'example/constraint_controller.dart',
      ),
      body: ConstraintLayout(
        controller: controller,
        children: [
          GestureDetector(
            child: Container(
              color: Colors.pink,
              alignment: Alignment.center,
              child: const Text('box draggable'),
            ),
            onPanUpdate: (details) {
              setState(() {
                x += details.delta.dx;
                y += details.delta.dy;
                controller.markNeedsPaint();
              });
            },
          ).applyConstraint(
            size: 200,
            centerTo: parent,
            translate: Offset(x, y),
          )
        ],
      ),
    );
  }
}

结束语

好了,以上就是 Flutter ConstraintLayout(约束布局)的所有功能介绍,赶紧收藏起来吧。

https://github.com/hackware1993/Flutter_ConstraintLayout

我是 hackware,关注我的公众号:FlutterFirst,一起成长!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,362评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,330评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,247评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,560评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,580评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,569评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,929评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,587评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,840评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,596评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,678评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,366评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,945评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,929评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,165评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,271评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,403评论 2 342

推荐阅读更多精彩内容