DEV Community

Cover image for The Click That Fixed Our Scrolling: Flutter macOS Performance
Soe Min Thein
Soe Min Thein

Posted on

The Click That Fixed Our Scrolling: Flutter macOS Performance

We optimised the data, the queries and the list, and scrolling still stuttered. The real fix came from watching what a single click changed.


Here's a bug report you don't want to get:

"Scrolling is laggy. But if I click a row first, it's smooth."

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.

This is how we got there. First we fixed all the things you're supposed to fix. Then we noticed the one thing we hadn't looked at.


Act 1: Doing everything right

The app is a Flutter desktop app on macOS, backed by a local SQLite database (via drift) holding about 100,000 rows. 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.

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.

Pagination in the database, not the widget

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 LIMIT/OFFSET, plus a COUNT for the "Showing 1–100 of 98,978" footer. Both queries run in one transaction, so the count always matches the rows.

Future<RowPage> search(SearchRequest request) => transaction(() async {
  final count = rows.id.count();
  final totals = await (selectOnly(rows)
        ..addColumns([count])
        ..where(filterFor(request)))
      .getSingle();
  final query = select(rows)
    ..where((_) => filterFor(request))
    ..orderBy([
      (_) => OrderingTerm(expression: sortColumn, mode: direction),
      // Tie-break, so rows never shuffle across a page boundary.
      (_) => OrderingTerm.asc(rows.id),
    ])
    ..limit(pageSize, offset: request.page * pageSize);
  return RowPage(await query.get(), totals.read(count) ?? 0);
});
Enter fullscreen mode Exit fullscreen mode

The database runs on a background isolate (NativeDatabase.createInBackground), so a slow query can never block a frame.

Riverpod as the wiring

The screen never fetches anything itself. The request lives in a Notifier, and the results are a FutureProvider 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.

final searchProvider =
    NotifierProvider<SearchController, SearchRequest>(SearchController.new);

final resultsProvider = FutureProvider<RowPage>(
  (ref) => ref.watch(databaseProvider).search(ref.watch(searchProvider)),
);
Enter fullscreen mode Exit fullscreen mode

The selected row and scroll offset live in providers too, so leaving a screen and coming back lands you exactly where you were.

A virtualised grid

The grid is a ListView.builder with a fixed itemExtent. 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.

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.

Scrolling still wasn't smooth.


Act 2: The usual suspects (all guilty, none the culprit)

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

Suspect 1: hover flicker

Every row had a MouseRegion that called setState 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.

Fix: pause hover while the list moves. A NotificationListener flips a ValueNotifier<bool>, which reaches the rows through an InheritedNotifier. 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 ScrollEndNotification.

Suspect 2: a detail panel that built everything

The detail panel showed the latest 200 related rows. The panel itself was a ListView, but those 200 rows sat inside it as one Column, so all 200 were built and laid out at once whenever the selection changed. Worse, the query fetched every 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.

Fix: turn the panel into a CustomScrollView with a lazy SliverList.builder, and have the query use LIMIT 200 plus a separate COUNT.

Suspect 3: an unindexed sort

EXPLAIN QUERY PLAN against the real database showed one sort column doing a full table scan and sort on every page:

|--SCAN rows
`--USE TEMP B-TREE FOR ORDER BY
Enter fullscreen mode Exit fullscreen mode

One index later:

Sorting by that column Time per page
Before ~165 ms
After ~0.3 ms

Another column we assumed needed an index turned out to be covered already. Read the plan before you add indexes.

Suspect 4: debug mode

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 --profile, judge in --release.

All four fixes helped. The lag was still there.


Act 3: The observation

The lag had a very particular shape, and writing it down step by step cracked the case:

  1. Open a list screen and scroll: stutters.
  2. Click any row (the detail panel opens) and scroll: smooth.
  3. Switch to the other list screen and scroll: stutters again.
  4. Click a row: smooth.
  5. Switch back to the first screen, where the detail panel is still open from before, and scroll: stutters, until you click a row.

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?

Keyboard focus.

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 EditableText's tap-outside action:

case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
  intent.focusNode.unfocus();
Enter fullscreen mode Exit fullscreen mode

Put that next to the steps above:

State Search field focused? Scrolling
Screen just opened yes stutters
After clicking a row no smooth
Came back to a screen yes (it focused search again) stutters

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.

The click never fixed the list. It fixed the focus.


Act 4: Why would focus touch scrolling?

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

The Flutter framework side is cheap. While a text field is connected to the platform's text input, EditableText 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 TextInputConnection 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.

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

- (void)scrollWheel:(NSEvent*)event {
  [_currentViewController scrollWheel:event];
}
Enter fullscreen mode Exit fullscreen mode

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 Reported frame time is older than the last one; clamping during these sessions, which fits timing irregularity but doesn't prove it.

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


Act 5: The fix — let scrolling do what clicking already did

Nobody minded that a click dropped focus from the search field. So we made a scroll do the same.

The shared grid wraps its list in a small widget that listens for UserScrollNotification. That notification fires for wheel and trackpad scrolls but not for jumpTo. 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:

class _ScrollLeavesFieldState extends State<_ScrollLeavesField> {
  final _focus = FocusNode(debugLabel: 'Grid', skipTraversal: true);

  bool _onScroll(UserScrollNotification notification) {
    final context = FocusManager.instance.primaryFocus?.context;
    if (notification.direction != ScrollDirection.idle &&
        context?.findAncestorWidgetOfExactType<EditableText>() != null) {
      _focus.requestFocus();
    }
    return false;
  }

  @override
  Widget build(BuildContext context) =>
      NotificationListener<UserScrollNotification>(
        onNotification: _onScroll,
        child: Focus(focusNode: _focus, child: widget.child),
      );
}
Enter fullscreen mode Exit fullscreen mode

Because it lives in the shared grid, every list in the app gets it without any per-screen code.

Bonus bug: where focus lands matters

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

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 TargetPlatformVariant.only(TargetPlatform.macOS) and kind: PointerDeviceKind.mouse reproduced it on the first try.

The fix is to give each screen a focus node inside its shortcuts, and send focus there when it leaves the search field:

Widget browseShortcuts({required Widget child}) => CallbackShortcuts(
  bindings: browseBindings,
  child: Focus(focusNode: browseFocus, child: child),
);

// On the search field:
onTapOutside: (_) => browseFocus.requestFocus(),
Enter fullscreen mode Exit fullscreen mode

The grid's own focus node also sits inside the shortcuts, so the keys keep working after a scroll too.


What we took away

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

Top comments (1)

Collapse
 
ahmadmukhtiar profile image
Ahmad Mukhtiar •

Writing the lag down as steps, and using step 5 to rule out the detail panel, is the real lesson here. Did a plain unfocus() on UserScrollNotification behave any differently, or did you need the grid's own focus node mainly to keep the CallbackShortcuts alive? The TargetPlatformVariant + mouse pointer tip is going straight into our widget tests.