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
- A project from Your first DartNative app
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_growingso 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
ListViewre-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 areUITableView’s andRecyclerView’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.