Tutorial · 15 min · intermediate
Photo grid, infinitely
An infinite-scrolling photo grid on a real UICollectionView/RecyclerView — native load-more, shimmer placeholders, right-sized decoding, and a masonry tab.
What you'll build — running on device.
Image grids are where cross-platform UIs usually give in: thousands of
cells, network images, fling scrolling. FastGrid lowers to the
platform’s own collection machinery — UICollectionView on iOS,
RecyclerView on Android — so cells are recycled natively and cost stays
O(visible) however deep the user scrolls. You’ll build a three-tab screen
straight from the DartNative playground: an infinite photo grid with
native load-more, right-sized decoding and shimmer placeholders; a
masonry variant; and a static-grid showcase for the small cases.
What you need
- A project from Your first DartNative app
Step 1 — The grid
FastGrid(
// Honest count — no phantom placeholder cells: mounting placeholders
// and shrinking the count on the first fetch is misleading UI and a
// needless recycling stress.
itemCount: _imageUrls.length,
crossAxisCount: 3,
childAspectRatio: 1.0,
mainAxisSpacing: 2,
crossAxisSpacing: 2,
padding: const EdgeInsets.symmetric(horizontal: 2),
gridController: _gridController, // ScrollTo #4 (index) from the topbar
onScroll: _onGridScroll, // infinite load-more (replaces ScrollController)
keepAliveCount: 90, // windowing (ITEMS not rows)
itemBuilder: (_, i) => _InfiniteImageTile(
url: i < _imageUrls.length ? _imageUrls[i] : '',
// Always set for image cells: unset = full-res decode (no automatic
// downsampling — see Image.cacheWidth docs).
cacheSize: _kCacheSize,
),
)
The API reads like GridView.builder, but itemBuilder feeds native cell
recycling — only visible cells exist as views. Note the honest item
count: the grid renders exactly the photos it has (zero before the first
page lands, fetched in initState). Padding the count with phantom
placeholder cells looks clever but is misleading UI, and mounting slots
that immediately shrink is needless recycling stress.
keepAliveCount: 90 is content windowing: cells far off-screen release
what they hold, so memory stays flat at any scroll depth — windowing
counts items, not rows (the FastList tutorial
unpacks how it works). And gridController is a FastGridController;
the app bar’s ScrollTo #4 button calls jumpToItem(4) — programmatic
scrolling by index, the platform-correct way, no pixel math.
(For small static grids — the demo’s first tab — plain GridView.count
and GridView.builder are also available and compose inside any
ListView. FastGrid is for the big, scrolling case.)
Step 2 — Load more, from native scroll telemetry
There’s no ScrollController polling — the native scroll view streams its
state to Dart:
// FastGrid.onScroll — the Fast-family replacement for a ScrollController
// listener. Fires natively with (offset, maxExtent, viewport, dragging); fetch
// the next page when within 2.5 viewports of the end. Only the Infinite-Grid
// tab passes onScroll, so no tab guard is needed.
void _onGridScroll(
double offset,
double maxExtent,
double viewport,
bool dragging,
) {
if (_isFetching || _isLastPage) return;
final remaining = maxExtent - offset;
if (remaining > 2.5 * viewport) return;
_fetchNextPage();
}
Fetching within 2.5 viewports of the end means the next page usually
arrives before the user reaches it — infinite scroll that never shows a
spinner row. The fetch itself is ordinary HttpClient + jsonDecode
against the picsum API, with two subtleties worth copying verbatim:
// setState is required: it pumps the Dart event loop during native scroll
// callback floods. Without it, HTTP futures never complete.
setState(() => _isFetching = true);
and, once the page arrives:
// Defer the visual update (adding images to grid) until after the current
// scroll frame completes. This avoids jank during active scrolling.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() {
_imageUrls.addAll(newUrls);
_nextPage++;
if (newUrls.length < _pageSize) _isLastPage = true;
_isFetching = false;
});
}
});
Inserting rows mid-fling would compete with the native scroll animation for the frame; deferring one frame keeps the fling silky.
Step 3 — Decode at cell size
Every image cell in the demo is this tile:
Image.network(
url,
fit: BoxFit.cover,
cacheWidth: cacheSize, // decode at this px size (null = full res)
cacheHeight: cacheSize,
placeholder: Shimmer.fromColors(
baseColor: kRowBg,
highlightColor: const Color(0xFF424242),
child: Container(color: kRowBg),
),
)
(kRowBg, kTextPrimary and friends are theme shorthands from the
playground’s shared UI kit — demo_ui.dart, which ships with the screen.)
This is the difference between a grid that survives a thousand photos and one that gets killed by the OS. Unset, an image decodes at full source resolution — a 4000-px photo in a 130-dp tile costs ~64 MB of decoded bitmap instead of ~0.4 MB. The demo sets one decode target for every cell:
// Decode target for grid images (px, longest edge). Since cacheWidth/
// cacheHeight shipped, an UNSET image decodes at FULL source resolution on
// both platforms (Android's Coil no longer auto-downsizes to the view) —
// always set it for image cells. ~2× the ~130dp cell ≈ crisp + tiny.
static const int _kCacheSize = 300;
About 2× the cell’s dp size: crisp on retina, memory flat. The
placeholder: slot takes any widget while the network fetch runs;
Shimmer.fromColors gives the familiar loading sheen.
Step 4 — Masonry
Same engine, variable heights — MasonryFastGrid adds one callback:
MasonryFastGrid(
itemCount: _masonryCount,
crossAxisCount: 2,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
padding: const EdgeInsets.all(8),
gridController: _masonryController, // ScrollTo #4 (index) from the topbar
onScroll: _onMasonryScroll, // load-more (grows the procedural count)
keepAliveCount: 90, // windowing (ITEMS not rows)
itemHeightBuilder: (i) => _masonryHeights[i % _masonryHeights.length],
itemBuilder: (_, i) {
final k = i % 100;
final h = _masonryHeights[k % _masonryHeights.length].toInt();
final url = 'https://picsum.photos/seed/dn$k/400/$h';
return Image.network(url, fit: BoxFit.cover, /* …as in Step 3 */);
},
)
itemHeightBuilder gives each index its height up front — so the native
layout can pack the columns without measuring widgets — and BoxFit.cover
absorbs any aspect-ratio difference per cell. This tab’s load-more is the
same onScroll pattern as Step 2, just growing a procedural count (20 at
a time, up to 1000) instead of fetching pages.
One field lesson baked into that i % 100: perceived shimmer is often
data, not framework. This tab once shimmered far more than the
infinite grid with the exact same pipeline — because each cell requested a
unique fresh-seed image (~1.5–2 s of CDN-cold generation each) while the
grid recycled ~100 CDN-warm URLs. When two lists shimmer differently,
compare their image sources first: distinct-URL count, CDN warmth, and
per-request latency dominate what the user sees.
Step 5 — Tabs that behave like native tabs
The SegmentedControl doesn’t swap subtrees — all three tabs stay
mounted, and switching flips an index:
child: IndexedStack(
index: _tabIndex,
children: [
_tabGrid ??= _buildListViewContent(),
_memoMasonryTab(),
_memoInfiniteTab(),
],
),
That’s exactly what UIKit container view controllers and
UITabBarController do: children stay alive, switching is a view swap,
never a teardown+rebuild — so scroll positions and pagination state
persist per tab. The _memo… helpers add the second half of the
pattern — memoization:
Widget _memoInfiniteTab() {
if (_tabInfinite == null || _tabInfiniteCountKey != _imageUrls.length) {
_tabInfiniteCountKey = _imageUrls.length;
_tabInfinite = _buildFastGridContent();
}
return _tabInfinite!;
}
Returning the identical widget instance across unrelated setStates
lets the reconciler’s identical() short-circuit skip the whole subtree —
each tab rebuilds only when its inputs change, so a pagination update on
the infinite grid never re-walks the masonry tab’s item elements.
Why this is native
Flutter’s grids rasterize cells onto its own canvas and re-implement scroll physics; a deep image grid is a compositing workout. Here the scrolling, recycling and fling physics belong to
UICollectionView/RecyclerView— the exact machinery behind the iOS Photos app — and Dart only builds the visible cells. The scroll feel isn’t “close to native”; it’s the same code path.
The finished code
In the public repo — dn create ., dn run. The screen (GridDemo in
lib/screens/grid_demo.dart) is the playground’s grid demo carried
byte-identical, with its shared UI kit, under a thin main.dart: when the
playground screen improves, this tutorial updates by copying the file
again. The copy keeps the playground’s [DN-RSS] readout — its
honest-memory instrumentation for profiling image memory, off by default.