我会用三篇文章来讲透 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)。所有的基本约束如下:
- left
- toLeft
- toCenter(默认偏移量为 0.5,代表中心)
- toRight
- right
- toLeft
- toCenter(默认偏移量为 0.5,代表中心)
- toRight
- top
- toTop
- toCenter(默认偏移量为 0.5,代表中心)
- toBottom
- bottom
- toTop
- toCenter(默认偏移量为 0.5,代表中心)
- toBottom
- baseline
- toTop
- toCenter(默认偏移量为 0.5,代表中心)
- toBaseline
- 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,
)
如果你把左右或上下都约束到同一个位置,那子元素就会相对于这个位置居中。
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。它们可以取值为:
- matchParent: 撑满父布局
- wrapContent(默认值): 自己有多大就撑多大,可设置最小或最大大小
- matchConstraint: 大小由约束决定
- 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:
- rId(3) 代表第三个子元素,以此类推
- rId(-1) 代表最后一个子元素
- rId(-2) 代表倒数第二个子元素,以此类推
- sId(-1) 代表上一个兄弟元素,以此类推
- 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 个包装约束,分别是:
- topLeftTo
- topCenterTo
- topRightTo
- centerLeftTo
- centerTo
- centerRightTo
- bottomLeftTo
- bottomCenterTo
- bottomRightTo
- centerHorizontalTo
- centerVerticalTo
- outTopLeftTo
- outTopCenterTo
- outTopRightTo
- outCenterLeftTo
- outCenterRightTo
- outBottomLeftTo
- outBottomCenterTo
- outBottomRightTo
- centerTopLeftTo
- centerTopCenterTo
- centerTopRightTo
- centerCenterLeftTo
- centerCenterRightTo
- centerBottomLeftTo
- centerBottomCenterTo
- 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,还可以使用约束布局提供的等比例布局功能。相关的属性是:
- widthHeightRatio: 1 / 3,
- 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。一般很少用到它。比如以下场景就需要用到它:
图片中的 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,
)
引导线 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 有四个属性可以设置,分别是 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 提供了布局调试的功能,提供了以下的调试开关:
- showHelperWidgets:辅助 Widget 包含 Guideline 和 Barrier,默认情况下它们是不可见的,可开启此开关让它们可见
- showClickArea:当使用 clickPadding 扩大点击区域时,可开启此开关查看实际的点击区域
- showZIndex:可开启此开关查看各子元素的 z-Index
- showChildDepth:在后面的原理分析文章中再作介绍
- debugPrintConstraints:在后面的原理分析文章中再作介绍
- 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 会比对参数并决定以下事情:
- 是否需要重新计算约束?
- 是否需要重新布局?
- 是否需要重新绘制?
- 是否需要重排绘制顺序?
- 是否需要重排事件分发顺序?
这些比对不会成为性能瓶颈,但会提高 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,一起成长!