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

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.

Open the finished code →