LayoutBuilder Does Not Support Returning Intrinsic Dimensions
DEV Community

LayoutBuilder Does Not Support Returning Intrinsic Dimensions

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 aTextPainter 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. 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 RenderBox rather than composing widgets. - Keep a TextPainter as a field. Calllayout() on it with candidate styles. - Binary search font size between your minimum and maximum, checking didExceedMaxLines and the resultingsize against the constraints. - Implement computeMinIntrinsicWidth ,computeMaxIntrinsicWidth ,computeMinIntrinsicHeight andcomputeMaxIntrinsicHeight using 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: - didExceedMaxLines is not enough on its own. Text can fit withinmaxLines and still be wider than the constraint if a single word cannot break. Check the paintedsize 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 system font size will not invalidate a cache keyed only on the string and the box. - TextDirection is required and easy to forget, since it usuall

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.