You put auto-sizing text in a Table cell, hot-reloaded, and got this:
LayoutBuilder does not support returning intrinsic dimensions.
Calculating the intrinsic dimensions would require running the layout
callback speculatively, which might mutate the live render object tree.
at _RenderLayoutBuilder.computeMaxIntrinsicHeight
The stack trace is long, it points at framework code you did not write, and the
widget worked perfectly ten minutes ago in a Column. So the obvious first
thought is that something is misconfigured.
Nothing is misconfigured. This is Flutter refusing to do something it genuinely
cannot do, and understanding why tells you exactly which layouts will break and
which will not — which is more useful than a workaround, because the same wall
is waiting behind three or four different widgets.
What an intrinsic dimension actually is
Most Flutter layout is a single downward pass. A parent hands its child a set of
constraints — you may be between 0 and 300 logical pixels wide — the child
picks a size within them, and the parent positions it. One pass, no
backtracking, which is why Flutter's layout is linear in the number of widgets
rather than quadratic.
Some widgets cannot work that way. IntrinsicHeight has to make all its
children the height of the tallest one, so it has to know how tall each wants
to be before it can decide what to hand them. Table sizes a column to fit its
widest cell by default. Baseline alignment needs to know where text sits before
it can align on it.
For those, Flutter has a second protocol. A parent can ask a child a hypothetical
question:
If I gave you 200 pixels of width, how tall would you want to be?
That question is computeMaxIntrinsicHeight, and the crucial part is the word
hypothetical. Nothing is being laid out. The child is being asked to predict a
size for constraints it has not been given and may never be given. The answer
must be computed without touching the render tree, because the tree is in the
middle of a layout pass and nothing may move yet.
Why LayoutBuilder cannot answer it
LayoutBuilder exists to run your code with the constraints as an argument:
LayoutBuilder(
builder: (context, constraints) {
final size = _bestFontSizeFor(constraints.maxWidth);
return Text(label, style: TextStyle(fontSize: size));
},
)
This is how essentially every auto-sizing text package works, and it is a
perfectly good idea. It measures the text at a candidate size, checks whether it
fits the constraints it was handed, and adjusts.
Now look at what answering an intrinsic query would require. To tell the parent
how tall it would be at 200 pixels wide, LayoutBuilder would have to run your
builder callback with maxWidth: 200. Your callback constructs widgets. Those
widgets get mounted into the element tree. That is a mutation — during a
speculative query, for a layout that may never happen, possibly several times
with different widths as the parent searches for a fit.
Flutter's own comment on the matter is blunt about it: doing so "might mutate
the live render object tree". So it refuses, loudly, rather than corrupting your
layout quietly. The error is the framework protecting you.
This is why it is not going to be fixed. It is not an oversight, it is the
boundary between two layout protocols that cannot both be satisfied by the same
widget.
The two-pass problem, and why Flutter avoids it
There is a deeper reason this protocol exists at all, and knowing it makes the
constraint feel less arbitrary.
Web layout is famously able to do this sort of thing — a CSS table sizes its
columns to content without anyone thinking about it. It manages that by doing
multiple passes over the tree, and by having a layout engine that can revisit
decisions.
Flutter deliberately does not. Its layout is single-pass and linear:
constraints go down, sizes come back up, each render object is visited once.
That is a large part of why Flutter can hit 60 or 120fps on a deep tree — there
is no risk of a pathological case where a small change triggers repeated
re-layout of a large subtree.
The intrinsic protocol is the pressure valve. It lets a parent ask a question
without triggering a real layout pass, at the cost of the query being a pure
computation with no side effects. Widgets that use it are explicitly documented
as more expensive, and IntrinsicHeight's own API docs warn that it can be
O(N²) in the depth of the tree.
So when Flutter refuses to let LayoutBuilder participate, it is protecting the
guarantee that makes the whole layout system fast. Allowing it would mean
speculative builder invocations, which is a second pass through user code — the
exact thing the architecture is designed to avoid.
Where this bites
The failure only appears when something above your widget asks an intrinsic
question. That makes it feel random — the same widget works in one screen and
throws in another. It is not random at all. These are the layouts that ask:
| Widget | Why it asks |
|---|---|
IntrinsicHeight |
Matches children to the tallest one |
IntrinsicWidth |
Matches children to the widest one |
Table |
Columns size to their widest cell by default |
Baseline-aligned Row
|
Needs the text baseline before aligning |
ListView with shrinkWrap in some nestings |
Sizes to content |
Table is the one that catches people, because nothing about writing
Table(children: [...]) suggests you have just opted into a second layout
protocol. You get a table. You put a heading in a cell. It explodes.
How to tell before you hit it
You do not need to memorise the table. There is a quicker heuristic: if a
widget needs to know its children's sizes before deciding what constraints to
give them, it will use intrinsics.
Column does not — it hands down its own constraints and takes whatever comes
back. IntrinsicHeight does, by definition. Table does, because a column width
is a function of every cell in it.
A second signal is the error itself. If a stack trace mentions
computeMinIntrinsicWidth, computeMaxIntrinsicWidth,
computeMinIntrinsicHeight or computeMaxIntrinsicHeight, you are in the
intrinsic protocol regardless of which widget triggered it.
The fix: do the fitting in a RenderBox
The way out is to stop asking the widget layer to measure things and drop to the
layer that is allowed to. A RenderBox can measure text with a TextPainter
whenever it likes, because a TextPainter is not part of the render tree — it is
a standalone object that lays out a paragraph and reports its size. No mutation,
no speculative element mounting.
That means the fitting can happen in two places that both work:
- during
performLayout, for the real pass - during
computeMaxIntrinsicHeight, for the hypothetical question — because measuring with aTextPainteris exactly the kind of pure computation the intrinsic protocol requires
The binary search for the largest font size that fits is the same algorithm the
LayoutBuilder approach uses. The difference is entirely about where it runs.
This is what I built fit_text to do:
FitText('A headline that must never wrap', maxLines: 1, minFontSize: 12)
It works inside Table, IntrinsicHeight, IntrinsicWidth, baseline rows,
Expanded, and unbounded constraints. The package's test suite contains a case
that puts a LayoutBuilder-based sizer and a FitText in the same widget tree
and asserts that the first throws while the second lays out — so the difference
is pinned by a test rather than claimed in a README.
It has no dependencies, and it runs on all six platforms.
Why TextPainter is allowed and LayoutBuilder is not
The distinction is worth stating precisely, because it is the whole trick.
TextPainter is a standalone object. Constructing one and calling layout()
on it does not touch the render tree, does not mount elements, does not schedule
a frame, and has no effect on anything outside the object itself. It is a pure
function from (text, style, constraints) to a size, with some caching inside.
LayoutBuilder is the opposite. Its entire purpose is to run a callback that
builds widgets, and building widgets means creating elements and mounting them
into the tree. That is a side effect, and side effects are exactly what an
intrinsic query is forbidden to have.
So the rule generalises beyond text. If you can compute your intrinsic size
with a pure function, you can implement the intrinsic protocol. If computing
it requires building widgets, you cannot, and no amount of cleverness at the
widget layer will change that. Drop to a render object and compute directly.
If you would rather not add a package
You do not have to. The approach is the point, and it is reproducible:
- Subclass
RenderBoxrather than composing widgets. - Keep a
TextPainteras a field. Calllayout()on it with candidate styles. - Binary search font size between your minimum and maximum, checking
didExceedMaxLinesand the resultingsizeagainst the constraints. - Implement
computeMinIntrinsicWidth,computeMaxIntrinsicWidth,computeMinIntrinsicHeightandcomputeMaxIntrinsicHeightusing the same measurement, and do not touch the tree in any of them. - Cache by (text, constraints, style) — intrinsic queries are called more often than you expect, sometimes several times per frame.
Point five is the one people miss. Table may ask each cell more than once while
resolving column widths, and a binary search over twenty font sizes per query
adds up quickly in a list.
The subtleties that bite in a custom implementation
If you do write your own, these are the ones that cost an evening each:
-
didExceedMaxLinesis not enough on its own. Text can fit withinmaxLinesand still be wider than the constraint if a single word cannot break. Check the paintedsizeagainst the constraints as well as the line count, or a long unbroken token will silently overflow at your minimum size. -
textScalermust be part of your cache key. A user changing their system font size will not invalidate a cache keyed only on the string and the box. -
TextDirectionis required and easy to forget, since it usually comes from the ambientDirectionality. In a render object you have to thread it through yourself. - Round your candidate sizes. A binary search over continuous doubles will happily spend iterations distinguishing 14.001 from 14.002. Search in whole or half points and stop.
-
Respect the minimum. When even
minFontSizedoes not fit, decide deliberately whether to overflow, clip or ellipsise — and make it a parameter, because different call sites genuinely want different answers.
The short version
LayoutBuilder cannot answer intrinsic queries because answering them means
running your builder, and running your builder means mutating the tree during a
speculative measurement. Any auto-sizing built on LayoutBuilder inherits that
limit. Move the measurement into a RenderBox with a TextPainter and the
limitation disappears, because measuring a paragraph was never the thing that
needed the tree.
If you want it already done: fit_text on pub.dev, MIT
licensed, no dependencies.
Originally published at devshakib.jumyn.com. I write about Flutter, Dart and the parts of shipping that are genuinely awkward — and publish the packages that came out of them at pub.dev/publishers/jumyn.com.
Top comments (0)