skip to content

In a Flutter photo viewer, how do precacheImage and gaplessPlayback each prevent a blank flash when the user swipes to the next listing photo?

level: middleimportance: should knowfreq 33%

answer

  1. precacheImage(provider, context)
  2. same provider, same key
  3. call it after initState
  4. the future never completes with an error
  5. gaplessPlayback keeps the old frame

basics

~20 s

precacheImage decodes the next photo into the ImageCache before it is shown, so the swipe finds it ready. gaplessPlayback, false by default, keeps an Image showing its old picture while a changed provider loads, instead of going blank.

solid answer

~40 s

`precacheImage(provider, context)` resolves the provider with the context's image configuration and completes when the image is decoded and cached; later an `Image` with an **equal** provider, meaning the same URL and the same `cacheWidth` so the `ResizeImage` key matches, shows it in its first frame. It reads inherited widgets, so I call it from `didChangeDependencies` or later, never `initState`, and its future never completes with an error; failures go to `onError`. `gaplessPlayback` solves a different case: one `Image` widget whose provider changes, for example a single hero photo swapped when the user taps a thumbnail. With the default `false` it shows nothing while the new image loads; with `true` it keeps the old frame. The default avoids pairing a new listing with an old photo.

code

dart · 43 lines
dart
import 'package:flutter/material.dart';

class ListingViewer extends StatefulWidget {
  const ListingViewer({super.key, required this.urls});

  final List<String> urls;

  @override
  State<ListingViewer> createState() => _ListingViewerState();
}

class _ListingViewerState extends State<ListingViewer> {
  ImageProvider _providerFor(String url) {
    final width = (MediaQuery.sizeOf(context).width *
            MediaQuery.devicePixelRatioOf(context))
        .round();
    return ResizeImage(NetworkImage(url), width: width);
  }

  void _precacheAround(int index) {
    for (final i in [index - 1, index + 1]) {
      if (i >= 0 && i < widget.urls.length) {
        precacheImage(_providerFor(widget.urls[i]), context);
      }
    }
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    _precacheAround(0);
  }

  @override
  Widget build(BuildContext context) {
    return PageView.builder(
      itemCount: widget.urls.length,
      onPageChanged: _precacheAround,
      itemBuilder: (context, index) =>
          Image(image: _providerFor(widget.urls[index]), fit: BoxFit.contain),
    );
  }
}

go deeper

for a junior

Know that precacheImage loads an image before it is shown and that gaplessPlayback keeps the old picture while a new one loads.

for a middle

Explain key matching with ResizeImage, why precacheImage belongs in didChangeDependencies, its error behaviour, and why gaplessPlayback defaults to false.

for a senior

Precache only what the user will see next, share providers between precache and display, and choose gaplessPlayback per surface so stale photos never mislead.

for a principal

Balance perceived speed against memory for image-heavy flows, and set rules for how far ahead any screen may prefetch.

## Two different causes of a blank frame A photo viewer can flash empty for two different reasons, and each has its own tool: | Situation | Cause | Tool | |---|---|---| | A new `Image` widget appears (next page in a `PageView`) | its image is not decoded yet | **`precacheImage`** | | An existing `Image` gets a new provider (tap a thumbnail to swap the main photo) | the widget drops the old frame while the new one loads | **`gaplessPlayback: true`** | ## precacheImage `precacheImage` is a top-level function in the widgets library: ```dart Future<void> precacheImage( ImageProvider provider, BuildContext context, { Size? size, ImageErrorListener? onError, }) ``` What it does: 1. Builds an `ImageConfiguration` from the context (asset bundle, device pixel ratio, locale, text direction, platform). 2. Resolves the provider, which starts loading and decoding and puts the result in the `ImageCache`. 3. Completes its future when the image has loaded, or when loading has failed. **It never completes with an error**; pass `onError` to find out about failures. 4. Keeps its own listener until the end of that frame, so the image stays live long enough for a widget to pick it up; after that, whether it stays depends on the cache limits. ### Rules for it to work - **Match the key.** The cache is keyed by the provider. `Image.network(url, cacheWidth: 1080)` resolves a `ResizeImage` around `NetworkImage(url)`, so precaching `NetworkImage(url)` alone fills a *different* entry. Precache `ResizeImage(NetworkImage(url), width: 1080)`, or build the provider once and share it. - **Call it at the right time.** It reads inherited widgets such as `MediaQuery`, which is not allowed in `initState`. Use `didChangeDependencies`, or a page-change callback in the viewer. - **Stay small.** Precache the next and previous photo, not the whole listing: every precached full-screen photo costs its full decoded size and can evict something you need. ## gaplessPlayback `gaplessPlayback` is a `bool` on every `Image` constructor, **default `false`**. It matters only when the **same** `Image` element receives a **different** provider: - `false`: the old image is dropped immediately, and the widget paints nothing until the new image arrives. - `true`: the old image stays on screen until the new one is ready. The framework documents why `false` is the default: most of the time a provider change means the content changed, such as a different listing, and showing the previous photo next to the new listing's price, especially if the new photo then fails, would be misleading. Turn it on where the old picture remains a reasonable stand-in, such as switching between photos of the same property. ## What precaching costs Precaching trades memory for smoothness. Each precached full-screen photo is decoded at its full requested size, and it stays only as long as the `ImageCache` keeps it: once `precacheImage` releases its listener at the end of the frame, the image is an ordinary cache entry that can be evicted by later loads. Precaching ten photos ahead on a phone with a small cache can evict the very photo the user is about to swipe to. You can check whether a provider is still cached with `provider.obtainCacheStatus(configuration: ...)`, which reports whether the key is pending, kept alive or live. ## Putting both together in a listing viewer 1. The viewer is a `PageView.builder` of `Image.network(url, cacheWidth: ...)`. 2. In `onPageChanged` (and once in `didChangeDependencies`), precache the next and previous photos with the **same** provider the page will build. 3. Above the viewer, a large preview swaps its URL when the user taps a thumbnail; that `Image` sets `gaplessPlayback: true` so the swap has no gap. 4. Both use `errorBuilder` so a missing photo shows a tile instead of an empty page.

  • Why can precacheImage(NetworkImage(url), context) still leave a blank frame for Image.network(url, cacheWidth: 1080)?
    `Image.network` with `cacheWidth` wraps the provider in a `ResizeImage`, whose key includes the target width. Precaching the bare `NetworkImage` decodes and caches a different, full-size entry, so the widget's resized key is still a cache miss. Precache the same `ResizeImage`, or share one provider object between the precache call and the widget.
  • How do you find out that a precache failed?
    Pass `onError` to `precacheImage`. Its returned future completes normally even when loading fails, because precaching is a best-effort optimisation; without `onError`, the error is reported to `FlutterError` silently. The widget that later shows the image still gets the failure through its own `errorBuilder`.

saying these in an interview costs you the question

  • precacheImage's future completes with an error when the download fails.
  • Calling precacheImage in initState is the right place.
  • Precaching NetworkImage(url) also covers Image.network(url, cacheWidth: 600).
  • gaplessPlayback defaults to true.
  • gaplessPlayback preloads the next image before it is requested.