Mustafa Can Yücel
blog-post-19

WPF Image Loader with Status Animations and Disk Cache

WPF Image Element

Loading images in WPF is a common task. Image element is used to display images in WPF. It has a property named Source which is used to set the image source. The source can be a file path, a URI, or a stream. However, unlike MAUI, the WPF Image element does not have a built-in loading animation or a status indicator. In MAUI, the element has flag booleans and events that you can use to show various status indicators, and these are compatible with MVVM approaches. Yet the WPF counter is extremely simple and does not have these features. In this blog post, we will create a custom WPF Image element that has the following features:

  • Loading animation while the image is being loaded/downloaded
  • Error image when the image could not be fetched
  • A disk cache to prevent downloading the same images again and again.

The native Image element has a memory cache; however, we will be downloading the image manually so if we do not handle caching, the same image will be downloaded every time it has to be displayed.

Disk Caching

For the storage media, we can use either the RAM or the disk. The RAM is faster, but it is limited. The disk is slower, but it is more abundant. Considering images take a lot of space, and they require even more when they are uncompressed for displaying, we don't want to fill the memory by dispaying only a bunch of images. For this reason, we will use the disk as the storage media.

The `DiskImageCache` class will be responsible for the following tasks:

  • When requested an image, check if the image already exists on the disk
  • If not, download the image and save it to the cache, then return the local file path
  • If it does exist, directly return the local cache file path
While performing these operations, the class should be thread-safe; a multitude of tasks and threads may ask for the same image at the same time. It should also store images such that their remote URLs are not directly visible in the file system.

The full code of the class can be found below, and the explanation of the code will be given in the next section.

using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Windows.Media.Imaging;

namespace DesktopClient.Controls.Caching
{
    internal class DiskImageCache
    {

        public DiskImageCache(string cacheDir)
        {
            cacheDirectory = cacheDir;
            Directory.CreateDirectory(cacheDir);
        }

        public async Task GetOrAddAsync(string key, Func<Task<BitmapImage>> imageFactory)
        {
            string filePath = $"{GetFilePath(key)}.png";
            bool fileExists = File.Exists(filePath);
            if (fileExists)
            {
                // no need for a semaphore for reading from disk
                File.SetLastAccessTime(filePath, DateTime.Now);
                return await Task.Run(() => LoadImageFromDisk(filePath));
            }

            await semaphore.WaitAsync();
            try
            {
                // double check if another thread has already saved the image
                if (File.Exists(filePath))
                {
                    File.SetLastAccessTime(filePath, DateTime.Now);
                    return await Task.Run(() => LoadImageFromDisk(filePath));
                }
                else
                {
                    var bitmap = await imageFactory();
                    await Task.Run(() => SaveImageToDisk(bitmap, filePath));
                    return bitmap;
                }
            }
            finally
            {
                semaphore.Release();
            }
        }

        static BitmapImage LoadImageFromDisk(string filePath)
        {
            BitmapImage result = new(new(filePath));
            result.Freeze();
            return result;
        }

        static void SaveImageToDisk(BitmapImage bitmap, string filePath)
        {
            using var fileStream = new FileStream(filePath, FileMode.Create);
            BitmapEncoder encoder = new PngBitmapEncoder();
            encoder.Frames.Add(BitmapFrame.Create(bitmap));
            encoder.Save(fileStream);
        }

        private string GetFilePath(string key)
        {
            var hash = SHA256.HashData(Encoding.UTF8.GetBytes(key));
            var fileName = BitConverter.ToString(hash).Replace("-", "");
            return Path.Combine(cacheDirectory, fileName);
        }


        readonly string cacheDirectory;
        readonly SemaphoreSlim semaphore = new(1, 1);

        public static async Task CleanCache(string cacheDir)
        {
            int MAX_AGE_DAYS = 30;
            var now = DateTime.Now;

            var filesToDelete = Directory.GetFiles(cacheDir)
                .Where(file => now - File.GetLastAccessTime(file) > TimeSpan.FromDays(MAX_AGE_DAYS))
                .ToList();

            var deleteTasks = filesToDelete.Select(file => Task.Run(() =>
            {
                try
                {
                    File.Delete(file);
                }
                catch (IOException)
                {
                    // ignore errors
                }
            }));

            await Task.WhenAll(deleteTasks);
        }
    }
}

Constructor

When constructed, the class creates the cache directory if it does not exist. The cache directory is supplied as a parameter to the constructor (similar to constructor DI). The cache directory is where the images will be stored. For a WPF app, you can define the cache directory within the App.xaml.cs file:

public string CacheDirectory { get; } = System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "AppName", "cache");
This way, the cache directory will be unique to the app and will be stored in the local application data folder. The CacheDirectory property can be accessed from anywhere in the app.

GetOrAddAsync

This is the main method of the class. It is used to get an image from the cache or download it if it does not exist.

  • Note that it returns an instance of BitmapImage. The main reason is the native image control of WPF requires this class instances to display.
  • The method (thus the class) does not know how to download the image. It is supplied with a function that returns a BitmapImage instance. This function is called imageFactory. The method will call this function if the image does not exist in the cache.

If the file exists in the disk, we do not need to lock the operation; we can directly read the image into a BitmapImage and return it. We also update the last access time of the file to prevent it from being deleted by the CleanCache method.

If the file does not exist, we lock the operation with a semaphore. This is to prevent multiple threads from downloading the same image at the same time. We then double-check if the file has been downloaded by another thread while we were waiting. If it has, we return the image. If not, we download the image, save it to the disk, and return it. The semaphore is released after the operation is completed.

LoadImageFromDisk

This method reads the image from the disk and returns it as a BitmapImage. The image is frozen to allow sharing between threads. Read operation is done first by creating a Uri from the path, and then using this Uri to create a BitmapImage instance.

SaveImageToDisk

This method saves the image to the disk. The image is saved as a PNG file. The operation is done by creating a FileStream from the file path, then creating a PngBitmapEncoder instance, adding the image to the encoder, and saving the encoder to the file stream.

GetFilePath

This method is used to get the file path of the image. The file path is created by hashing the key (the URL of the image) with SHA256, converting the hash to a string, and then combining the cache directory with the hash string. This serves several purposes:

  • The URL of the image is not directly visible in the file system. This is important for security reasons.
  • The file path is unique to the image. This is important to prevent conflicts between images with the same name but different URLs.
  • The file path is short. This is important to prevent long file paths that may cause issues in some file systems.
  • The file path is consistent. This is important to prevent issues with different file paths for the same image.

CleanCache

This optional method is used to clean the cache. There are few key points that should be noted on when the cleaning can be done.

On App Exit

The major problem here is that when the application exits gracefully, the Exit event is called. However, the continuation of this event does not guarantee that the application closing will wait for the event to finish. On the contrary, all the background tasks will be flushed independent of them being finished or not. This means that the cache cleaning may not be completed before the application is closed. This is a problem because the cache cleaning may be interrupted, and the cache may be left in an inconsistent state.

On App Start or During App is Running

The major problem with this approach is that the cache will be in use during the cleaning process. This may cause application slow downs or even crashes. The cleaning process should be done in a separate thread, and the cache should be locked during the cleaning process. This will prevent the cache from being used during the cleaning process, but it will also prevent the cache from being used during the cleaning process. This is a problem because the cache cleaning may be interrupted, and the cache may be left in an inconsistent state.

A Separate App

This is the most reliable approach. A separate app can be created that will clean the cache. This app can be run when the original app ends, and the main app will wait for this cleaning app to finish, or can gracefully stop it (with a CancellationToken), so it will not interfere with the main app. This is the most reliable approach, but it requires additional development and maintenance.

Manual Cleaning

This is another reliable approach. A button is placed into the settings of the application, and the user can clear the cache by clicking this button. This is the most user-friendly approach, but it requires the user to manually clean the cache.

ImageItem Class

To represent images with a caption, a custom class is created. This is completely optional; if you want you can update the code so that it works with strings. In this case, the source of image (web or local disk) can be distinguished by a simple regex expression.

public class ImageItem : INotifyPropertyChanged
{
    /// 
    /// The PK, auto-incremented
    /// 
    public int ImageItemId { get; set; }

    /// 
    /// The notes / caption of the image
    /// 
    public string? Notes { get; set; }

    /// 
    /// The remote URL of the image
    /// 
    public string Url
    {
        get => url;
        set
        {
            url = value;
            OnPropertyChanged();
            ReloadKey = (ReloadKey + 1) % 2;
        }
    }

    /// 
    /// THe local path of the image
    /// 
    [NotMapped]
    public string? LocalPath { get; set; }

    [NotMapped]
    public int ReloadKey
    {
        get => reloadKey;
        private set
        {
            reloadKey = value;
            OnPropertyChanged();
        }
    }

    [NotMapped]
    public ImageSource ImageSource
    {
        get => imageSource;
        set
        {
            imageSource = value;
            OnPropertyChanged();
        }
    }
    [NotMapped]
    public ImageStatus ImageStatus
    {
        get => imageStatus;
        set
        {
            imageStatus = value;
            OnPropertyChanged();
        }
    }
    public override bool Equals(object? obj)
    {
        bool isEqual = false;

        if (obj is ImageItem imageItem)
        {
            // if the image item has a nonzero ID, compare the IDs
            if (ImageItemId != 0 && imageItem.ImageItemId != 0)
            {
                isEqual = ImageItemId == imageItem.ImageItemId;
            }
            // if both has zero ID, compare the local paths
            else if (ImageItemId == 0 && imageItem.ImageItemId == 0)
            {
                isEqual = LocalPath == imageItem.LocalPath;
            }
            // otherwise we have no way to compare them
        }
        return isEqual;
    }

    public override int GetHashCode()
    {
        return base.GetHashCode();
    }

    public event PropertyChangedEventHandler? PropertyChanged;

    protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = "")
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }

    private int reloadKey;
    private string url = string.Empty;
    private ImageSource imageSource = ImageSource.Cloud;
    private ImageStatus imageStatus = ImageStatus.NoChange;
}

ImageWithLoader

This is the main UI user control that will display the image. For ease of implementing, we will use a third party library; MahApps.Metro for the loading animation and the error icon. Let's show the code and then discuss several key points:

public partial class ImageWithLoader : UserControl
{
    public static readonly DependencyProperty ImageSourceProperty =
        DependencyProperty.Register("ImageSource", typeof(ImageItem), typeof(ImageWithLoader), new PropertyMetadata(null, OnImageSourceChanged));

    public static readonly DependencyProperty ReloadKeyProperty =
        DependencyProperty.Register("ReloadKey", typeof(int), typeof(ImageWithLoader), new PropertyMetadata(0, OnReloadKeyChanged));

    static readonly DiskImageCache imageCache = new(App.Current.CacheDirectory);

    public ImageItem? ImageSource
    {
        get => (ImageItem?)GetValue(ImageSourceProperty);
        set => SetValue(ImageSourceProperty, value);
    }

    public int ReloadKey
    {
        get => (int)GetValue(ReloadKeyProperty);
        set => SetValue(ReloadKeyProperty, value);
    }

    public ImageWithLoader()
    {
        InitializeComponent();
    }

    private async static void OnImageSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        if (d is ImageWithLoader imageWithLoader)
        {
            if (e.NewValue is ImageItem newImageSource)
            {
                imageWithLoader.ShowLoading();
                try
                {
                    await imageWithLoader.LoadImageAsync(newImageSource);
                }
                catch (Exception ex)
                {
                    logger.Error(ex, "Failed to load image");
                    imageWithLoader.ShowError();
                }
            }
            else
            {
                imageWithLoader.ShowError();
            }
        }
    }

    private async static void OnReloadKeyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        if (d is ImageWithLoader imageWithLoader)
        {
            ImageItem? newSource = imageWithLoader.ImageSource;
            if (newSource is ImageItem newImageSource)
            {
                imageWithLoader.ShowLoading();
                try
                {
                    await imageWithLoader.LoadImageAsync(newImageSource);
                }
                catch (Exception ex)
                {
                    logger.Error(ex, "Failed to load image");
                    imageWithLoader.ShowError();
                }
            }
            else
            {
                imageWithLoader.ShowError();
            }
        }
    }

    private async Task LoadImageAsync(ImageItem newImageSource)
    {
        if (VerifyImageSource(newImageSource))
        {
            ShowError();
            return;
        }

        BitmapImage bitmap = newImageSource.ImageSource switch
        {
            Core.Model.Files.ImageSource.Cloud => await LoadImageFromCloud(newImageSource),
            Core.Model.Files.ImageSource.Local => await LoadImageFromLocal(newImageSource),
            _ => throw new ArgumentException("Invalid image source")
        };
        ShowImage(bitmap);
    }

    /// 
    /// Verifies that the image source is valid.
    /// 
    /// 
    /// True if the image source is valid, false otherwise
    private static bool VerifyImageSource(ImageItem newImageSource)
    {
        return (newImageSource.ImageSource == Core.Model.Files.ImageSource.Cloud && string.IsNullOrWhiteSpace(newImageSource.Url))
            || (newImageSource.ImageSource == Core.Model.Files.ImageSource.Local && string.IsNullOrWhiteSpace(newImageSource.LocalPath));
    }

    private static async Task LoadImageFromLocal(ImageItem newImageSource)
    {
        return await Task.Run(() =>
        {
            BitmapImage bitmap = new(new(newImageSource.LocalPath!));
            // Freeze to share across threads
            bitmap.Freeze();
            return bitmap;
        });
    }

    private static async Task LoadImageFromCloud(ImageItem imageItem)
    {
        string cacheKey = imageItem.Url;
        BitmapImage bitmap = await imageCache.GetOrAddAsync(cacheKey, async () => await DownloadAndCreateBitmap(imageItem.Url));
        return bitmap;
    }

    static async Task DownloadAndCreateBitmap(string url)
    {
        using var client = new HttpClient();
        byte[] data = await client.GetByteArrayAsync(url);
        var stream = new MemoryStream(data);
        var bitmap = new BitmapImage();
        bitmap.BeginInit();
        bitmap.StreamSource = stream;
        bitmap.CacheOption = BitmapCacheOption.OnLoad;
        bitmap.EndInit();
        bitmap.Freeze();
        return bitmap;
    }

    private void ShowLoading()
    {
        Img.Visibility = Visibility.Hidden;
        Loader.IsActive = true;
        ErrorIcon.Visibility = Visibility.Hidden;
    }

    private void ShowError()
    {
        Img.Visibility = Visibility.Hidden;
        Loader.IsActive = false;
        ErrorIcon.Visibility = Visibility.Visible;
    }

    private void ShowImage(BitmapImage bitmap)
    {

        Img.Source = bitmap;
        Img.Visibility = Visibility.Visible;
        Loader.IsActive = false;
        ErrorIcon.Visibility = Visibility.Hidden;

    }

    private readonly static ILogger logger = Log.Logger;
}

The corresponding XAML code is as follows:

<UserControl x:Class="MyApp.Controls.ImageWithLoader"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:mah="http://metro.mahapps.com/winfx/xaml/controls"
    xmlns:iconpacks="http://metro.mahapps.com/winfx/xaml/iconpacks"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    mc:Ignorable="d" 
    d:DesignHeight="450" d:DesignWidth="800">
<Grid>
<Image x:Name="Img"
      RenderOptions.BitmapScalingMode="LowQuality"
      Stretch="Uniform" />
<mah:ProgressRing x:Name="Loader"
                 IsActive="False"
                 HorizontalAlignment="Center"
                 VerticalAlignment="Center"
                 IsLarge="True" />
<iconpacks:PackIconModern x:Name="ErrorIcon"
                         Kind="WarningCircle"
                         HorizontalAlignment="Center"
                         VerticalAlignment="Center"
                         Foreground="Red"
                         Width="50"
                         Height="50"
                         Visibility="Hidden" />
</Grid>
</UserControl>

ImageSourceProperty

This is the dependency property that will be used to set the image source. When the property is set, the OnImageSourceChanged method is called. Since this is a dependency property, it can be bound to a property in the view model. This way, the image source can be changed from the view model, and the image will be updated automatically.

ReloadKeyProperty

This is the dependency property that will be used to force the image to reload. When the property is set, the OnReloadKeyChanged method is called. This property is used to force the image to reload when the image source is the same. This is useful when the image source is a local file, and the file is updated. By changing the ReloadKey property, the image will be reloaded. This is also a dependency property, so it can be bound to a property in the view model.

OnImageSourceChanged

This method is called when the ImageSource property is changed. It is used to load the image from the source. If the source is a cloud URL, the image is downloaded from the URL. If the source is a local file, the image is loaded from the file. If the source is invalid, an error icon is shown.

OnReloadKeyChanged

This method is called when the ReloadKey property is changed. It is used to reload the image from the source. This is useful when the image source is a local file, and the file is updated. By changing the ReloadKey property, the image will be reloaded.

LoadImageAsync

This method is used to load the image from the source. It verifies that the source is valid, then loads the image from the source. If the source is a cloud URL, the image is downloaded from the URL. If the source is a local file, the image is loaded from the file. If the source is invalid, an error icon is shown.

VerifyImageSource

This method is used to verify that the image source is valid. It checks if the source is a cloud URL and the URL is not empty, or if the source is a local file and the local path is not empty.

LoadImageFromLocal

This method is used to load the image from a local file. It creates a BitmapImage from the local path and freezes it to share across threads.

LoadImageFromCloud

This method is used to load the image from a cloud URL. It uses the DiskImageCache class to download and cache the image. The image is downloaded from the URL, saved to the cache, and returned as a BitmapImage.

DownloadAndCreateBitmap

This method is used to download the image from the URL and create a BitmapImage from the data. It uses an HttpClient to download the image data, creates a MemoryStream from the data, and creates a BitmapImage from the stream.

ShowLoading

This method is used to show the loading animation. It hides the image, shows the loading animation, and hides the error icon.

ShowError

This method is used to show the error icon. It hides the image, hides the loading animation, and shows the error icon.

ShowImage

This method is used to show the image. It sets the image source, shows the image, hides the loading animation, and hides the error icon.

logger

This is an application-wide logger service. Its implementation depends on the application logging service and it is out of the scope of this post.

Usage

To use the ImageWithLoader control, you can add it to your XAML file like this (assuming the control is in the Controls namespace):

xmlns:controls="clr-namespace:MayApp.Controls"
<controls:ImageWithLoader ImageSource="{Binding ImageItem}" ReloadKey="{Binding ImageItem.ReloadKey}" />
blog-21-1
The green cloud on the left bottom displays the source, and the reason of slight downward movement is the textblocks on the upper parts of the window getting larger as the UI is hydrated with the data.

Improvements

There are few improvements that can be made to the control:
  • If a large image is downloaded or the bandwith is limited, the download may take a lot of time. This may cause the user to think that the app hung. An option is to either add a non-indeterminate progress bar or change the activity indicator with one, then updating its value per bytes downloaded. This is not a very straightforward task, and the benefits are questionable.
  • The app keeps a hold to the cached image files until the app is closed. This is not a problem for most of the apps, but it will prevent manual deletion of the files.
  • The app does not check if the image is updated on the server. This is a common problem with caching (stale data). The app can check the last modified date of the image on the server and compare it with the last modified date of the image in the cache. If the image is updated, the cache should be updated as well. This is a complex task and requires additional development, both on server and client side.
  • The app does not check if the image is deleted on the server. This is another common problem with caching (orphan data). The app can check if the image exists on the server and if it does not, the cache should be deleted.