Tutorial · 12 min · intermediate

Lists that never jank

A feed that paginates from 50 to 10,000 rows on a real UITableView/RecyclerView — native scroll telemetry, flat memory via content windowing, index-based scrolling.

What you'll build — running on device.

The long list is the mobile UI. It’s also where cross-platform frameworks spend their credibility — dropped frames on mid-range Android, memory climbing as the user scrolls. FastList sidesteps the fight: it is a UITableView on iOS and a RecyclerView on Android, with Dart building rows on demand. You’ll build the playground’s FastList demo — two flavors (featherweight text rows and photo rows), pagination from 50 to 10,000, content windowing, and index-based scrolling — in one screen.

What you need

Step 1 — A native list from one builder

A row is just a widget function, and the list is one call:

Widget _cell(int i) => Container(
      height: 56,
      padding: const EdgeInsets.symmetric(horizontal: 16),
      decoration: const BoxDecoration(
        border: Border(bottom: BorderSide(color: Color(0x22FFFFFF))),
      ),
      alignment: Alignment.centerLeft,
      child: Text('Item $i',
          style: TextStyle(fontSize: 16, color: kTextPrimary)),
    );

FastList(
  key: key,
  controller: _listController,
  itemCount: _count,
  stableItems: true, // append-only → O(page) load-more
  onScroll: _onScroll,
  itemBuilder: (_, i) => _cell(i),
)

That’s a working virtualized list: only rows near the viewport exist as native views, recycled by the platform as you scroll — itemBuilder cost is O(visible), never O(itemCount). The builder API matches ListView.builder, so feeds port mechanically (the k* colors are the playground UI kit’s theme shorthands). Three of those arguments — onScroll, stableItems, controller — are the levers this tutorial unpacks; the key remounts the list when you flip flavors, which is also how the demo times a fresh mount (Step 6).

Step 2 — Paginate from native scroll telemetry

Ten thousand rows shouldn’t be built up front — they should arrive as the user approaches them. Start small for a fast first mount and let scrolling grow the count:

static const int _initial = 50;
static const int _page = 50;
static const int _max = 10000;
int _count = _initial;
bool _growing = false;

void _onScroll(double offset, double maxExtent, double viewport, bool drag) {
  if (_growing || _count >= _max) return;
  if (maxExtent - offset > 4.0 * viewport) return;
  _growing = true;
  WidgetsBinding.instance.addPostFrameCallback((_) {
    if (!mounted) return;
    setState(() {
      _count = (_count + _page).clamp(0, _max);
      _growing = false;
      _result = '$_type · N=$_count · ${_rssLabel()}';
    });
  });
}

onScroll streams (offset, maxExtent, viewport, dragging) straight from the native scroll view — offset is how far you’ve scrolled, maxExtent is how far you can, viewport is the visible height. It replaces the ScrollController listener pattern. Two habits worth keeping:

  • Fetch early — grow when within 4 viewports of the end, so new pages are built before the fling reaches them and the user never sees a loading row.
  • Defer the insert one frame (addPostFrameCallback), so appending rows never competes with the native scroll animation for frame time — and guard with _growing so one approach triggers one page.

Step 3 — Tell the list its data is stable

stableItems: true, // append-only → O(page) load-more

Every setState that changes _count makes the reconciler compare the old list to the new one. With stableItems: true it knows existing indices never change meaning — row 12 is still row 12 after a load-more — so the diff touches only the appended page: O(page) instead of O(total), which is what keeps page 199 as cheap as page 1. Right for feeds and logs; leave it off if you insert, remove, or reorder.

Step 4 — Image rows: decode at cell size

Flip the demo’s segmented control to Images and each row carries a photo. The row itself is ordinary widget code — the discipline is in the two decode arguments:

Widget _imgCell(int i) => Container(
      height: 100,
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
      decoration: const BoxDecoration(
        border: Border(bottom: BorderSide(color: Color(0x22FFFFFF))),
      ),
      child: Row(
        children: [
          SizedBox(
            width: 92,
            height: 92,
            child: Image.network(
              'https://picsum.photos/seed/dn$i/800/800',
              fit: BoxFit.cover,
              cacheWidth: _fullRes ? null : 280,
              cacheHeight: _fullRes ? null : 280,
              placeholder: Shimmer.fromColors(
                baseColor: kRowBg,
                highlightColor: const Color(0xFF424242),
                child: Container(color: kRowBg),
              ),
            ),
          ),
          const SizedBox(width: 12),
          Text('Item $i',
              style: TextStyle(fontSize: 16, color: kTextPrimary)),
        ],
      ),
    );

cacheWidth/cacheHeight decode the bitmap at the size the cell will display, not the size the server sent: 280 px (about 3× the 92-pt cell, so it stays sharp on 3× screens) costs ~0.3 MB decoded, where the 800² source would cost ~2.5 MB — an 8× difference multiplied by every row you scroll past. The demo’s _fullRes flag (a const, normally false) drops the sizing to decode full-resolution bitmaps — the heavy stress test for Step 5’s windowing. The Shimmer placeholder covers the ~ms it takes a cached image to decode from disk, so rows never flash the bare cell background.

Step 5 — Flat memory at any depth

Recycling bounds the views, but the content of every built row — image bitmaps above all — would still accumulate as you scroll deeper. Content windowing caps it. The Images flavor is the same FastList with one more lever:

FastList(
  key: key,
  controller: _listController,
  itemCount: _count,
  keepAliveCount: 30,
  stableItems: true, // append-only → O(page) load-more
  onScroll: _onScroll,
  itemBuilder: (_, i) => _imgCell(i),
)

With keepAliveCount: 30, the visible rows plus 30 on each side stay built; rows farther out release their content — the slot keeps its measured height, so scrollbars don’t twitch — and rebuild just before you scroll back to them, so there’s no seam. Held content becomes O(visible + 2×keepAliveCount), independent of itemCount: an image feed that would otherwise climb toward out-of-memory stays flat whether you’re at row 100 or row 10,000.

Step 6 — Scroll by index, measure the mount

Native lists think in rows, not pixels. FastListController is the Fast-family replacement for a pixel ScrollController:

final _listController = FastListController();

// in the AppBar:
BarButtonItem(
  title: 'ScrollTo #4',
  titleStyle: TextStyle(
    color: kTextPrimary,
    fontSize: 15,
    fontWeight: FontWeight.w500,
  ),
  onPressed: () => _listController.jumpToItem(4),
),

jumpToItem(index) is the platform-correct way to scroll programmatically — no pixel math, and it works no matter how rows vary in height. Finally, the demo remounts the list whenever you flip flavors (new key) and times what the user actually feels — setState to first frame:

void _run(String type) {
  ImageCache.clearMemory();
  _baselineRssBytes = ProcessInfo.currentRss;
  final sw = Stopwatch()..start();
  setState(() {
    _type = type;
    _count = _initial;
    _buildId++;
  });
  WidgetsBinding.instance.addPostFrameCallback((_) {
    sw.stop();
    if (!mounted) return;
    final line =
        '$type · N=$_count · mount=${sw.elapsedMilliseconds}ms · ${_rssLabel()}';
    dnLog('[DN-BENCH] $line');
    setState(() => _result = line);
  });
}

The RSS=… part is the playground’s honest-memory harness: _rssLabel() reads ProcessInfo.currentRss (the whole app’s resident memory) and reports Δ against _baselineRssBytes — a baseline captured at screen entry and re-captured on every run, each time after ImageCache.clearMemory(), so Δ is what this list actually adds and every visit reads the same. The leak signal is a Δ that keeps climbing instead of plateauing.

The result line reads something like Text · N=50 · mount=23ms · RSS=219MB (Δ+2MB) — a 50-row native list mounting in a frame or two, then growing to 10,000 without the number you feel (frame time) ever changing.

Why this is native

Flutter’s ListView re-implements scrolling and rasterizes rows onto its own canvas; React Native’s FlatList virtualizes cells from JS. Here the fling curve, the overscroll behavior, the recycling are UITableView’s and RecyclerView’s own — the code paths every native app on the device uses — while Dart does only what it’s good at: building the row that’s about to appear.

The finished code

The public repo carries the playground’s FastList demo screen byte-for-byte (plus its shared UI kit), with only a thin main.dart entry — dn create ., dn run. When the playground screen improves, this tutorial inherits it verbatim.

Open the finished code →