Skip to content

Instantly share code, notes, and snippets.

@tonyedwardspz
Created February 3, 2026 12:53
Show Gist options
  • Select an option

  • Save tonyedwardspz/19b362ff4562374b88778d47c1032150 to your computer and use it in GitHub Desktop.

Select an option

Save tonyedwardspz/19b362ff4562374b88778d47c1032150 to your computer and use it in GitHub Desktop.
A simple async image control with caching, placeholders, and optimised image sizing via appending parameters onto the image request URL. The cdn source should support this feature.
<?xml version="1.0" encoding="utf-8" ?>
<ContentView
x:Class="Shared_Library.Controls.AsyncImage"
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:sc="clr-namespace:Shared_Library.Controls"
x:Name="AsyncImageControl"
x:DataType="sc:AsyncImage">
<Image
x:Name="InnerImage"
Aspect="{Binding ImageAspect, Source={x:Reference AsyncImageControl}}" />
</ContentView>
using Microsoft.Maui.Devices;
using Shared_Library.Services;
namespace Shared_Library.Controls;
public partial class AsyncImage : ContentView
{
private CancellationTokenSource? _loadCancellationTokenSource;
private double? _pixelWidth;
private double? _pixelHeight;
private bool _layoutComplete;
private string? _loadedUrl;
private readonly object _loadLock = new();
// Shared cache service instance
private static readonly IImageCacheService _cacheService = new ImageCacheService();
// Shared HttpClient (reuse to avoid socket exhaustion)
private static readonly HttpClient _httpClient = new()
{
Timeout = TimeSpan.FromSeconds(30)
};
#region Bindable Properties
public static readonly BindableProperty ImageUrlProperty = BindableProperty.Create(
nameof(ImageUrl),
typeof(string),
typeof(AsyncImage),
null,
propertyChanged: OnImageUrlChanged);
public string? ImageUrl
{
get => (string?)GetValue(ImageUrlProperty);
set => SetValue(ImageUrlProperty, value);
}
public static readonly BindableProperty ImageAspectProperty = BindableProperty.Create(
nameof(ImageAspect),
typeof(Aspect),
typeof(AsyncImage),
Aspect.AspectFill,
propertyChanged: OnImageAspectChanged);
public Aspect ImageAspect
{
get => (Aspect)GetValue(ImageAspectProperty);
set => SetValue(ImageAspectProperty, value);
}
#endregion
public AsyncImage()
{
InitializeComponent();
ShowPlaceholder();
}
private static void OnImageUrlChanged(BindableObject bindable, object oldValue, object newValue)
{
if (bindable is AsyncImage asyncImage)
{
asyncImage.CancelCurrentLoad();
var newUrl = newValue as string;
if (asyncImage._loadedUrl != newUrl)
{
asyncImage._loadedUrl = null;
if (!string.IsNullOrEmpty(newUrl) &&
asyncImage._pixelWidth.HasValue &&
asyncImage._pixelHeight.HasValue)
{
var cdnUrl = asyncImage.BuildCdnUrl(newUrl);
var cachedBytes = _cacheService.GetFromMemory(cdnUrl);
if (cachedBytes != null)
{
asyncImage.InnerImage.Source = ImageSource.FromStream(() => new MemoryStream(cachedBytes));
asyncImage._loadedUrl = newUrl;
return;
}
}
asyncImage.ShowPlaceholder();
}
if (!string.IsNullOrEmpty(newUrl))
{
asyncImage.TryLoadImage();
}
}
}
private static void OnImageAspectChanged(BindableObject bindable, object oldValue, object newValue)
{
if (bindable is AsyncImage asyncImage)
{
asyncImage.InnerImage.Aspect = (Aspect)newValue;
}
}
protected override void OnSizeAllocated(double width, double height)
{
base.OnSizeAllocated(width, height);
if (width > 0 && height > 0 && !_layoutComplete)
{
_layoutComplete = true;
var density = DeviceDisplay.MainDisplayInfo.Density;
_pixelWidth = width * density;
_pixelHeight = height * density;
TryLoadImage();
}
}
protected override void OnHandlerChanging(HandlerChangingEventArgs args)
{
base.OnHandlerChanging(args);
if (args.NewHandler == null)
{
CancelCurrentLoad();
_layoutComplete = false;
_pixelWidth = null;
_pixelHeight = null;
}
}
private void ShowPlaceholder()
{
InnerImage.Source = ImageSource.FromFile("placeholder.png");
}
private void ShowError()
{
InnerImage.Source = ImageSource.FromFile("error_placeholder.png");
}
private void CancelCurrentLoad()
{
lock (_loadLock)
{
_loadCancellationTokenSource?.Cancel();
_loadCancellationTokenSource?.Dispose();
_loadCancellationTokenSource = null;
}
}
private void TryLoadImage()
{
if (!_pixelWidth.HasValue || !_pixelHeight.HasValue || string.IsNullOrEmpty(ImageUrl))
{
return;
}
_ = LoadImageAsync();
}
private async Task LoadImageAsync()
{
CancellationToken cancellationToken;
lock (_loadLock)
{
_loadCancellationTokenSource?.Cancel();
_loadCancellationTokenSource?.Dispose();
_loadCancellationTokenSource = new CancellationTokenSource();
cancellationToken = _loadCancellationTokenSource.Token;
}
try
{
var cdnUrl = BuildCdnUrl(ImageUrl!);
cancellationToken.ThrowIfCancellationRequested();
// Check cache first (memory + file)
var cachedBytes = await _cacheService.GetAsync(cdnUrl, cancellationToken);
if (cachedBytes != null)
{
await MainThread.InvokeOnMainThreadAsync(() =>
{
InnerImage.Source = ImageSource.FromStream(() => new MemoryStream(cachedBytes));
_loadedUrl = ImageUrl;
});
return;
}
// Download from network (using shared HttpClient)
using var response = await _httpClient.GetAsync(cdnUrl, cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
if (!response.IsSuccessStatusCode)
{
await MainThread.InvokeOnMainThreadAsync(ShowError);
return;
}
var imageBytes = await response.Content.ReadAsByteArrayAsync(cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
// Save to cache (memory + file)
await _cacheService.SetAsync(cdnUrl, imageBytes, cancellationToken);
await MainThread.InvokeOnMainThreadAsync(() =>
{
InnerImage.Source = ImageSource.FromStream(() => new MemoryStream(imageBytes));
_loadedUrl = ImageUrl;
});
}
catch (OperationCanceledException)
{
// Expected when scrolling fast - no action needed
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"AsyncImage load failed: {ex.Message}");
if (!cancellationToken.IsCancellationRequested)
{
await MainThread.InvokeOnMainThreadAsync(ShowError);
}
}
}
private string BuildCdnUrl(string baseUrl)
{
var width = (int)Math.Round(_pixelWidth!.Value);
var height = (int)Math.Round(_pixelHeight!.Value);
var separator = baseUrl.Contains('?') ? "&" : "?";
return $"{baseUrl}{separator}w={width}&h={height}&mode=crop";
}
public static Task ClearCacheAsync() => _cacheService.ClearAsync();
}
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
namespace Shared_Library.Services;
public interface IImageCacheService
{
byte[]? GetFromMemory(string url);
Task<byte[]?> GetAsync(string url, CancellationToken cancellationToken = default);
Task SetAsync(string url, byte[] imageBytes, CancellationToken cancellationToken = default);
Task ClearAsync();
}
public class ImageCacheService : IImageCacheService
{
private readonly ConcurrentDictionary<string, CacheEntry> _memoryCache = new();
private readonly string _cacheDirectory;
private readonly TimeSpan _cacheValidity = TimeSpan.FromDays(30);
private const int MaxMemoryCacheItems = 60;
private readonly object _evictionLock = new();
private record CacheEntry(byte[] Data, DateTime LastAccessed);
public ImageCacheService()
{
_cacheDirectory = Path.Combine(FileSystem.CacheDirectory, "images");
Directory.CreateDirectory(_cacheDirectory);
}
/// <summary>
/// Synchronous memory-only cache check. Returns null if not in memory.
/// </summary>
public byte[]? GetFromMemory(string url)
{
var cacheKey = GetCacheKey(url);
if (_memoryCache.TryGetValue(cacheKey, out var entry))
{
// Update last accessed time
_memoryCache[cacheKey] = entry with { LastAccessed = DateTime.UtcNow };
return entry.Data;
}
return null;
}
public async Task<byte[]?> GetAsync(string url, CancellationToken cancellationToken = default)
{
var cacheKey = GetCacheKey(url);
// Check memory cache first (fastest)
if (_memoryCache.TryGetValue(cacheKey, out var entry))
{
// Update last accessed time
_memoryCache[cacheKey] = entry with { LastAccessed = DateTime.UtcNow };
return entry.Data;
}
// Check file cache
var filePath = GetFilePath(cacheKey);
if (File.Exists(filePath))
{
var fileInfo = new FileInfo(filePath);
// Check if cache is still valid
if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc < _cacheValidity)
{
var fileBytes = await File.ReadAllBytesAsync(filePath, cancellationToken);
// Populate memory cache for faster subsequent access
AddToMemoryCache(cacheKey, fileBytes);
return fileBytes;
}
// Cache expired - delete file
File.Delete(filePath);
}
return null;
}
public async Task SetAsync(string url, byte[] imageBytes, CancellationToken cancellationToken = default)
{
var cacheKey = GetCacheKey(url);
AddToMemoryCache(cacheKey, imageBytes);
// Write to file cache
var filePath = GetFilePath(cacheKey);
await File.WriteAllBytesAsync(filePath, imageBytes, cancellationToken);
}
private void AddToMemoryCache(string cacheKey, byte[] data)
{
// Evict least recently used entries if cache is full
if (_memoryCache.Count >= MaxMemoryCacheItems && !_memoryCache.ContainsKey(cacheKey))
{
lock (_evictionLock)
{
while (_memoryCache.Count >= MaxMemoryCacheItems)
{
var lruKey = _memoryCache
.OrderBy(kvp => kvp.Value.LastAccessed)
.Select(kvp => kvp.Key)
.FirstOrDefault();
if (lruKey != null)
{
_memoryCache.TryRemove(lruKey, out _);
}
}
}
}
_memoryCache[cacheKey] = new CacheEntry(data, DateTime.UtcNow);
}
public Task ClearAsync()
{
_memoryCache.Clear();
if (Directory.Exists(_cacheDirectory))
{
foreach (var file in Directory.GetFiles(_cacheDirectory))
{
File.Delete(file);
}
}
return Task.CompletedTask;
}
private string GetFilePath(string cacheKey) => Path.Combine(_cacheDirectory, cacheKey);
private static string GetCacheKey(string url)
{
var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(url));
return Convert.ToHexString(hashBytes);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment