<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: K M Shahriar Hossain</title>
    <description>The latest articles on DEV Community by K M Shahriar Hossain (@devshakib).</description>
    <link>https://dev.to/devshakib</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4106725%2Fe7221d32-33c6-462f-a9d7-c92b3890990c.png</url>
      <title>DEV Community: K M Shahriar Hossain</title>
      <link>https://dev.to/devshakib</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/devshakib"/>
    <language>en</language>
    <item>
      <title>LayoutBuilder Does Not Support Returning Intrinsic Dimensions</title>
      <dc:creator>K M Shahriar Hossain</dc:creator>
      <pubDate>Wed, 02 Sep 2026 19:51:59 +0000</pubDate>
      <link>https://dev.to/devshakib/layoutbuilder-does-not-support-returning-intrinsic-dimensions-3ilb</link>
      <guid>https://dev.to/devshakib/layoutbuilder-does-not-support-returning-intrinsic-dimensions-3ilb</guid>
      <description>&lt;p&gt;You put auto-sizing text in a &lt;code&gt;Table&lt;/code&gt; cell, hot-reloaded, and got this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The stack trace is long, it points at framework code you did not write, and the&lt;br&gt;
widget worked perfectly ten minutes ago in a &lt;code&gt;Column&lt;/code&gt;. So the obvious first&lt;br&gt;
thought is that something is misconfigured.&lt;/p&gt;

&lt;p&gt;Nothing is misconfigured. This is Flutter refusing to do something it genuinely&lt;br&gt;
cannot do, and understanding why tells you exactly which layouts will break and&lt;br&gt;
which will not — which is more useful than a workaround, because the same wall&lt;br&gt;
is waiting behind three or four different widgets.&lt;/p&gt;
&lt;h2&gt;
  
  
  What an intrinsic dimension actually is
&lt;/h2&gt;

&lt;p&gt;Most Flutter layout is a single downward pass. A parent hands its child a set of&lt;br&gt;
constraints — &lt;em&gt;you may be between 0 and 300 logical pixels wide&lt;/em&gt; — the child&lt;br&gt;
picks a size within them, and the parent positions it. One pass, no&lt;br&gt;
backtracking, which is why Flutter's layout is linear in the number of widgets&lt;br&gt;
rather than quadratic.&lt;/p&gt;

&lt;p&gt;Some widgets cannot work that way. &lt;code&gt;IntrinsicHeight&lt;/code&gt; has to make all its&lt;br&gt;
children the height of the tallest one, so it has to know how tall each &lt;em&gt;wants&lt;/em&gt;&lt;br&gt;
to be before it can decide what to hand them. &lt;code&gt;Table&lt;/code&gt; sizes a column to fit its&lt;br&gt;
widest cell by default. Baseline alignment needs to know where text sits before&lt;br&gt;
it can align on it.&lt;/p&gt;

&lt;p&gt;For those, Flutter has a second protocol. A parent can ask a child a hypothetical&lt;br&gt;
question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If I gave you 200 pixels of width, how tall would you want to be?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That question is &lt;code&gt;computeMaxIntrinsicHeight&lt;/code&gt;, and the crucial part is the word&lt;br&gt;
&lt;em&gt;hypothetical&lt;/em&gt;. Nothing is being laid out. The child is being asked to predict a&lt;br&gt;
size for constraints it has not been given and may never be given. The answer&lt;br&gt;
must be computed &lt;strong&gt;without touching the render tree&lt;/strong&gt;, because the tree is in the&lt;br&gt;
middle of a layout pass and nothing may move yet.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why LayoutBuilder cannot answer it
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;LayoutBuilder&lt;/code&gt; exists to run &lt;em&gt;your&lt;/em&gt; code with the constraints as an argument:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;LayoutBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nl"&gt;builder:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;constraints&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_bestFontSizeFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;constraints&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;maxWidth&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;style:&lt;/span&gt; &lt;span class="n"&gt;TextStyle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;fontSize:&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is how essentially every auto-sizing text package works, and it is a&lt;br&gt;
perfectly good idea. It measures the text at a candidate size, checks whether it&lt;br&gt;
fits the constraints it was handed, and adjusts.&lt;/p&gt;

&lt;p&gt;Now look at what answering an intrinsic query would require. To tell the parent&lt;br&gt;
how tall it would be at 200 pixels wide, &lt;code&gt;LayoutBuilder&lt;/code&gt; would have to &lt;strong&gt;run your&lt;br&gt;
builder callback&lt;/strong&gt; with &lt;code&gt;maxWidth: 200&lt;/code&gt;. Your callback constructs widgets. Those&lt;br&gt;
widgets get mounted into the element tree. That is a mutation — during a&lt;br&gt;
speculative query, for a layout that may never happen, possibly several times&lt;br&gt;
with different widths as the parent searches for a fit.&lt;/p&gt;

&lt;p&gt;Flutter's own comment on the matter is blunt about it: doing so &lt;em&gt;"might mutate&lt;br&gt;
the live render object tree"&lt;/em&gt;. So it refuses, loudly, rather than corrupting your&lt;br&gt;
layout quietly. The error is the framework protecting you.&lt;/p&gt;

&lt;p&gt;This is why it is not going to be fixed. It is not an oversight, it is the&lt;br&gt;
boundary between two layout protocols that cannot both be satisfied by the same&lt;br&gt;
widget.&lt;/p&gt;
&lt;h2&gt;
  
  
  The two-pass problem, and why Flutter avoids it
&lt;/h2&gt;

&lt;p&gt;There is a deeper reason this protocol exists at all, and knowing it makes the&lt;br&gt;
constraint feel less arbitrary.&lt;/p&gt;

&lt;p&gt;Web layout is famously able to do this sort of thing — a CSS table sizes its&lt;br&gt;
columns to content without anyone thinking about it. It manages that by doing&lt;br&gt;
multiple passes over the tree, and by having a layout engine that can revisit&lt;br&gt;
decisions.&lt;/p&gt;

&lt;p&gt;Flutter deliberately does not. Its layout is &lt;strong&gt;single-pass and linear&lt;/strong&gt;:&lt;br&gt;
constraints go down, sizes come back up, each render object is visited once.&lt;br&gt;
That is a large part of why Flutter can hit 60 or 120fps on a deep tree — there&lt;br&gt;
is no risk of a pathological case where a small change triggers repeated&lt;br&gt;
re-layout of a large subtree.&lt;/p&gt;

&lt;p&gt;The intrinsic protocol is the pressure valve. It lets a parent ask a question&lt;br&gt;
&lt;em&gt;without&lt;/em&gt; triggering a real layout pass, at the cost of the query being a pure&lt;br&gt;
computation with no side effects. Widgets that use it are explicitly documented&lt;br&gt;
as more expensive, and &lt;code&gt;IntrinsicHeight&lt;/code&gt;'s own API docs warn that it can be&lt;br&gt;
O(N²) in the depth of the tree.&lt;/p&gt;

&lt;p&gt;So when Flutter refuses to let &lt;code&gt;LayoutBuilder&lt;/code&gt; participate, it is protecting the&lt;br&gt;
guarantee that makes the whole layout system fast. Allowing it would mean&lt;br&gt;
speculative builder invocations, which is a second pass through user code — the&lt;br&gt;
exact thing the architecture is designed to avoid.&lt;/p&gt;
&lt;h2&gt;
  
  
  Where this bites
&lt;/h2&gt;

&lt;p&gt;The failure only appears when something above your widget asks an intrinsic&lt;br&gt;
question. That makes it feel random — the same widget works in one screen and&lt;br&gt;
throws in another. It is not random at all. These are the layouts that ask:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Widget&lt;/th&gt;
&lt;th&gt;Why it asks&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;IntrinsicHeight&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Matches children to the tallest one&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;IntrinsicWidth&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Matches children to the widest one&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;Table&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Columns size to their widest cell by default&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Baseline-aligned &lt;code&gt;Row&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Needs the text baseline before aligning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ListView&lt;/code&gt; with &lt;code&gt;shrinkWrap&lt;/code&gt; in some nestings&lt;/td&gt;
&lt;td&gt;Sizes to content&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;Table&lt;/code&gt; is the one that catches people, because nothing about writing&lt;br&gt;
&lt;code&gt;Table(children: [...])&lt;/code&gt; suggests you have just opted into a second layout&lt;br&gt;
protocol. You get a table. You put a heading in a cell. It explodes.&lt;/p&gt;
&lt;h3&gt;
  
  
  How to tell before you hit it
&lt;/h3&gt;

&lt;p&gt;You do not need to memorise the table. There is a quicker heuristic: &lt;strong&gt;if a&lt;br&gt;
widget needs to know its children's sizes before deciding what constraints to&lt;br&gt;
give them, it will use intrinsics.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Column&lt;/code&gt; does not — it hands down its own constraints and takes whatever comes&lt;br&gt;
back. &lt;code&gt;IntrinsicHeight&lt;/code&gt; does, by definition. &lt;code&gt;Table&lt;/code&gt; does, because a column width&lt;br&gt;
is a function of every cell in it.&lt;/p&gt;

&lt;p&gt;A second signal is the error itself. If a stack trace mentions&lt;br&gt;
&lt;code&gt;computeMinIntrinsicWidth&lt;/code&gt;, &lt;code&gt;computeMaxIntrinsicWidth&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;computeMinIntrinsicHeight&lt;/code&gt; or &lt;code&gt;computeMaxIntrinsicHeight&lt;/code&gt;, you are in the&lt;br&gt;
intrinsic protocol regardless of which widget triggered it.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix: do the fitting in a RenderBox
&lt;/h2&gt;

&lt;p&gt;The way out is to stop asking the widget layer to measure things and drop to the&lt;br&gt;
layer that is allowed to. A &lt;code&gt;RenderBox&lt;/code&gt; can measure text with a &lt;code&gt;TextPainter&lt;/code&gt;&lt;br&gt;
whenever it likes, because a &lt;code&gt;TextPainter&lt;/code&gt; is not part of the render tree — it is&lt;br&gt;
a standalone object that lays out a paragraph and reports its size. No mutation,&lt;br&gt;
no speculative element mounting.&lt;/p&gt;

&lt;p&gt;That means the fitting can happen in two places that both work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;during &lt;code&gt;performLayout&lt;/code&gt;, for the real pass&lt;/li&gt;
&lt;li&gt;during &lt;code&gt;computeMaxIntrinsicHeight&lt;/code&gt;, for the hypothetical question — because
measuring with a &lt;code&gt;TextPainter&lt;/code&gt; is exactly the kind of pure computation the
intrinsic protocol requires&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The binary search for the largest font size that fits is the same algorithm the&lt;br&gt;
&lt;code&gt;LayoutBuilder&lt;/code&gt; approach uses. The difference is entirely about &lt;em&gt;where&lt;/em&gt; it runs.&lt;/p&gt;

&lt;p&gt;This is what I built &lt;a href="https://devshakib.jumyn.com/packages/fit_text" rel="noopener noreferrer"&gt;fit_text&lt;/a&gt; to do:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;FitText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'A headline that must never wrap'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;maxLines:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;minFontSize:&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works inside &lt;code&gt;Table&lt;/code&gt;, &lt;code&gt;IntrinsicHeight&lt;/code&gt;, &lt;code&gt;IntrinsicWidth&lt;/code&gt;, baseline rows,&lt;br&gt;
&lt;code&gt;Expanded&lt;/code&gt;, and unbounded constraints. The package's test suite contains a case&lt;br&gt;
that puts a &lt;code&gt;LayoutBuilder&lt;/code&gt;-based sizer and a &lt;code&gt;FitText&lt;/code&gt; in the same widget tree&lt;br&gt;
and asserts that the first throws while the second lays out — so the difference&lt;br&gt;
is pinned by a test rather than claimed in a README.&lt;/p&gt;

&lt;p&gt;It has no dependencies, and it runs on all six platforms.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why TextPainter is allowed and LayoutBuilder is not
&lt;/h3&gt;

&lt;p&gt;The distinction is worth stating precisely, because it is the whole trick.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;TextPainter&lt;/code&gt; is a &lt;strong&gt;standalone object&lt;/strong&gt;. Constructing one and calling &lt;code&gt;layout()&lt;/code&gt;&lt;br&gt;
on it does not touch the render tree, does not mount elements, does not schedule&lt;br&gt;
a frame, and has no effect on anything outside the object itself. It is a pure&lt;br&gt;
function from (text, style, constraints) to a size, with some caching inside.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;LayoutBuilder&lt;/code&gt; is the opposite. Its entire purpose is to run a callback that&lt;br&gt;
&lt;em&gt;builds widgets&lt;/em&gt;, and building widgets means creating elements and mounting them&lt;br&gt;
into the tree. That is a side effect, and side effects are exactly what an&lt;br&gt;
intrinsic query is forbidden to have.&lt;/p&gt;

&lt;p&gt;So the rule generalises beyond text. &lt;strong&gt;If you can compute your intrinsic size&lt;br&gt;
with a pure function, you can implement the intrinsic protocol.&lt;/strong&gt; If computing&lt;br&gt;
it requires building widgets, you cannot, and no amount of cleverness at the&lt;br&gt;
widget layer will change that. Drop to a render object and compute directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you would rather not add a package
&lt;/h2&gt;

&lt;p&gt;You do not have to. The approach is the point, and it is reproducible:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Subclass &lt;code&gt;RenderBox&lt;/code&gt; rather than composing widgets.&lt;/li&gt;
&lt;li&gt;Keep a &lt;code&gt;TextPainter&lt;/code&gt; as a field. Call &lt;code&gt;layout()&lt;/code&gt; on it with candidate styles.&lt;/li&gt;
&lt;li&gt;Binary search font size between your minimum and maximum, checking
&lt;code&gt;didExceedMaxLines&lt;/code&gt; and the resulting &lt;code&gt;size&lt;/code&gt; against the constraints.&lt;/li&gt;
&lt;li&gt;Implement &lt;code&gt;computeMinIntrinsicWidth&lt;/code&gt;, &lt;code&gt;computeMaxIntrinsicWidth&lt;/code&gt;,
&lt;code&gt;computeMinIntrinsicHeight&lt;/code&gt; and &lt;code&gt;computeMaxIntrinsicHeight&lt;/code&gt; using the same
measurement, and &lt;strong&gt;do not&lt;/strong&gt; touch the tree in any of them.&lt;/li&gt;
&lt;li&gt;Cache by (text, constraints, style) — intrinsic queries are called more often
than you expect, sometimes several times per frame.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Point five is the one people miss. &lt;code&gt;Table&lt;/code&gt; may ask each cell more than once while&lt;br&gt;
resolving column widths, and a binary search over twenty font sizes per query&lt;br&gt;
adds up quickly in a list.&lt;/p&gt;

&lt;h3&gt;
  
  
  The subtleties that bite in a custom implementation
&lt;/h3&gt;

&lt;p&gt;If you do write your own, these are the ones that cost an evening each:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;didExceedMaxLines&lt;/code&gt; is not enough on its own.&lt;/strong&gt; Text can fit within
&lt;code&gt;maxLines&lt;/code&gt; and still be wider than the constraint if a single word cannot
break. Check the painted &lt;code&gt;size&lt;/code&gt; against the constraints as well as the line
count, or a long unbroken token will silently overflow at your minimum size.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;textScaler&lt;/code&gt; must be part of your cache key.&lt;/strong&gt; A user changing their system
font size will not invalidate a cache keyed only on the string and the box.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;TextDirection&lt;/code&gt; is required&lt;/strong&gt; and easy to forget, since it usually comes
from the ambient &lt;code&gt;Directionality&lt;/code&gt;. In a render object you have to thread it
through yourself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Round your candidate sizes.&lt;/strong&gt; A binary search over continuous doubles will
happily spend iterations distinguishing 14.001 from 14.002. Search in whole or
half points and stop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Respect the minimum.&lt;/strong&gt; When even &lt;code&gt;minFontSize&lt;/code&gt; does not fit, decide
deliberately whether to overflow, clip or ellipsise — and make it a parameter,
because different call sites genuinely want different answers.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;LayoutBuilder&lt;/code&gt; cannot answer intrinsic queries because answering them means&lt;br&gt;
running your builder, and running your builder means mutating the tree during a&lt;br&gt;
speculative measurement. Any auto-sizing built on &lt;code&gt;LayoutBuilder&lt;/code&gt; inherits that&lt;br&gt;
limit. Move the measurement into a &lt;code&gt;RenderBox&lt;/code&gt; with a &lt;code&gt;TextPainter&lt;/code&gt; and the&lt;br&gt;
limitation disappears, because measuring a paragraph was never the thing that&lt;br&gt;
needed the tree.&lt;/p&gt;

&lt;p&gt;If you want it already done: &lt;a href="https://devshakib.jumyn.com/packages/fit_text" rel="noopener noreferrer"&gt;&lt;code&gt;fit_text&lt;/code&gt;&lt;/a&gt; on pub.dev, MIT&lt;br&gt;
licensed, no dependencies.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devshakib.jumyn.com/blog/layoutbuilder-does-not-support-returning-intrinsic-dimensions" rel="noopener noreferrer"&gt;devshakib.jumyn.com&lt;/a&gt;. I write about Flutter, Dart and the parts of shipping that are genuinely awkward — and publish the packages that came out of them at &lt;a href="https://pub.dev/publishers/jumyn.com/packages" rel="noopener noreferrer"&gt;pub.dev/publishers/jumyn.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>dart</category>
      <category>mobile</category>
      <category>programming</category>
    </item>
    <item>
      <title>Running Apple's On-Device AI From Flutter, With No API Key and No Server</title>
      <dc:creator>K M Shahriar Hossain</dc:creator>
      <pubDate>Wed, 02 Sep 2026 19:08:58 +0000</pubDate>
      <link>https://dev.to/devshakib/running-apples-on-device-ai-from-flutter-with-no-api-key-and-no-server-1o78</link>
      <guid>https://dev.to/devshakib/running-apples-on-device-ai-from-flutter-with-no-api-key-and-no-server-1o78</guid>
      <description>&lt;p&gt;Every "add AI to your Flutter app" tutorial ends the same way: get an API key,&lt;br&gt;
add a billing card, send the user's text to somebody else's server, and hope the&lt;br&gt;
latency is tolerable.&lt;/p&gt;

&lt;p&gt;On iOS 26 and macOS 26 there is a different option that most Flutter developers&lt;br&gt;
have not tried, because the plugins did not exist. Apple ships a language model,&lt;br&gt;
an image generator and a LiDAR room scanner &lt;strong&gt;on the device&lt;/strong&gt;. No key, no&lt;br&gt;
account, no per-token cost, no network — and nothing the user types leaves their&lt;br&gt;
phone.&lt;/p&gt;

&lt;p&gt;I built three packages to reach them from Flutter. This is what each one can&lt;br&gt;
genuinely do, and where each one stops.&lt;/p&gt;

&lt;h2&gt;
  
  
  The language model
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;LanguageModelSession&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nl"&gt;instructions:&lt;/span&gt; &lt;span class="s"&gt;'You summarise text in one sentence.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;respond&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;article&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;dispose&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That runs entirely on the device. It streams, it supports tool calling, and —&lt;br&gt;
the part that matters most in practice — it does &lt;strong&gt;schema-constrained structured&lt;br&gt;
output&lt;/strong&gt;. You describe the shape you want and the model is constrained to produce&lt;br&gt;
it, rather than being asked nicely in a prompt and then parsed hopefully.&lt;/p&gt;

&lt;p&gt;If you have ever written a retry loop around &lt;code&gt;jsonDecode&lt;/code&gt; because a hosted model&lt;br&gt;
returned prose instead of JSON, that is the problem this removes at the source.&lt;/p&gt;

&lt;h3&gt;
  
  
  Structured output is the feature
&lt;/h3&gt;

&lt;p&gt;It is worth dwelling on this one, because it is the difference between a demo&lt;br&gt;
and something you can put in a product.&lt;/p&gt;

&lt;p&gt;The usual pattern with a hosted model is: write a prompt asking politely for&lt;br&gt;
JSON, get back something that is &lt;em&gt;usually&lt;/em&gt; JSON, wrap &lt;code&gt;jsonDecode&lt;/code&gt; in a&lt;br&gt;
try/catch, add a retry, and accept a failure rate you cannot drive to zero. Every&lt;br&gt;
production LLM integration has this scar tissue.&lt;/p&gt;

&lt;p&gt;Schema-constrained generation removes the problem rather than mitigating it. The&lt;br&gt;
model is constrained &lt;em&gt;during sampling&lt;/em&gt; to tokens that keep the output valid&lt;br&gt;
against your schema. It is not being asked to produce the right shape; it is&lt;br&gt;
unable to produce a wrong one.&lt;/p&gt;

&lt;p&gt;For anything where the model's output feeds code rather than a human — extract&lt;br&gt;
these fields, classify into these categories, pull the dates out of this text —&lt;br&gt;
that is the difference between a feature you ship and a feature you keep&lt;br&gt;
apologising for.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tool calling, on-device
&lt;/h3&gt;

&lt;p&gt;The model can also call functions you expose. The pattern is the familiar one —&lt;br&gt;
you describe the tools, the model decides when to call them, you run them and&lt;br&gt;
hand back results — but with the round trip happening entirely on the device.&lt;/p&gt;

&lt;p&gt;The practical use is less "agents" and more "give the model access to the user's&lt;br&gt;
own data without that data going anywhere". A tool that reads the user's local&lt;br&gt;
notes or transactions lets the model answer questions about them, while the notes&lt;br&gt;
themselves never leave the phone. That is not achievable with a hosted model at&lt;br&gt;
any price.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it stops.&lt;/strong&gt; This is a small model. It is very good at summarising,&lt;br&gt;
extracting, classifying, rewriting and answering questions about text you give&lt;br&gt;
it. It is not a frontier model and it will not reason its way through a hard&lt;br&gt;
novel problem. Treat it as a fast, free, private text-processing engine and it&lt;br&gt;
is excellent. Treat it as a substitute for a large hosted model and it will&lt;br&gt;
disappoint you.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://devshakib.jumyn.com/packages/apple_foundation_models" rel="noopener noreferrer"&gt;&lt;code&gt;apple_foundation_models&lt;/code&gt;&lt;/a&gt; — iOS and macOS.&lt;/p&gt;

&lt;h2&gt;
  
  
  Image generation
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;image&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ImageCreator&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;setState&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_preview&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Image Playground generates on the device too, and the API streams &lt;strong&gt;partial&lt;br&gt;
images as they are made&lt;/strong&gt;. That is not a nicety, it is the entire user&lt;br&gt;
experience. Here is the timing I measured:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;first image&lt;/td&gt;
&lt;td&gt;6.8s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;second image&lt;/td&gt;
&lt;td&gt;10.3s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;size&lt;/td&gt;
&lt;td&gt;~4 MB PNG each&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Seven to ten seconds of a spinner feels broken. Seven to ten seconds of an image&lt;br&gt;
visibly resolving in front of you feels like something is being made for you.&lt;br&gt;
Same wait, completely different experience — which is why the package streams by&lt;br&gt;
default rather than offering it as an option.&lt;/p&gt;

&lt;p&gt;The same package also wraps the native text field that carries &lt;strong&gt;Writing Tools&lt;/strong&gt;&lt;br&gt;
and &lt;strong&gt;Genmoji&lt;/strong&gt;, so users get the system rewrite, proofread and summarise&lt;br&gt;
affordances they already know from Notes and Mail, in your app, without you&lt;br&gt;
building any of it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why streaming is not a nicety
&lt;/h3&gt;

&lt;p&gt;Look at the timings again and think about what the alternative looks like.&lt;/p&gt;

&lt;p&gt;A modal spinner for 6.8 seconds is a long time. Long enough that a meaningful&lt;br&gt;
share of users will assume it has hung and back out. Long enough that on the&lt;br&gt;
second image, at 10.3 seconds, some will kill the app.&lt;/p&gt;

&lt;p&gt;The same 10.3 seconds spent watching an image emerge from noise into shape is not&lt;br&gt;
a wait at all — it is the product. The user is watching their idea being made.&lt;br&gt;
Nobody backs out of that, because there is visible progress on every frame.&lt;/p&gt;

&lt;p&gt;This is the general lesson for on-device generation, not a quirk of this API. The&lt;br&gt;
compute is slower than a datacentre GPU. You cannot fix that. What you can do is&lt;br&gt;
make the latency &lt;em&gt;visible and interesting&lt;/em&gt; rather than hidden behind a spinner,&lt;br&gt;
and the perceived experience inverts.&lt;/p&gt;

&lt;p&gt;Budget around &lt;strong&gt;4 MB per PNG&lt;/strong&gt;, which matters if you are storing or syncing them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://devshakib.jumyn.com/packages/apple_intelligence" rel="noopener noreferrer"&gt;&lt;code&gt;apple_intelligence&lt;/code&gt;&lt;/a&gt; — iOS and macOS.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scanning a room
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;controller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;RoomScanController&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;RoomPlan uses the LiDAR sensor to scan a room and hand back structured geometry:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;walls&lt;/code&gt;, &lt;code&gt;floors&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;surfaces with metre dimensions and a transform&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;doors&lt;/code&gt;, &lt;code&gt;windows&lt;/code&gt;, &lt;code&gt;openings&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;found within the walls&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;objects&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;recognised furniture — chair, table, bed, storage…&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;usdzPath&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a USDZ model, ready for AR Quick Look&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;raw&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;RoomPlan's own encoding, untouched&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Real dimensions in metres, not a point cloud you have to interpret. For anyone&lt;br&gt;
building for interiors, property, removals, insurance or furniture retail, that&lt;br&gt;
is the difference between a demo and a product.&lt;/p&gt;

&lt;p&gt;The package deliberately hosts &lt;strong&gt;Apple's own scanning UI&lt;/strong&gt; rather than&lt;br&gt;
reimplementing it. Users have seen that interface in Apple's Measure app; the&lt;br&gt;
coaching overlays that tell you to move slower or point at the wall are tuned by&lt;br&gt;
people with the sensor data. Replacing that with a custom camera view would be&lt;br&gt;
worse in every way that matters.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://devshakib.jumyn.com/packages/roomplan" rel="noopener noreferrer"&gt;&lt;code&gt;roomplan&lt;/code&gt;&lt;/a&gt; — iOS, LiDAR required.&lt;/p&gt;

&lt;h3&gt;
  
  
  What RoomPlan is actually for
&lt;/h3&gt;

&lt;p&gt;The USDZ output gets the attention because it drops straight into AR Quick Look.&lt;br&gt;
The structured geometry is the more valuable half.&lt;/p&gt;

&lt;p&gt;Because you get walls with metre dimensions, doors and windows located within&lt;br&gt;
them, and recognised furniture, you can answer questions rather than just display&lt;br&gt;
a model: &lt;em&gt;will this sofa fit through that door?&lt;/em&gt; &lt;em&gt;how much paint for these&lt;br&gt;
walls?&lt;/em&gt; &lt;em&gt;what is the floor area, excluding the built-in wardrobe?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That is the difference between a scanning feature and a product. Estate agents,&lt;br&gt;
removals firms, insurers, interior retail and trades all have a version of that&lt;br&gt;
question, and all of them currently answer it with a tape measure and a phone&lt;br&gt;
call.&lt;/p&gt;

&lt;h2&gt;
  
  
  The limits, plainly
&lt;/h2&gt;

&lt;p&gt;These are Apple frameworks, so the constraints are Apple's:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;iOS 26 / macOS 26 or newer.&lt;/strong&gt; There is no polyfill. Check availability and
design a fallback path — all three packages expose a capability check, and you
should call it before showing any UI that depends on the feature.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apple Intelligence must be enabled&lt;/strong&gt; by the user on a supported device.
Availability is not the same as "the device is new enough".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RoomPlan needs LiDAR&lt;/strong&gt; — Pro iPhones and iPads, not the base models.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;iOS and macOS only.&lt;/strong&gt; Nothing here runs on Android or the web, and no amount
of wrapping changes that.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Which is why the honest framing is not "replace your hosted model". It is: for&lt;br&gt;
the substantial set of tasks these handle, you get them for free, instantly,&lt;br&gt;
offline, and without any user data leaving the device — and you fall back to&lt;br&gt;
whatever you use today when the device cannot help.&lt;/p&gt;

&lt;p&gt;That fallback path is not a workaround. It is the design.&lt;/p&gt;

&lt;h3&gt;
  
  
  Checking availability properly
&lt;/h3&gt;

&lt;p&gt;There are three separate questions and they have three different answers, which&lt;br&gt;
is why a single &lt;code&gt;isSupported&lt;/code&gt; boolean is not enough:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Is the OS new enough?&lt;/strong&gt; A compile-time and runtime version check.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is the hardware capable?&lt;/strong&gt; Apple Intelligence needs specific silicon;
RoomPlan needs LiDAR.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Has the user enabled it?&lt;/strong&gt; Apple Intelligence is opt-in and can be off on a
perfectly capable device.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Only the third can change while your app is running, and it is the one people&lt;br&gt;
forget. Query availability at the point of use rather than caching a result from&lt;br&gt;
launch, and design the UI so an unavailable feature is &lt;em&gt;absent&lt;/em&gt; rather than&lt;br&gt;
present-and-failing. A greyed-out button with a tooltip explaining an OS&lt;br&gt;
requirement is worse than not showing the button, because it advertises&lt;br&gt;
something the user cannot act on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why on-device is worth the trouble
&lt;/h2&gt;

&lt;p&gt;Three reasons that survive contact with a real product.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost.&lt;/strong&gt; Per-token pricing turns every active user into a running bill.&lt;br&gt;
On-device inference costs nothing and does not scale with success.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Latency.&lt;/strong&gt; No round trip. For summarise-as-you-type or classify-on-paste, the&lt;br&gt;
difference between 40ms and 900ms is the difference between a feature people use&lt;br&gt;
and one they wait for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Privacy you can state simply.&lt;/strong&gt; "Your notes never leave your phone" is a&lt;br&gt;
sentence you can put in an App Store listing and defend. It is also the only&lt;br&gt;
version of that promise that is true without a legal team.&lt;/p&gt;

&lt;h3&gt;
  
  
  The honest comparison
&lt;/h3&gt;

&lt;p&gt;To be clear about when &lt;em&gt;not&lt;/em&gt; to reach for these:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;On-device&lt;/th&gt;
&lt;th&gt;Hosted&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cost per call&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;per token&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Latency&lt;/td&gt;
&lt;td&gt;~40ms to first token&lt;/td&gt;
&lt;td&gt;300ms–2s round trip&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Works offline&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data leaves device&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reasoning depth&lt;/td&gt;
&lt;td&gt;limited&lt;/td&gt;
&lt;td&gt;frontier&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Platform&lt;/td&gt;
&lt;td&gt;iOS/macOS 26+&lt;/td&gt;
&lt;td&gt;anywhere&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Availability&lt;/td&gt;
&lt;td&gt;user must enable&lt;/td&gt;
&lt;td&gt;always&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The right architecture for most apps is both: on-device for the high-frequency,&lt;br&gt;
low-complexity, privacy-sensitive work, hosted for the occasional hard question,&lt;br&gt;
and a capability check deciding which one runs. That is not a compromise, it is&lt;br&gt;
just using the cheaper tool for the easier job.&lt;/p&gt;

&lt;p&gt;All three packages are MIT licensed with public source, and all three hold a&lt;br&gt;
perfect 160/160 on pub.dev. Start with whichever problem you actually have —&lt;br&gt;
&lt;a href="https://devshakib.jumyn.com/packages" rel="noopener noreferrer"&gt;the full list is here&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devshakib.jumyn.com/blog/apple-on-device-ai-from-flutter" rel="noopener noreferrer"&gt;devshakib.jumyn.com&lt;/a&gt;. I write about Flutter, Dart and the parts of shipping that are genuinely awkward — and publish the packages that came out of them at &lt;a href="https://pub.dev/publishers/jumyn.com/packages" rel="noopener noreferrer"&gt;pub.dev/publishers/jumyn.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>ios</category>
      <category>macos</category>
      <category>ai</category>
    </item>
    <item>
      <title>Why Your Loading Spinner Flickers, and the Two Rules That Fix It</title>
      <dc:creator>K M Shahriar Hossain</dc:creator>
      <pubDate>Wed, 02 Sep 2026 19:07:49 +0000</pubDate>
      <link>https://dev.to/devshakib/why-your-loading-spinner-flickers-and-the-two-rules-that-fix-it-2jn6</link>
      <guid>https://dev.to/devshakib/why-your-loading-spinner-flickers-and-the-two-rules-that-fix-it-2jn6</guid>
      <description>&lt;p&gt;Every Flutter app has this somewhere:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;_loading&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_signIn&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;setState&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_loading&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;signIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;setState&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_loading&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a &lt;code&gt;Stack&lt;/code&gt; with a spinner on top when &lt;code&gt;_loading&lt;/code&gt; is true. It is the first&lt;br&gt;
thing everyone writes, it is in every tutorial, and it is correct in the sense&lt;br&gt;
that the state is never wrong.&lt;/p&gt;

&lt;p&gt;It also looks broken on a fast connection, and the reason is worth understanding&lt;br&gt;
properly — because the fix is not a nicer spinner, and no amount of animation&lt;br&gt;
polish will help.&lt;/p&gt;
&lt;h2&gt;
  
  
  The failure is in the timing, not the widget
&lt;/h2&gt;

&lt;p&gt;Your API call returns in 90 milliseconds. At 60fps that is about five and a half&lt;br&gt;
frames.&lt;/p&gt;

&lt;p&gt;So the spinner mounts, paints for five frames, and unmounts. The user does not&lt;br&gt;
perceive "loading". They perceive the screen &lt;strong&gt;flinching&lt;/strong&gt; — a flash of grey, a&lt;br&gt;
shape that appeared and left before their eye could settle on it. On a fast&lt;br&gt;
network, every single tap does this.&lt;/p&gt;

&lt;p&gt;It gets worse when the timing sits near the boundary. A call that takes 200ms&lt;br&gt;
on Wi-Fi and 900ms on cellular produces two entirely different experiences from&lt;br&gt;
the same code: a flash on one, a legible wait on the other. Users on the fast&lt;br&gt;
network get the glitchy one, which is precisely backwards.&lt;/p&gt;

&lt;p&gt;There is decades-old research on this. Jakob Nielsen's response-time limits put&lt;br&gt;
&lt;strong&gt;0.1 seconds&lt;/strong&gt; as the threshold below which an action feels instantaneous, and&lt;br&gt;
&lt;strong&gt;1 second&lt;/strong&gt; as the limit for uninterrupted flow of thought. The important&lt;br&gt;
implication is the one most implementations miss: &lt;strong&gt;below about 100ms, showing&lt;br&gt;
progress is worse than showing nothing&lt;/strong&gt;, because the feedback itself becomes&lt;br&gt;
the disruption.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;bool&lt;/code&gt; cannot express that. A &lt;code&gt;bool&lt;/code&gt; says "in flight" or "not in flight", and&lt;br&gt;
maps both directly to pixels. What you need is a third state — &lt;em&gt;in flight, but&lt;br&gt;
not yet worth mentioning&lt;/em&gt; — and that requires a clock.&lt;/p&gt;
&lt;h2&gt;
  
  
  Rule one: nothing paints before the reveal delay
&lt;/h2&gt;

&lt;p&gt;Start a timer when the operation begins. If the operation finishes before the&lt;br&gt;
timer fires, &lt;strong&gt;render nothing at all&lt;/strong&gt;. No spinner, no scrim, no success tick.&lt;br&gt;
The screen never moves.&lt;/p&gt;

&lt;p&gt;A threshold somewhere around 140ms works well in practice. Below it, an&lt;br&gt;
operation is fast enough that feedback is noise. Above it, the user has begun to&lt;br&gt;
wonder whether their tap registered, and feedback is reassurance.&lt;/p&gt;

&lt;p&gt;This single rule eliminates the majority of flicker in a typical app, because&lt;br&gt;
the majority of requests in a typical app are fast. Cached reads, local&lt;br&gt;
database queries, a warm API on a good connection — all silent.&lt;/p&gt;
&lt;h2&gt;
  
  
  Rule two: once it paints, it holds
&lt;/h2&gt;

&lt;p&gt;The second rule is less obvious and just as important.&lt;/p&gt;

&lt;p&gt;Say your reveal delay is 140ms and the request finishes at 170ms. Without a&lt;br&gt;
second rule, the spinner appears for 30 milliseconds. You have replaced a&lt;br&gt;
five-frame flash with a two-frame flash, which is worse.&lt;/p&gt;

&lt;p&gt;So once the overlay commits to appearing, it stays for a minimum — half a second&lt;br&gt;
is a good default. The user waits slightly longer than strictly necessary, and&lt;br&gt;
in exchange the interface reads as &lt;strong&gt;deliberate&lt;/strong&gt; rather than glitchy.&lt;/p&gt;

&lt;p&gt;That trade is worth being explicit about, because it sounds wrong at first: you&lt;br&gt;
are deliberately making the app slower. But perceived performance is not average&lt;br&gt;
latency. An interface that is 300ms slower and never flinches feels faster and&lt;br&gt;
more trustworthy than one that is technically quicker and visibly twitchy. The&lt;br&gt;
user is not timing you with a stopwatch; they are forming an impression of&lt;br&gt;
whether the software is solid.&lt;/p&gt;

&lt;p&gt;Those two rules are the whole reason &lt;a href="https://devshakib.jumyn.com/packages/loading_kit" rel="noopener noreferrer"&gt;&lt;code&gt;loading_kit&lt;/code&gt;&lt;/a&gt;&lt;br&gt;
exists:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Loading&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;signIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nl"&gt;message:&lt;/span&gt; &lt;span class="s"&gt;'Signing in…'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;successMessage:&lt;/span&gt; &lt;span class="s"&gt;'Welcome back'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;run&lt;/code&gt; rethrows whatever the task threw, so your error handling is unchanged.&lt;br&gt;
Everything else in the package follows from taking blocking state seriously&lt;br&gt;
rather than from having more spinner shapes.&lt;/p&gt;
&lt;h2&gt;
  
  
  The six things a bool never handles
&lt;/h2&gt;

&lt;p&gt;Timing is the headline. These are the problems that show up afterwards, once a&lt;br&gt;
real app grows past one screen — and each one is a bug I have seen in production&lt;br&gt;
code that started as &lt;code&gt;bool _loading&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  Concurrent requests
&lt;/h3&gt;

&lt;p&gt;Two operations start. The first finishes and sets &lt;code&gt;_loading = false&lt;/code&gt;. The second&lt;br&gt;
is still running, and the overlay is gone — the user can now tap into a screen&lt;br&gt;
that is mid-mutation.&lt;/p&gt;

&lt;p&gt;The fix is &lt;strong&gt;reference counting&lt;/strong&gt;: the overlay leaves when the &lt;em&gt;last&lt;/em&gt; operation&lt;br&gt;
retires, not the first. Related: if one request succeeds while another is still&lt;br&gt;
in flight, busy must outrank settled, or a check mark flashes mid-flight and&lt;br&gt;
tells the user the work is done when it is not.&lt;/p&gt;
&lt;h3&gt;
  
  
  The overlay outliving its screen
&lt;/h3&gt;

&lt;p&gt;A request starts on the profile screen. The user hits back. The request fails&lt;br&gt;
slowly. The &lt;code&gt;finally&lt;/code&gt; block runs on a &lt;code&gt;State&lt;/code&gt; that is no longer mounted, or the&lt;br&gt;
overlay is hosted above the navigator and is now sitting over a completely&lt;br&gt;
different screen with a message about a profile the user has left.&lt;/p&gt;

&lt;p&gt;This needs route awareness — an observer that clears overlays when the route&lt;br&gt;
beneath them changes. A &lt;code&gt;bool&lt;/code&gt; in a &lt;code&gt;State&lt;/code&gt; cannot know that a navigation&lt;br&gt;
happened somewhere above it.&lt;/p&gt;
&lt;h3&gt;
  
  
  Input that leaks through
&lt;/h3&gt;

&lt;p&gt;A &lt;code&gt;Stack&lt;/code&gt; with a spinner on top does not necessarily block taps. If your overlay&lt;br&gt;
does not have an opaque hit-test target, the buttons underneath are still live&lt;br&gt;
and the user can fire the same request three times.&lt;/p&gt;
&lt;h3&gt;
  
  
  Keyboard focus
&lt;/h3&gt;

&lt;p&gt;Even with taps blocked, a hardware keyboard can still tab to buttons under the&lt;br&gt;
scrim and activate them with Enter. On desktop and web that is a real&lt;br&gt;
double-submit path, and it is invisible in testing because nobody tabs during a&lt;br&gt;
loading state on purpose.&lt;/p&gt;

&lt;p&gt;The blocked subtree needs to come out of focus traversal entirely.&lt;/p&gt;
&lt;h3&gt;
  
  
  Screen readers
&lt;/h3&gt;

&lt;p&gt;A visual spinner communicates nothing to a screen reader user. Without markup&lt;br&gt;
they get silence, then the screen changes.&lt;/p&gt;

&lt;p&gt;The overlay should be a live region that announces its message and its progress,&lt;br&gt;
and — just as important — the blocked application underneath should be hidden&lt;br&gt;
from the accessibility tree with &lt;code&gt;BlockSemantics&lt;/code&gt;, or the user can navigate to&lt;br&gt;
controls that no longer work.&lt;/p&gt;
&lt;h3&gt;
  
  
  Reduced motion
&lt;/h3&gt;

&lt;p&gt;If the platform asks for reduced motion, the entrance animation should drop its&lt;br&gt;
scale. It is a small thing that takes one conditional, and it is the sort of&lt;br&gt;
thing that never gets added to a hand-rolled overlay because it is nobody's&lt;br&gt;
ticket.&lt;/p&gt;
&lt;h2&gt;
  
  
  When an overlay is the wrong answer
&lt;/h2&gt;

&lt;p&gt;The mistake in the other direction is blacking out the entire application for&lt;br&gt;
something small.&lt;/p&gt;

&lt;p&gt;A form that saves in place does not need the whole screen scrimmed — it needs&lt;br&gt;
&lt;em&gt;that form&lt;/em&gt; to stop accepting input. Scoping the overlay to a subtree keeps the&lt;br&gt;
rest of the app usable, and it should still apply the same timing policy so a&lt;br&gt;
fast save flashes nothing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;LoadingBarrier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nl"&gt;loading:&lt;/span&gt; &lt;span class="n"&gt;_saving&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;message:&lt;/span&gt; &lt;span class="s"&gt;'Saving…'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;borderRadius:&lt;/span&gt; &lt;span class="n"&gt;BorderRadius&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;circular&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nl"&gt;child:&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;ProfileForm&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a great deal of what gets a spinner should not block anything at all. Work&lt;br&gt;
that has already succeeded, or that continues in the background, wants a toast:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;Loading&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'Draft saved'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;Loading&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toastSuccess&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'Order placed'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;Loading&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toastError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'Could not sync'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;detail:&lt;/span&gt; &lt;span class="s"&gt;'Retrying in the background'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule of thumb: &lt;strong&gt;block only when proceeding would be wrong.&lt;/strong&gt; If the user&lt;br&gt;
could carry on doing something else while this finishes, taking the screen away&lt;br&gt;
from them is a bug in the design, not a loading state.&lt;/p&gt;
&lt;h2&gt;
  
  
  Progress, and when to offer cancelling
&lt;/h2&gt;

&lt;p&gt;Indeterminate spinners are honest when you genuinely do not know how long&lt;br&gt;
something will take. When you do know — uploading eleven files, processing a&lt;br&gt;
list — show it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Loading&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;runTask&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;files&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;throwIfCancelled&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;report&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;files&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;detail:&lt;/span&gt; &lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="si"&gt;${i + 1}&lt;/span&gt;&lt;span class="s"&gt; of &lt;/span&gt;&lt;span class="si"&gt;${files.length}&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;upload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;files&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nl"&gt;message:&lt;/span&gt; &lt;span class="s"&gt;'Uploading…'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;cancelAfter:&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;seconds:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details in there matter more than the progress number.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;cancelAfter&lt;/code&gt;&lt;/strong&gt; reveals the cancel affordance only once that much time has&lt;br&gt;
passed. A cancel button on a one-second operation is clutter that nobody will&lt;br&gt;
ever click. A cancel button on a thirty-second upload is the difference between&lt;br&gt;
a patient user and a force-quit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cancellation is cooperative.&lt;/strong&gt; Tapping cancel rejects the future immediately&lt;br&gt;
so the UI responds at once, and the body stops at its next &lt;code&gt;throwIfCancelled()&lt;/code&gt;.&lt;br&gt;
Pretending you can abort arbitrary Dart mid-execution would be a lie; making the&lt;br&gt;
cancellation points explicit is the honest version.&lt;/p&gt;

&lt;p&gt;For long work, a bar reads better than a ring. The difference between 60% and&lt;br&gt;
70% is obvious in a line and genuinely hard to judge in a circle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;LoadingStyle&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;material&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;copyWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;progressStyle:&lt;/span&gt; &lt;span class="n"&gt;LoadingProgressStyle&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;bar&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  One continuous form, not three widgets
&lt;/h2&gt;

&lt;p&gt;A detail that is easy to skip and changes how the whole thing feels.&lt;/p&gt;

&lt;p&gt;Most implementations swap widgets at each stage: a &lt;code&gt;CircularProgressIndicator&lt;/code&gt;,&lt;br&gt;
then an &lt;code&gt;Icon(Icons.check)&lt;/code&gt;, then nothing. Three unrelated shapes appearing in&lt;br&gt;
the same slot. Each swap is a small visual discontinuity.&lt;/p&gt;

&lt;p&gt;Drawing all the states as one &lt;code&gt;CustomPainter&lt;/code&gt; means the arc &lt;strong&gt;closes into a&lt;br&gt;
ring&lt;/strong&gt;, crosses to the terminal colour, and strokes the check inside itself. The&lt;br&gt;
outcome grows out of the waiting rather than replacing it. It costs one painter&lt;br&gt;
instead of three widgets, and it is the difference between an interface that&lt;br&gt;
transitions and one that cuts.&lt;/p&gt;
&lt;h2&gt;
  
  
  Making it free when idle
&lt;/h2&gt;

&lt;p&gt;An overlay host sits above your entire app, so its cost while nothing is loading&lt;br&gt;
matters more than its cost while something is.&lt;/p&gt;

&lt;p&gt;With nothing in flight it should build &lt;code&gt;SizedBox.shrink()&lt;/code&gt; — no scrim, no blur,&lt;br&gt;
no ticker, no hit-test target. And critically, &lt;strong&gt;the app subtree must be passed&lt;br&gt;
through by identity&lt;/strong&gt;, so that when loading starts and stops Flutter can skip&lt;br&gt;
rebuilding your entire application. If your overlay wrapper rebuilds its child,&lt;br&gt;
every loading state in your app is now a full rebuild.&lt;/p&gt;

&lt;p&gt;Two more that add up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stop the ticker when it settles.&lt;/strong&gt; A repeating animation left running
through the exit transition schedules frames for no reason.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scope any blur to the card.&lt;/strong&gt; A backdrop filter over the whole screen is one
of the most expensive things you can composite; clipped to a small card it is
cheap.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Testing timing behaviour
&lt;/h2&gt;

&lt;p&gt;The nice property of making the rules explicit is that they become testable —&lt;br&gt;
on Flutter's fake clock, with no real waiting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;testWidgets&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'a fast call shows nothing'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tester&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;controller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;LoadingController&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="n"&gt;addTearDown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;controller&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;dispose&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;work&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;controller&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;delayed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;milliseconds:&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tester&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;pump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;milliseconds:&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;controller&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;visible&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;isFalse&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tester&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;pump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;milliseconds:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;work&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One caveat that applies to any indeterminate indicator, including Flutter's own:&lt;br&gt;
a spinning arc schedules a frame forever, so &lt;strong&gt;&lt;code&gt;pumpAndSettle()&lt;/code&gt; will never&lt;br&gt;
settle&lt;/strong&gt; while one is on screen. Pump explicit durations instead. This catches&lt;br&gt;
people out and produces mysterious test timeouts that look like a deadlock.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you take nothing else
&lt;/h2&gt;

&lt;p&gt;You do not need a package for any of this. The two rules are twenty lines:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Start a timer on begin. Paint nothing until it fires — around 140ms.&lt;/li&gt;
&lt;li&gt;Once painted, hold for a minimum — around 500ms.&lt;/li&gt;
&lt;li&gt;Reference-count so overlapping operations do not strand each other.&lt;/li&gt;
&lt;li&gt;Block input &lt;em&gt;and&lt;/em&gt; focus, not just taps.&lt;/li&gt;
&lt;li&gt;Announce to screen readers, and hide the blocked app from the a11y tree.&lt;/li&gt;
&lt;li&gt;Do not block at all unless proceeding would be wrong.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That list is most of the value, and it applies whatever you build it with.&lt;/p&gt;

&lt;p&gt;If you would rather have it done: &lt;a href="https://devshakib.jumyn.com/packages/loading_kit" rel="noopener noreferrer"&gt;&lt;code&gt;loading_kit&lt;/code&gt;&lt;/a&gt; is on&lt;br&gt;
pub.dev — MIT, no dependencies, all six platforms, 160/160 pub points. Five&lt;br&gt;
presets that resolve against your &lt;code&gt;ThemeData&lt;/code&gt;, six indicator styles, and an&lt;br&gt;
&lt;code&gt;indicatorBuilder&lt;/code&gt; slot if you would rather supply a Lottie file or your own&lt;br&gt;
brand mark and keep only the timing behaviour.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devshakib.jumyn.com/blog/why-your-flutter-loading-spinner-flickers" rel="noopener noreferrer"&gt;devshakib.jumyn.com&lt;/a&gt;. I write about Flutter, Dart and the parts of shipping that are genuinely awkward — and publish the packages that came out of them at &lt;a href="https://pub.dev/publishers/jumyn.com/packages" rel="noopener noreferrer"&gt;pub.dev/publishers/jumyn.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>ux</category>
      <category>a11y</category>
      <category>dart</category>
    </item>
  </channel>
</rss>
