Created
February 3, 2026 12:53
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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