我们Flutter开发经常会碰到的一个问题:如何自适应Row中widget的高度?例如我们需要找出这些children里面最高的那个,其他child的高度都设置成这个最高child高度相同或者某个百分比。 本文将会列出几种常见的方法,并分析他们的优缺点。
比较常用的方法有:
方法1:将Row中的Item设置成固定高度
比如下方代码:
dart
Row(
children: [
SizedBox(
height: 100,
child: Center(
child: Text("data1"),
),
),
SizedBox(
height: 100,
child: Center(
child: Text("data2"),
),
)
],
);
这种方法一般用于我们知道Row中item的高度是多少,例如上面的例子,我们将item的高度都设置成100。
但大部分情况下,我们是不知道Row中item的高度的,希望它能够自适应。于是就有了
方法2:通过IntrinsicHeight包裹Row的方式,计算出Row的最小高度。
比如下方代码:
dart
IntrinsicHeight(
child:Row(
children: [
SizedBox(
child: Center(
child: Text("data1"),
),
),
SizedBox(
height: 100,
child: Center(
child: Text("data2"),
),
)
],
)
);
第一个SizedBox没有设置它的高度,但因为是通过IntrinsicHeight包裹的,第一个SizedBox的高度会强行变成100.
貌似"方法2"就能解决我们大部分问题了,而且通过搜索引擎我们得到的大部分解决方法也都是这个。但IntrinsicHeight并不适用于所有的场景,特别是Row中的children通过LayoutBuilder包裹的情况。
我们再看看下面的代码:
dart
IntrinsicHeight(
child:Row(
children: [
LayoutBuilder(builder:(context,cons){
return SizedBox(
child: Center(
child: Text("data1"),
),
);
}),
SizedBox(
height: 100,
child: Center(
child: Text("data2"),
),
)
],
)
);
通过测试,你会发现,上面的代码会抱错: "LayoutBuilder does not support returning intrinsic dimensions."
在研究 IntrinsicHeight源码后,会发现IntrinsicHeight之所以能够实现它child以最小高度展示,是因为IntrinsicHeight 通过重写 computeMinIntrinsicHeight和 computeMaxIntrinsicHeight方法获取child的最小高度。 而这两个方法是通过轮询自己的子child 的 computeMinIntrinsicHeight和computeMaxIntrinsicHeight进行计算的,而我们再看看LayoutBuilder的相关代码:
dart
class _RenderLayoutBuilder extends RenderBox
with RenderObjectWithChildMixin<RenderBox>,
RenderConstrainedLayoutBuilder<BoxConstraints, RenderBox> {
....
@override
double computeMinIntrinsicWidth(double height) {
assert(_debugThrowIfNotCheckingIntrinsics());
return 0.0;
}
@override
double computeMaxIntrinsicWidth(double height) {
assert(_debugThrowIfNotCheckingIntrinsics());
return 0.0;
}
@override
double computeMinIntrinsicHeight(double width) {
assert(_debugThrowIfNotCheckingIntrinsics());
return 0.0;
}
@override
double computeMaxIntrinsicHeight(double width) {
assert(_debugThrowIfNotCheckingIntrinsics());
return 0.0;
}
....
}
bool _debugThrowIfNotCheckingIntrinsics() {
assert(() {
if (!RenderObject.debugCheckingIntrinsics) {
throw FlutterError(
'LayoutBuilder does not support returning intrinsic dimensions.\n'
'Calculating the intrinsic dimensions would require running the layout '
'callback speculatively, which might mutate the live render object tree.',
);
}
return true;
}());
return true;
}
从上方代码可以看出,LayoutBuilder对compute这一系列的方法都做了限制,这些方法都不允许在LayoutBuilder存在的Widget树中使用。也就是说通过IntrinsicHeight包裹的widget都不允许存在LayoutBuilder,不管它是在wiget树中的第几层。
在日常开中LayoutBuilder是经常用到的组件,它让我们可以通过上级传递的constraints进行动态布局。
那么如果我们在Row中用到了LayoutBuilder就没有办法实现"Row中的Item高度一致"吗? 现在可以想到的方式就是通过重写Row组件,在计算出Row中所有Item的高度,取其中最高的,再进行一次布局。
方法3:重写Row组件,实现统一高度
dart
typedef _NextChild = RenderBox? Function(RenderBox child);
class MRow extends Flex {
const MRow({
super.key,
super.mainAxisAlignment,
super.mainAxisSize,
super.textDirection,
super.verticalDirection,
super.textBaseline,
super.clipBehavior,
super.children,
}) : super(
direction: Axis.horizontal,
crossAxisAlignment: CrossAxisAlignment.start);
@override
RenderFlex createRenderObject(BuildContext context) {
return MRowFlex(
direction: direction,
mainAxisAlignment: mainAxisAlignment,
mainAxisSize: mainAxisSize,
crossAxisAlignment: crossAxisAlignment,
textDirection: getEffectiveTextDirection(context),
verticalDirection: verticalDirection,
textBaseline: textBaseline,
clipBehavior: clipBehavior,
);
}
}
class MRowFlex extends RenderFlex {
MRowFlex({
List<RenderBox>? children,
super.direction,
super.mainAxisSize,
super.mainAxisAlignment,
super.crossAxisAlignment,
super.textDirection,
super.verticalDirection,
super.textBaseline,
super.clipBehavior,
});
bool get _flipMainAxis =>
firstChild != null &&
switch (direction) {
Axis.horizontal => switch (textDirection) {
null || TextDirection.ltr => false,
TextDirection.rtl => true,
},
Axis.vertical => switch (verticalDirection) {
VerticalDirection.down => false,
VerticalDirection.up => true,
},
};
@override
void performLayout() {
//先预算一遍所有children的边界值
super.performLayout();
//便利所有children,找到高度最高的值
final (_NextChild nextChild, RenderBox? topLeftChild) =
_flipMainAxis ? (childBefore, lastChild) : (childAfter, firstChild);
double maxHeightSize = 0;
for (RenderBox? child = firstChild;
child != null;
child = nextChild(child)) {
maxHeightSize = max(maxHeightSize, child.size.height);
}
//将所有children的高度设置成最高值
for (RenderBox? child = firstChild;
child != null;
child = nextChild(child)) {
var constraints = child.constraints
.copyWith(maxHeight: maxHeightSize, minHeight: maxHeightSize);
child.layout(constraints, parentUsesSize: false);
}
}
}
上面代码实际上是对Row进行了重写,通过重写performLayout将所有孩子节点的高度重置成最高的高度。但这种写法有个弊端,就是每个孩子节点都会被layout两次。如果孩子节点里有LayoutBuilder则会发现Row每次build都会触发两次LayoutBuilder的回调函数。而且这两次触发传入的constraints值是不同的,如果用到此属性则可能会对实际业务造成影响,而且在性能上也不是最优。 实际上可以将Row重写的更彻底一点,例如单独设计一个控件放在Row里面,让Row里的其它控件高度都以这个控件高度为准,这样我们只需要找到Row里面的这个特殊控件,也避免了laylout两次的问题。限于篇幅和难度这里就不展开了。也可以看看"方法5"实现原理和这个类似。
方法4:用Table代替Row
less
TableRow(children: [
TableCell(
//高度灵活适配
verticalAlignment: TableCellVerticalAlignment.intrinsicHeight,
child: LayoutBuilder(builder: (context, cons) {
print("LayoutBuilder layout ${item["title"]} $cons");
return Container(
width: 10,
color: Colors.red,
);
})),
TableCell(
child: Container(
margin: const EdgeInsets.all(8),
padding: const EdgeInsets.all(8),
decoration: const BoxDecoration(
color: Colors.grey,
borderRadius: BorderRadius.all(Radius.circular(8))),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item["title"] ?? ""),
const SizedBox(
height: 8,
),
Row(
children: [Expanded(child: Text(item["info"] ?? ""))],
),
],
))),
])
Table虽然在灵活性上没有Row强,性能也没有Row高,代码量也会比Row多不少,但是用来解决高度适配问题却很不错,关键点就在于TableCell里有个参数 verticalAlignment: TableCellVerticalAlignment.intrinsicHeight,通过它我们可以将这个TableCell和同级别的TableCell适配成统一高度。
方法5:第三方插件
这里推荐一个第三方控件boxy,pub.dev/packages/bo...
它不仅能够适配横向的还可以适配纵向的,还有很多其它很实用的功能,具体如何使用这里就不展示了。从原理上看是将Row重写了,可以看到很多Row的老代码。走的也是"方法三"的路子,但可以看到源码里用到了computeMaxIntrinsicHeight去计算控件的高度,大概率子控件里也无法使用LayoutBuilder了。