A Wrap full of chips, each one an Align around a label, and every chip comes
out the full width of the line instead of hugging its text.
It is not a bug, and the fix is one property. The reason is worth knowing
though, because the same rule catches Center, Container(alignment:), and
anything else backed by RenderPositionedBox.
The measurement
Same Text, same Wrap, one property different:
| widget | measured size |
|---|---|
Align(child: Text('hi')) |
Size(800.0, 14.0) |
Align(widthFactor: 1.0, child: Text('hi')) |
Size(28.0, 14.0) |
800 against 28, for a two-character string.
Why it happens
From RenderPositionedBox.performLayout, in shifted_box.dart:
final bool shrinkWrapWidth =
_widthFactor != null || constraints.maxWidth == double.infinity;
// ...
size = constraints.constrain(Size(
shrinkWrapWidth ? child!.size.width * (_widthFactor ?? 1.0) : double.infinity,
shrinkWrapHeight ? child!.size.height * (_heightFactor ?? 1.0) : double.infinity,
));
There are exactly two ways to get shrink-wrapping, and you need one of them:
-
widthFactoris not null, or - the incoming
maxWidthis infinite.
Wrap hands its children loose but bounded constraints: minWidth is 0,
maxWidth is the line limit. Loose is not the same as unbounded. So neither
condition holds, double.infinity is what gets passed to constrain(), and
infinity clamps to maxWidth — the whole line.
The intuition that trips this up is reading loose constraints as "take only
what you need". They mean "you are allowed to be as small as zero". They say
nothing about what a box does with the room it is offered, and Align takes
all of it.
The fix
Align(
widthFactor: 1.0,
alignment: Alignment.centerLeft,
child: Text('hi'),
)
widthFactor: 1.0 reads as "size to the child, times one". Any non-null value
flips shrinkWrapWidth, so 1.0 is simply the identity case.
Why not IntrinsicWidth
IntrinsicWidth also works, and it is the answer you will usually be given. It
costs more. It measures the subtree to find its intrinsic width before laying
it out, which the framework's own documentation calls relatively expensive, and
that cost is paid on every layout. widthFactor reads child.size after a
layout that was going to happen anyway.
IntrinsicWidth earns its keep when siblings have to agree on a width. For one
box hugging one child, widthFactor is the cheaper tool.
Reproduce it
testWidgets('Align in Wrap', (tester) async {
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Wrap(
children: [
Align(key: const Key('t'), child: const Text('hi')),
],
),
),
);
debugPrint('${tester.getSize(find.byKey(const Key('t')))}');
});
Size(800.0, 14.0). Add widthFactor: 1.0 and it becomes Size(28.0, 14.0).
The 800 is the default test surface width, so the number you see is whatever
width the parent was offering — which is the point.
Related: LayoutBuilder Does Not Support Returning Intrinsic Dimensions
— the loud version of the same family. There a widget that measures its own box
throws inside IntrinsicHeight and Table, instead of quietly taking more room
than it needed.
Also: CanvasKit Layout Traps — the same family at its worst,
where the axis really is unbounded, the throw only happens in a release build,
and the page renders nothing at all.
Top comments (0)