Contact Me
Blog

Fancy Network Images with Flutter

When developing for mobile, there are a few things one needs to consider when it comes to using network resources; namely network speed and reliability. Since images are generally among the biggest assets on the web and in dynamic views such as news feeds, they're an area that demands particularly close attention.

Moreover, because of the size of most mobile devices, images appearing after the main content has been displayed can significantly harm the user experience. You’ll have seen this if you’ve ever got partway through article and suddenly lost your place because an image has loaded, replacing an empty space with no height set and shunting the text down the page — or even off the screen entirely.

Last but not least; with a native mobile application, it's reasonable to expect a satisfactory offline experience, even if not all of an app's features are possible.

In this post, I’ll explore some of the ways you can address these issues in order to enhance the user experience using Flutter.

Specifically, I’ll look at:

  • Using decorative placeholders
  • Indicating that an image is loading
  • Animating the transition
  • Handing errors
  • Checking network connectivity
  • Caching images for better performance and offline use
  • Pre-caching images

Rather than describe what we're going to build, here's a very short video (that's been slowed down) by way of a demonstration:

Caching Images

I'm going to start with caching not just because it's arguably the most important aspect when thinking about network images on mobile, but also because the package I'm going to use also takes care of some other aspects; displaying a loading state, handling certain errors and supporting transitions.

The package in question is cached_network_image, and it can be installed in the usual way:

flutter pub add cached_network_image

Or:

dependencies:
  cached_network_image: ^3.3.0

Import it with:

import 'package:cached_network_image/cached_network_image.dart';

Basic Usage

At its most basic, you simply replace your existing network images:

return Image.network('https://example.com/path/to/image.jpg');

...with this:

return CachedNetworkImage(
    imageUrl: 'https://example.com/path/to/image.jpg',
);

As you would expect, this displays the image from the network first time around, then subsequently from the cache.

Behind the scenes, it uses the cache-control HTTP header; that's outside of the scope of this post — but you may wish to check out the underlying caching library for more details.

You can also specify how the image should fill the available space:

return CachedNetworkImage(
    imageUrl: 'https://example.com/path/to/image.jpg',
    fit: BoxFit.cover,
);

Simply by installing a package and swapping out some components, we've implemented caching for images. However, we can make it even better.

Loading State

The cached image component allows you to provide a builder function to indicate progress; either when the image is being retrieved over the network, or for the very short delay while it fetches it from storage.

The progress indicator also acts as an image placeholder; we'll look at that in the next section.

For now, here's how to display an indeterminate progress indicator:

CachedNetworkImage(
    imageUrl: widget.url,
    fit: BoxFit.cover,
    progressIndicatorBuilder: (context, url, downloadProgress) => 
      const CircularProgressIndicator(color: Colors.white,),
    )
);

Note that the builder function also provides a DownloadProgress instance, should you prefer a determinate indicator; e.g. the percentage downloaded.

A Better Loading State

There are a few issues with just displaying a progress indicator.

First, it's not obvious that we're waiting for an image. Second, its size bears no relation to that of the image. Less of a problem but worth considering; it provides no real idea of what the image is going to look like.

We could simply display a rectangular area the same size of the image, so let's look at how to do that before enhancing it.

It's highly unlikely that you'll display an image at its original size, no matter how many derivatives (e.g. small, medium and large) versions you create; simply because of the vast number of permutations relating to screen size and orientation.

That's where Flutter's excellent AspectRatio component comes in.

Suppose we know that the image is 4:3; four divided by three is 1.33333 - so we can do this:

AspectRatio(
    aspectRatio: 1.33333,
    child: Container(),)
);

In practice, the aspect ratio will likely vary according to the source image; so when returning information about an image from an API, say, simply include that value. To calculate it, simply divide the width by the height. We'll come back to that shortly.

Let's re-add the progress indicator; we can do that using a combination of the Stack and Center widgets:

return AspectRatio(
  aspectRatio: 1.33333,
  child: Stack(
    children: [
      Container(decoration: const BoxDecoration(color: Colors.black54),),
      const Center(
        child: CircularProgressIndicator(color: Colors.white,),
      )
    ],
  ),
);

This gives us the following:

A very basic loading indicator for an image in Flutter

Getting there — but let's make it look better by using a placeholder image instead.

Placeholder Images

There are various techniques for displaying placeholder images, from using generic graphics to using tiny versions of the original image and blowing it up. The latter, however, requires an additional network request. That's why my preferred method is typically BlurHash.

Introducing BlurHash

Suppose we have the following image:

An example image in order to demonstrate BlurHash; an inflatable toy on water.

The BlurHashed equivalent looks like this:

An example image in order to demonstrate BlurHash; an inflatable toy on water - this time, blurred out.

What does a BlurHash look like behind-the-scenes?

A short (approx 20-30 characters) string:

LNKB%R}]47jF7|xuzqwJ0xsorsS#

This is nice-and-easy, not to mention efficient to pull in from an API. We'll look at that shortly; but first — where the heck do you get that seemingly random string from?

Generating the BlurHash

I'm focusing primarily on the Flutter side in this post, but obviously you'll need a way of calculating the BlurHash on your server. The good news is that there are implementations for just about every language you can think of. You'll find a small selection here.

When I'm developing a backend in Laravel, I'll typically use Laravel Media Library and take the following approach:

  1. Associate an image with a model
  2. Create a migration to add a blurhash column to the media table
  3. Create an observer that listens to the MediaHasBeenAddedEvent event, which fires once the media file has been saved to disk
  4. Check that the media is an image by inspecting the MIME type
  5. Creating a queued job to calculate the BlurHash and store it with the model

To actually create the BlurHash, I use this PHP library, which comes complete with an optional Laravel integration.

I've also created a Laravel package that integrates with Laravel Media Library that automates the whole process using the above approach.

Providing the BlurHash

Typically, when I include an image in an API response, I'll structure it a little like this:

{
  "id": 123,
  "name": "South Cost",
  "photo": {      
      "sizes": {
        "thumbnail": "https://example.com/locations/123/images/thumbnail/beach.jpg",
        "small": "https://example.com/locations/123/images/small/beach.jpg",
        "medium": "https://example.com/locations/123/images/medium/beach.jpg",
        "large": "https://example.com/locations/123/images/large/beach.jpg"
      }
  }
}

This makes it straightforward to include the blurhash string, along with the aspect ratio we considered earlier:

{
  "id": 123,
  "name": "South Cost",
  "photo": {
      "blurhash": "LNKB%R}]47jF7|xuzqwJ0xsorsS#",
      "sizes": {
        "thumbnail": "https://example.com/locations/123/images/thumbnail/beach.jpg",
        "small": "https://example.com/locations/123/images/small/beach.jpg",
        "medium": "https://example.com/locations/123/images/medium/beach.jpg",
        "large": "https://example.com/locations/123/images/large/beach.jpg"
      }
  }
}

The above example assumes that each of the derivatives / sizes has an equal aspect ratio; in the example above the thumbnail is a square:

{
  "id": 123,
  "name": "South Cost",
  "photo": {
      "blurhash": "LNKB%R}]47jF7|xuzqwJ0xsorsS#",
      "sizes": {
        "thumbnail": {
          "url": "https://example.com/locations/123/images/thumbnail/beach.jpg",
          "aspect_ratio": 1
        },
        "small": {
          "url": "https://example.com/locations/123/images/small/beach.jpg",
          "aspect_ratio": 1.5        
        },
        "medium": {
          "url": "https://example.com/locations/123/images/medium/beach.jpg",
          "aspect_ratio": 1.5
        },
        "large": {
          "url": "https://example.com/locations/123/images/large/beach.jpg",
          "aspect_ratio": 1.5
        }
      }
  }
}

It should be noted that the one caveat of using BlurHash is that the rendering component needs to know how big it is; however in this instance we've already addressed that.

Putting it Together

Now that we have three pieces of information:

  • The image URL
  • The BlurHash value
  • The aspect ratio

...we can put it all together and replace that rather dull grey box with something a lot nicer.

First, you'll need to install the flutter_blurhash library:

flutter pub add flutter_blurhash

Or:

dependencies:
  flutter_blurhash: ^0.8.2

Let's create a stateful widget:

import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_blurhash/flutter_blurhash.dart';

class FancyImage extends StatefulWidget {

  const FancyImage({super.key, required this.url, required this.aspectRatio, required this.blurhash});

  final String url;
  final double aspectRatio;
  final String blurhash;

  @override
  State<FancyImage> createState() => _FancyImageState();
} 

Next, the method to create the widget's state:

class _FancyImageState extends State<FancyImage> {

  @override
  Widget build(BuildContext context) {

    return AspectRatio(
      aspectRatio: widget.aspectRatio, 
      child: CachedNetworkImage(
        imageUrl: widget.url,
        fit: BoxFit.cover,
        progressIndicatorBuilder: (context, url, downloadProgress) => 
          AspectRatio(
            aspectRatio: 2,
            child: LayoutBuilder(
              builder: (context, constraints){
                debugPrint(constraints.toString());
                return Stack(
                  children: [
                    BlurHash(hash: widget.blurhash),                    
                    const Center(
                      child: CircularProgressIndicator(color: Colors.white,),
                    )
                  ],
                );
              }
            ),
          )
      )
    );

  }
}

The only thing that's new here is that we've swapped out the dull grey Container widget with a BlurHash one.

Here's what it looks like in static form:

A placeholder with a circular progress indicator.

For a better idea of what it looks like, scroll to the top of the page to see it in video form — or clone the repository to see it in action locally.

Handling Errors

We also need a way to communicate with users when something goes wrong. We can do that by providing a widget to be displayed when an error occurs.

Here's a simple example of a widget to indicate a problem:

class _LoadError extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        children: [
          Image.asset('assets/images/no_image.png'),
          const SizedBox(height: 10,),
          const Text('The image failed to load'),
        ]),
    );
  }
}

Then just pass it to the CachedNetworkImage constructor using the named parameter errorWidget:

errorWidget: (context, url, error) => _LoadError(),

Here it is in context:

AspectRatio(
  aspectRatio: widget.aspectRatio, 
  child: CachedNetworkImage(
    imageUrl: widget.url,
    fit: BoxFit.cover,
    progressIndicatorBuilder: (context, url, downloadProgress) => 
      AspectRatio(
        aspectRatio: 2,
        child: LayoutBuilder(
          builder: (context, constraints){
            debugPrint(constraints.toString());
            return Stack(
              children: [
                BlurHash(hash: widget.blurhash),                    
                const Center(
                  child: CircularProgressIndicator(color: Colors.white,),
                )
              ],
            );
          }
        ),
      ),
      errorWidget: (context, url, error) => _LoadError(),
  )
);

There are currently some issues with the way the package handles errors; if the image cannot be found then at time of writing, the package throws an error rather than displaying the error widget, which you might argue is the expected behaviour.

Checking Network Connectivity

If you're offline and the image isn't in the cache, the cached_network_image package will display the error widget.

In case you want to handle this yourself, you can use the connectivity_plus package to check whether someone is offline:

import 'package:connectivity_plus/connectivity_plus.dart';

final connectivityResult = await (Connectivity().checkConnectivity());
bool isOffline = connectivityResult == ConnectivityResult.none;

To check whether the image is in the cache:

var cached = await DefaultCacheManager().getFileFromCache(‘http://example.com/beach.jpg');
bool isCached = cached != null;

Pre-caching

Sometimes, you’ll be able to anticipate images you’re likely to need before time. For example, if someone registers for your app using Facebook, chances are you’ll want to display their profile picture; a news app is likely to need to show images for the day’s top stories.

In these cases, it might be useful - within reason! - to pre-cache those images.

To do so, we need to delve into the underlying caching mechanism of the cached_network_image package, which is flutter_cache_manager.

It’s very simple:

await DefaultCacheManager().downloadFile(‘http://example.com/beach.jpg');

Purging the Cache

Lastly for completeness; you may wish to periodically purge the cache. As with pre-caching, we use the underlying cache provider:

await DefaultCacheManager().emptyCache();    

Wrapping Up

In this post, I've outlined a way in which you can significantly improve a mobile app's approach to network images using caching — which in turn helps provide an offline experience — and error-handling.

I've introduced BlurHash as a way to display a low-fi representation of an image while the user waits, along with an indication of progress.

You'll find the source code for this post on Github, and feel free to reach out to me on Twitter if you have any questions or feedback.