DEV Community

K M Shahriar Hossain
K M Shahriar Hossain

Posted on

Align only hugs its child if you ask it to

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,
));
Enter fullscreen mode Exit fullscreen mode

There are exactly two ways to get shrink-wrapping, and you need one of them:

  1. widthFactor is not null, or
  2. the incoming maxWidth is 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'),
)
Enter fullscreen mode Exit fullscreen mode

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')))}');
});
Enter fullscreen mode Exit fullscreen mode

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)