Why auto sizing text throws this in a Table cell or IntrinsicHeight — a framework limit rather than a bug — and how to size text without LayoutBuilder.
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 layoutcallback 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.
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.
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.
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.
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.
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 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:
performLayout, for the real passcomputeMaxIntrinsicHeight, for the hypothetical question — because measuring with a TextPainter is 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.
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.
You do not have to. The approach is the point, and it is reproducible:
RenderBox rather than composing widgets.TextPainter as a field. Call layout() on it with candidate styles. didExceedMaxLines and the resulting size against the constraints.
computeMinIntrinsicWidth, computeMaxIntrinsicWidth, computeMinIntrinsicHeight and computeMaxIntrinsicHeight using the same
measurement, and do not touch the tree in any of them.
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.
If you do write your own, these are the ones that cost an evening each:
didExceedMaxLines is not enough on its own. Text can fit within maxLines and still be wider than the constraint if a single word cannot
break. Check the painted size against the constraints as well as the line
count, or a long unbroken token will silently overflow at your minimum size.
textScaler must be part of your cache key. A user changing their systemfont size will not invalidate a cache keyed only on the string and the box.
TextDirection is required and easy to forget, since it usually comes from the ambient Directionality. In a render object you have to thread it
through yourself.
happily spend iterations distinguishing 14.001 from 14.002. Search in whole or
half points and stop.
minFontSize does not fit, decidedeliberately whether to overflow, clip or ellipsise — and make it a parameter,
because different call sites genuinely want different answers.
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.