<?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: Soe Min Thein</title>
    <description>The latest articles on DEV Community by Soe Min Thein (@soemin_thein).</description>
    <link>https://dev.to/soemin_thein</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%2F3935050%2Fb1019e96-7c59-4282-8bd8-2fafa2cbec7f.jpg</url>
      <title>DEV Community: Soe Min Thein</title>
      <link>https://dev.to/soemin_thein</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/soemin_thein"/>
    <language>en</language>
    <item>
      <title>The Click That Fixed Our Scrolling: Flutter macOS Performance</title>
      <dc:creator>Soe Min Thein</dc:creator>
      <pubDate>Sat, 26 Sep 2026 09:21:35 +0000</pubDate>
      <link>https://dev.to/soemin_thein/the-click-that-fixed-our-scrolling-flutter-macos-performance-1d52</link>
      <guid>https://dev.to/soemin_thein/the-click-that-fixed-our-scrolling-flutter-macos-performance-1d52</guid>
      <description>&lt;p&gt;&lt;em&gt;We optimised the data, the queries and the list, and scrolling still stuttered. The real fix came from watching what a single click changed.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Here's a bug report you don't want to get:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Scrolling is laggy. But if I click a row first, it's smooth."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Clicking a row doesn't touch the list's data. It doesn't change the query or the number of rows, and it doesn't make any widget cheaper to build. And yet, every time, one click turned a stuttering list into a smooth one.&lt;/p&gt;

&lt;p&gt;This is how we got there. First we fixed all the things you're &lt;em&gt;supposed&lt;/em&gt; to fix. Then we noticed the one thing we hadn't looked at.&lt;/p&gt;




&lt;h2&gt;
  
  
  Act 1: Doing everything right
&lt;/h2&gt;

&lt;p&gt;The app is a Flutter desktop app on macOS, backed by a local SQLite database (via &lt;a href="https://pub.dev/packages/drift" rel="noopener noreferrer"&gt;drift&lt;/a&gt;) holding about &lt;strong&gt;100,000 rows&lt;/strong&gt;. It has a few list screens: search at the top, filters at the side, a sortable grid in the middle, and a detail panel that opens when you click a row.&lt;/p&gt;

&lt;p&gt;That's not huge, but it's enough to punish a list that loads everything or builds every row. So we built it carefully from the start.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pagination in the database, not the widget
&lt;/h3&gt;

&lt;p&gt;A list never holds more than one page. The search, filters, sort and page number form one immutable request object. The query turns it into SQL &lt;code&gt;LIMIT&lt;/code&gt;/&lt;code&gt;OFFSET&lt;/code&gt;, plus a &lt;code&gt;COUNT&lt;/code&gt; for the "Showing 1–100 of 98,978" footer. Both queries run in one transaction, so the count always matches the rows.&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;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;RowPage&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SearchRequest&lt;/span&gt; &lt;span class="n"&gt;request&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;transaction&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;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;count&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;totals&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;selectOnly&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&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="na"&gt;addColumns&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;count&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="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filterFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
      &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getSingle&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;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&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="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&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;filterFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&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="na"&gt;orderBy&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&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;OrderingTerm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;expression:&lt;/span&gt; &lt;span class="n"&gt;sortColumn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;mode:&lt;/span&gt; &lt;span class="n"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="c1"&gt;// Tie-break, so rows never shuffle across a page boundary.&lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&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;OrderingTerm&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;asc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;id&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="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pageSize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;offset:&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;page&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;pageSize&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;RowPage&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;query&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;totals&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;)&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The database runs on a background isolate (&lt;code&gt;NativeDatabase.createInBackground&lt;/code&gt;), so a slow query can never block a frame.&lt;/p&gt;

&lt;h3&gt;
  
  
  Riverpod as the wiring
&lt;/h3&gt;

&lt;p&gt;The screen never fetches anything itself. The request lives in a &lt;code&gt;Notifier&lt;/code&gt;, and the results are a &lt;code&gt;FutureProvider&lt;/code&gt; that watches it. When you type, filter, sort or turn a page, the results provider reruns the query and the grid rebuilds from the new page.&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;searchProvider&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="n"&gt;NotifierProvider&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;SearchController&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SearchRequest&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;SearchController&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;new&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;resultsProvider&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;FutureProvider&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;RowPage&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ref&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;ref&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;databaseProvider&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;searchProvider&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;The selected row and scroll offset live in providers too, so leaving a screen and coming back lands you exactly where you were.&lt;/p&gt;

&lt;h3&gt;
  
  
  A virtualised grid
&lt;/h3&gt;

&lt;p&gt;The grid is a &lt;code&gt;ListView.builder&lt;/code&gt; with a fixed &lt;code&gt;itemExtent&lt;/code&gt;. Only the rows on screen get built, and the keyboard navigation (↑/↓ to move a highlight, Enter to open it) can map a row index straight to a scroll offset. Number and date formatters are created once, never per cell.&lt;/p&gt;

&lt;p&gt;On paper it's about as cheap as a list gets: 100 rows in memory, about 20 on screen, cheap cells, and queries off the UI thread.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scrolling still wasn't smooth.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Act 2: The usual suspects (all guilty, none &lt;em&gt;the&lt;/em&gt; culprit)
&lt;/h2&gt;

&lt;p&gt;We went hunting. Every one of these was a real problem worth fixing in any Flutter list, and none of them was the answer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Suspect 1: hover flicker
&lt;/h3&gt;

&lt;p&gt;Every row had a &lt;code&gt;MouseRegion&lt;/code&gt; that called &lt;code&gt;setState&lt;/code&gt; on enter and exit to paint a hover colour. With the cursor held still over a scrolling list, each row that slid underneath lit up and faded for a frame or two. To the eye, that looks exactly like stutter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; pause hover while the list moves. A &lt;code&gt;NotificationListener&lt;/code&gt; flips a &lt;code&gt;ValueNotifier&amp;lt;bool&amp;gt;&lt;/code&gt;, which reaches the rows through an &lt;code&gt;InheritedNotifier&lt;/code&gt;. While scrolling, rows record hover changes without rebuilding, and they repaint once when the list settles. One gotcha: a mouse wheel scrolls in separate ticks, each with its own start and end notification. So hover comes back after 150 ms of stillness, not on &lt;code&gt;ScrollEndNotification&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Suspect 2: a detail panel that built everything
&lt;/h3&gt;

&lt;p&gt;The detail panel showed the latest 200 related rows. The panel itself was a &lt;code&gt;ListView&lt;/code&gt;, but those 200 rows sat inside it as one &lt;code&gt;Column&lt;/code&gt;, so all 200 were built and laid out at once whenever the selection changed. Worse, the query fetched &lt;em&gt;every&lt;/em&gt; related row (thousands, for busy items) just to show 200 and a count, and each fetched row was mapped into an object on the UI isolate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; turn the panel into a &lt;code&gt;CustomScrollView&lt;/code&gt; with a lazy &lt;code&gt;SliverList.builder&lt;/code&gt;, and have the query use &lt;code&gt;LIMIT 200&lt;/code&gt; plus a separate &lt;code&gt;COUNT&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Suspect 3: an unindexed sort
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;EXPLAIN QUERY PLAN&lt;/code&gt; against the real database showed one sort column doing a full table scan and sort on every page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;|--SCAN rows
`--USE TEMP B-TREE FOR ORDER BY
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One index later:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Sorting by that column&lt;/th&gt;
&lt;th&gt;Time per page&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Before&lt;/td&gt;
&lt;td&gt;~165 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;After&lt;/td&gt;
&lt;td&gt;~0.3 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Another column we assumed needed an index turned out to be covered already. Read the plan before you add indexes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Suspect 4: debug mode
&lt;/h3&gt;

&lt;p&gt;We were also judging smoothness in a debug build. Debug Flutter is JIT-compiled with assertions on, and scrolling a dense list is where that shows most. Measure in &lt;code&gt;--profile&lt;/code&gt;, judge in &lt;code&gt;--release&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;All four fixes helped. &lt;strong&gt;The lag was still there.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Act 3: The observation
&lt;/h2&gt;

&lt;p&gt;The lag had a very particular shape, and writing it down step by step cracked the case:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open a list screen and scroll: &lt;strong&gt;stutters.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Click any row (the detail panel opens) and scroll: &lt;strong&gt;smooth.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Switch to the other list screen and scroll: &lt;strong&gt;stutters&lt;/strong&gt; again.&lt;/li&gt;
&lt;li&gt;Click a row: &lt;strong&gt;smooth.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Switch back to the first screen, where the detail panel is still open from before, and scroll: &lt;strong&gt;stutters&lt;/strong&gt;, until you click a row.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 5 rules out the detail panel, because it was already open and the lag was back. It rules out the selection too. So what does a click change that switching screens undoes?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keyboard focus.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Each list screen focuses its search field when it opens, so you can start typing straight away. On desktop, Flutter drops a text field's focus when you click anywhere outside it. It's right there in &lt;code&gt;EditableText&lt;/code&gt;'s tap-outside action:&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;case&lt;/span&gt; &lt;span class="n"&gt;TargetPlatform&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;linux&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;TargetPlatform&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;macOS&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;TargetPlatform&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;windows&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
  &lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;focusNode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;unfocus&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put that next to the steps above:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;State&lt;/th&gt;
&lt;th&gt;Search field focused?&lt;/th&gt;
&lt;th&gt;Scrolling&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Screen just opened&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;stutters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;After clicking a row&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;smooth&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Came back to a screen&lt;/td&gt;
&lt;td&gt;yes (it focused search again)&lt;/td&gt;
&lt;td&gt;stutters&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A third screen confirmed it independently. It has its own search box, and its list stuttered exactly while that box was focused and scrolled smoothly once it wasn't.&lt;/p&gt;

&lt;p&gt;The click never fixed the list. It fixed the focus.&lt;/p&gt;




&lt;h2&gt;
  
  
  Act 4: Why would focus touch scrolling?
&lt;/h2&gt;

&lt;p&gt;We don't have a profiler trace that nails this down. What follows is what the source shows, not a measured root cause.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Flutter framework side is cheap.&lt;/strong&gt; While a text field is connected to the platform's text input, &lt;code&gt;EditableText&lt;/code&gt; runs a post-frame callback every frame. It reports the caret and composing rectangles, which macOS uses to position the input-method candidate window and the accent menu, and the field's size and transform. But &lt;code&gt;TextInputConnection&lt;/code&gt; caches those values and only messages the platform when something changes. A search box in a fixed header doesn't move while the list below it scrolls, so nothing is sent. That adds a handful of small calculations per frame, nowhere near a dropped frame.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The macOS engine side changes the input path.&lt;/strong&gt; On macOS, Flutter's text input plugin is a hidden &lt;code&gt;NSTextView&lt;/code&gt;. When a field gains focus, that view becomes the window's first responder and gets an &lt;code&gt;NSTextInputContext&lt;/code&gt;. From then on, mouse and scroll events arrive at it first and are passed along:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight objective_c"&gt;&lt;code&gt;&lt;span class="k"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;scrollWheel&lt;/span&gt;&lt;span class="p"&gt;:(&lt;/span&gt;&lt;span class="n"&gt;NSEvent&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nv"&gt;event&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;_currentViewController&lt;/span&gt; &lt;span class="nf"&gt;scrollWheel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;event&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;Our best explanation: with the system's text input machinery active and scroll events taking that detour, the deltas reach Flutter less evenly. Uneven deltas look like stutter even when every frame renders on time. The engine also logged &lt;code&gt;Reported frame time is older than the last one; clamping&lt;/code&gt; during these sessions, which fits timing irregularity but doesn't prove it.&lt;/p&gt;

&lt;p&gt;What we know for certain: the list does no extra work while the field is focused, and taking focus away removes the stutter.&lt;/p&gt;




&lt;h2&gt;
  
  
  Act 5: The fix — let scrolling do what clicking already did
&lt;/h2&gt;

&lt;p&gt;Nobody minded that a click dropped focus from the search field. So we made a scroll do the same.&lt;/p&gt;

&lt;p&gt;The shared grid wraps its list in a small widget that listens for &lt;code&gt;UserScrollNotification&lt;/code&gt;. That notification fires for wheel and trackpad scrolls but &lt;strong&gt;not&lt;/strong&gt; for &lt;code&gt;jumpTo&lt;/code&gt;. So when the arrow keys move the highlight and the list jumps to reveal it, focus stays in the search field and you can keep typing. Only a scroll by hand moves focus, onto a node on the grid itself:&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;class&lt;/span&gt; &lt;span class="nc"&gt;_ScrollLeavesFieldState&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;_ScrollLeavesField&lt;/span&gt;&lt;span class="p"&gt;&amp;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;_focus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;FocusNode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;debugLabel:&lt;/span&gt; &lt;span class="s"&gt;'Grid'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;skipTraversal:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;_onScroll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;UserScrollNotification&lt;/span&gt; &lt;span class="n"&gt;notification&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;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;FocusManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;instance&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;primaryFocus&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;direction&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;ScrollDirection&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;idle&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
        &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="na"&gt;findAncestorWidgetOfExactType&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;EditableText&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;_focus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;requestFocus&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&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="nd"&gt;@override&lt;/span&gt;
  &lt;span class="n"&gt;Widget&lt;/span&gt; &lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BuildContext&lt;/span&gt; &lt;span class="n"&gt;context&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;NotificationListener&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;UserScrollNotification&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
        &lt;span class="nl"&gt;onNotification:&lt;/span&gt; &lt;span class="n"&gt;_onScroll&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nl"&gt;child:&lt;/span&gt; &lt;span class="n"&gt;Focus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;focusNode:&lt;/span&gt; &lt;span class="n"&gt;_focus&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;child:&lt;/span&gt; &lt;span class="n"&gt;widget&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;child&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;Because it lives in the shared grid, every list in the app gets it without any per-screen code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bonus bug: where focus lands matters
&lt;/h3&gt;

&lt;p&gt;Chasing focus turned up a second bug. When the search field dropped focus after a click, focus fell back to the route's &lt;code&gt;FocusScope&lt;/code&gt;, which sits &lt;em&gt;above&lt;/em&gt; the screen's &lt;code&gt;CallbackShortcuts&lt;/code&gt;. Key events bubble up from wherever focus is, so they never reached the shortcuts. After clicking a row, ↑/↓, Enter and Esc silently stopped working.&lt;/p&gt;

&lt;p&gt;Our widget tests had never caught it, because by default they run as Android with touch taps, and a touch tap outside doesn't unfocus there. Rerunning with &lt;code&gt;TargetPlatformVariant.only(TargetPlatform.macOS)&lt;/code&gt; and &lt;code&gt;kind: PointerDeviceKind.mouse&lt;/code&gt; reproduced it on the first try.&lt;/p&gt;

&lt;p&gt;The fix is to give each screen a focus node &lt;em&gt;inside&lt;/em&gt; its shortcuts, and send focus there when it leaves the search field:&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;Widget&lt;/span&gt; &lt;span class="nf"&gt;browseShortcuts&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="n"&gt;Widget&lt;/span&gt; &lt;span class="n"&gt;child&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;CallbackShortcuts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nl"&gt;bindings:&lt;/span&gt; &lt;span class="n"&gt;browseBindings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;child:&lt;/span&gt; &lt;span class="n"&gt;Focus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;focusNode:&lt;/span&gt; &lt;span class="n"&gt;browseFocus&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;child:&lt;/span&gt; &lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// On the search field:&lt;/span&gt;
&lt;span class="nl"&gt;onTapOutside:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&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;browseFocus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;requestFocus&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The grid's own focus node also sits inside the shortcuts, so the keys keep working after a scroll too.&lt;/p&gt;




&lt;h2&gt;
  
  
  What we took away
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A fast list is necessary, not sufficient.&lt;/strong&gt; Pagination, virtualisation and background queries made the list cheap. They couldn't fix something outside it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the bug down as steps.&lt;/strong&gt; The breakthrough wasn't a flame chart. It was listing exactly when the lag appeared and disappeared, then asking what changed between those steps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the query plan.&lt;/strong&gt; &lt;code&gt;EXPLAIN QUERY PLAN&lt;/code&gt; turned "add some indexes" into one index that mattered and one that already existed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test desktop as desktop.&lt;/strong&gt; Flutter's defaults vary by platform and pointer kind. A macOS variant with a mouse pointer found a bug the default tests never could.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Never judge performance in debug mode.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Say what you've proven, and what you haven't.&lt;/strong&gt; We know focus caused the stutter and that removing it fixed it. We have a well-supported explanation of &lt;em&gt;why&lt;/em&gt;, not a measured one, and saying that is part of a good debugging story.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>flutter</category>
      <category>performance</category>
    </item>
    <item>
      <title>The Foldable Reality Check: When Your "Perfect" Responsive Flutter App Meets a Folding Screen</title>
      <dc:creator>Soe Min Thein</dc:creator>
      <pubDate>Sun, 17 May 2026 07:29:40 +0000</pubDate>
      <link>https://dev.to/soemin_thein/the-foldable-reality-check-when-your-perfect-responsive-flutter-app-meets-a-folding-screen-mai</link>
      <guid>https://dev.to/soemin_thein/the-foldable-reality-check-when-your-perfect-responsive-flutter-app-meets-a-folding-screen-mai</guid>
      <description>&lt;p&gt;As developers, we love the feeling of a successful multi-platform launch. Recently, I deployed a Flutter app supporting Android, iOS, macOS, Windows, and tablets with a fully responsive UI. I was incredibly proud of the seamless experience across all four ecosystem types.&lt;/p&gt;

&lt;p&gt;I had tested the breakpoints, verified the desktop scaling, ensured the mobile layouts were tight, and felt like I had genuinely mastered cross-platform UI.&lt;/p&gt;

&lt;p&gt;Then came the ultimate stress test: A shareholder ran the app on a foldable phone.&lt;/p&gt;

&lt;p&gt;Seeing the UI break during the screen transition was a massive reality check. It turns out, adapting to dynamic, unfolding aspect ratios is a completely different beast than building for fixed desktop or tablet screens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Illusion of "Full Responsiveness"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When we think about responsive design, we usually think about static targets. We write logic for a phone screen, a tablet screen, or a desktop monitor. Even when users resize desktop windows, the changes are usually gradual.&lt;/p&gt;

&lt;p&gt;Foldables shatter this paradigm.&lt;/p&gt;

&lt;p&gt;When a user unfolds a device like a Samsung Galaxy Fold, Huawei Mate X6 or a Google Pixel Fold, the app doesn't just get a little bigger. It undergoes an instant, violent shift in aspect ratio—transforming from a narrow, tall cover screen into a nearly square internal display mid-session.&lt;/p&gt;

&lt;p&gt;If your layout logic relies heavily on standard screen-width breakpoints or assumes a traditional landscape/portrait ratio, a foldable device will expose every single flaw in seconds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Expanded Text and Stretched Elements:&lt;/strong&gt; Layouts meant for a vertical phone screen suddenly stretch horizontally, making buttons look comically wide and text unreadable.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The Mid-Session Continuity Snap:&lt;/strong&gt; If your app doesn't gracefully handle the state change during the physical unfolding action, the UI can freeze, clip, or completely crash the layout.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The Uncanny Valley of Aspect Ratios:&lt;/strong&gt; A square-ish screen is neither a traditional phone nor a traditional tablet. It lives in a UI dead-zone that standard responsive code rarely accounts for.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The Humble Pie of Software Engineering&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It’s easy to feel discouraged when an edge case catches you off guard, but it’s also where the real growth happens.&lt;/p&gt;

&lt;p&gt;One minute you are riding high on the pride of supporting six platforms seamlessly, and the next minute a single device humbles your entire codebase. But that is the nature of software development. Our environments change, hardware evolves, and the boundaries of what a "screen" can be are constantly being rewritten.&lt;/p&gt;

&lt;p&gt;If you've recently had your flawless UI broken by a hardware edge case, take heart. You haven't failed; you've just found the next engineering hurdle to clear.&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>ui</category>
      <category>mobile</category>
      <category>responsivedesign</category>
    </item>
  </channel>
</rss>
