diff --git a/Handlers/ImageButtonHandler.cs b/Handlers/ImageButtonHandler.cs index 68ad2da..00b25b7 100644 --- a/Handlers/ImageButtonHandler.cs +++ b/Handlers/ImageButtonHandler.cs @@ -121,13 +121,13 @@ public partial class ImageButtonHandler : ViewHandler if (image.Background is SolidPaint solidPaint && solidPaint.Color is not null) { - handler.PlatformView.BackgroundColor = solidPaint.Color.ToSKColor(); + handler.PlatformView.ImageBackgroundColor = solidPaint.Color; } } diff --git a/Views/SkiaImage.cs b/Views/SkiaImage.cs index b2f1d47..d65f6b5 100644 --- a/Views/SkiaImage.cs +++ b/Views/SkiaImage.cs @@ -2,25 +2,199 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using System.Timers; +using Microsoft.Maui.Controls; +using Microsoft.Maui.Graphics; using SkiaSharp; using Svg.Skia; namespace Microsoft.Maui.Platform; /// -/// Skia-rendered image control with SVG support. +/// Skia-rendered image control with SVG support and GIF animation. +/// Full MAUI-compliant implementation. /// public class SkiaImage : SkiaView { + #region Image Cache + + /// + /// Static image cache for decoded bitmaps to avoid re-decoding. + /// Key is the file path or URI, value is the cached bitmap data. + /// + private static readonly ConcurrentDictionary _imageCache = new(); + private static readonly object _cacheLock = new(); + private const int MaxCacheSize = 50; // Maximum number of cached images + private const long MaxCacheMemoryBytes = 100 * 1024 * 1024; // 100MB max cache + + private class CachedImage + { + public SKBitmap? Bitmap { get; set; } + public List? Frames { get; set; } + public bool IsAnimated { get; set; } + public DateTime LastAccessed { get; set; } + public long MemorySize { get; set; } + } + + /// + /// Clears the image cache. + /// + public static void ClearCache() + { + lock (_cacheLock) + { + foreach (var cached in _imageCache.Values) + { + cached.Bitmap?.Dispose(); + if (cached.Frames != null) + { + foreach (var frame in cached.Frames) + { + frame.Bitmap?.Dispose(); + } + } + } + _imageCache.Clear(); + } + } + + private static void TrimCacheIfNeeded() + { + lock (_cacheLock) + { + if (_imageCache.Count <= MaxCacheSize) + return; + + // Calculate total memory + long totalMemory = 0; + foreach (var cached in _imageCache.Values) + { + totalMemory += cached.MemorySize; + } + + // If under memory limit and count limit, don't trim + if (totalMemory < MaxCacheMemoryBytes && _imageCache.Count <= MaxCacheSize) + return; + + // Remove oldest entries until under limits + var sortedEntries = _imageCache.ToArray(); + Array.Sort(sortedEntries, (a, b) => a.Value.LastAccessed.CompareTo(b.Value.LastAccessed)); + + int removeCount = Math.Max(1, _imageCache.Count - MaxCacheSize + 10); + for (int i = 0; i < removeCount && i < sortedEntries.Length; i++) + { + if (_imageCache.TryRemove(sortedEntries[i].Key, out var removed)) + { + removed.Bitmap?.Dispose(); + if (removed.Frames != null) + { + foreach (var frame in removed.Frames) + { + frame.Bitmap?.Dispose(); + } + } + } + } + } + } + + #endregion + + #region Animation Support + + private class AnimationFrame + { + public SKBitmap? Bitmap { get; set; } + public int Duration { get; set; } // Duration in milliseconds + } + + private List? _animationFrames; + private int _currentFrameIndex; + private System.Timers.Timer? _animationTimer; + private bool _isAnimatedImage; + + #endregion + + #region BindableProperties + + /// + /// Bindable property for Aspect. + /// + public static readonly BindableProperty AspectProperty = + BindableProperty.Create( + nameof(Aspect), + typeof(Aspect), + typeof(SkiaImage), + Aspect.AspectFit, + BindingMode.TwoWay, + propertyChanged: (b, o, n) => ((SkiaImage)b).Invalidate()); + + /// + /// Bindable property for IsOpaque. + /// + public static readonly BindableProperty IsOpaqueProperty = + BindableProperty.Create( + nameof(IsOpaque), + typeof(bool), + typeof(SkiaImage), + false, + BindingMode.TwoWay, + propertyChanged: (b, o, n) => ((SkiaImage)b).Invalidate()); + + /// + /// Bindable property for IsAnimationPlaying. + /// + public static readonly BindableProperty IsAnimationPlayingProperty = + BindableProperty.Create( + nameof(IsAnimationPlaying), + typeof(bool), + typeof(SkiaImage), + false, + BindingMode.TwoWay, + propertyChanged: (b, o, n) => ((SkiaImage)b).OnIsAnimationPlayingChanged((bool)n)); + + /// + /// Bindable property for ImageBackgroundColor (MAUI Color for background). + /// + public static readonly BindableProperty ImageBackgroundColorProperty = + BindableProperty.Create( + nameof(ImageBackgroundColor), + typeof(Color), + typeof(SkiaImage), + Colors.Transparent, + BindingMode.TwoWay, + propertyChanged: (b, o, n) => ((SkiaImage)b).Invalidate()); + + #endregion + + #region Color Conversion Helper + + /// + /// Converts a MAUI Color to SkiaSharp SKColor. + /// + private static SKColor ToSKColor(Color color) + { + if (color == null) return SKColors.Transparent; + return new SKColor( + (byte)(color.Red * 255), + (byte)(color.Green * 255), + (byte)(color.Blue * 255), + (byte)(color.Alpha * 255)); + } + + #endregion + private SKBitmap? _bitmap; private SKImage? _image; private bool _isLoading; private string? _currentFilePath; + private string? _cacheKey; private bool _isSvg; private CancellationTokenSource? _loadCts; private readonly object _loadLock = new object(); @@ -34,7 +208,11 @@ public class SkiaImage : SkiaView get => _bitmap; set { - _bitmap?.Dispose(); + // Don't dispose if this is a cached bitmap + if (_bitmap != null && (_cacheKey == null || !_imageCache.ContainsKey(_cacheKey))) + { + _bitmap.Dispose(); + } _bitmap = value; _image?.Dispose(); _image = value != null ? SKImage.FromBitmap(value) : null; @@ -42,13 +220,48 @@ public class SkiaImage : SkiaView } } - public Aspect Aspect { get; set; } + /// + /// Gets or sets the aspect ratio scaling mode. + /// + public Aspect Aspect + { + get => (Aspect)GetValue(AspectProperty); + set => SetValue(AspectProperty, value); + } - public bool IsOpaque { get; set; } + /// + /// Gets or sets whether the image is opaque. + /// + public bool IsOpaque + { + get => (bool)GetValue(IsOpaqueProperty); + set => SetValue(IsOpaqueProperty, value); + } + /// + /// Gets whether the image is currently loading. + /// public bool IsLoading => _isLoading; - public bool IsAnimationPlaying { get; set; } + /// + /// Gets or sets whether animation is playing (for GIF support). + /// When set to true, animated GIFs will play their animation. + /// When set to false, the first frame is displayed. + /// + public bool IsAnimationPlaying + { + get => (bool)GetValue(IsAnimationPlayingProperty); + set => SetValue(IsAnimationPlayingProperty, value); + } + + /// + /// Gets or sets the image background color (MAUI Color type). + /// + public Color ImageBackgroundColor + { + get => (Color)GetValue(ImageBackgroundColorProperty); + set => SetValue(ImageBackgroundColorProperty, value); + } public new double WidthRequest { @@ -73,6 +286,78 @@ public class SkiaImage : SkiaView public event EventHandler? ImageLoaded; public event EventHandler? ImageLoadingError; + private void OnIsAnimationPlayingChanged(bool isPlaying) + { + if (_isAnimatedImage && _animationFrames != null && _animationFrames.Count > 1) + { + if (isPlaying) + { + StartAnimation(); + } + else + { + StopAnimation(); + } + } + } + + private void StartAnimation() + { + if (_animationFrames == null || _animationFrames.Count <= 1) + return; + + StopAnimation(); + + var frame = _animationFrames[_currentFrameIndex]; + int duration = frame.Duration > 0 ? frame.Duration : 100; // Default 100ms if not specified + + _animationTimer = new System.Timers.Timer(duration); + _animationTimer.Elapsed += OnAnimationTimerElapsed; + _animationTimer.AutoReset = false; + _animationTimer.Start(); + } + + private void StopAnimation() + { + if (_animationTimer != null) + { + _animationTimer.Stop(); + _animationTimer.Elapsed -= OnAnimationTimerElapsed; + _animationTimer.Dispose(); + _animationTimer = null; + } + } + + private void OnAnimationTimerElapsed(object? sender, ElapsedEventArgs e) + { + if (_animationFrames == null || _animationFrames.Count <= 1 || !IsAnimationPlaying) + return; + + // Move to next frame + _currentFrameIndex = (_currentFrameIndex + 1) % _animationFrames.Count; + + // Update the displayed image + var frame = _animationFrames[_currentFrameIndex]; + if (frame.Bitmap != null) + { + _image?.Dispose(); + _image = SKImage.FromBitmap(frame.Bitmap); + Invalidate(); + } + + // Schedule next frame + if (IsAnimationPlaying) + { + int duration = frame.Duration > 0 ? frame.Duration : 100; + _animationTimer?.Stop(); + if (_animationTimer != null) + { + _animationTimer.Interval = duration; + _animationTimer.Start(); + } + } + } + private void ScheduleSvgReloadIfNeeded() { if (_isSvg && !string.IsNullOrEmpty(_currentFilePath)) @@ -95,7 +380,6 @@ public class SkiaImage : SkiaView _pendingSvgReload = false; if (!string.IsNullOrEmpty(_currentFilePath) && WidthRequest > 0.0 && HeightRequest > 0.0) { - Console.WriteLine($"[SkiaImage] Reloading SVG at {WidthRequest}x{HeightRequest} (was {_svgLoadedWidth}x{_svgLoadedHeight})"); await LoadSvgAtSizeAsync(_currentFilePath, WidthRequest, HeightRequest); } } @@ -103,11 +387,12 @@ public class SkiaImage : SkiaView protected override void OnDraw(SKCanvas canvas, SKRect bounds) { // Draw background if not opaque - if (!IsOpaque && BackgroundColor != SKColors.Transparent) + var bgColor = ImageBackgroundColor != null ? ToSKColor(ImageBackgroundColor) : SKColors.Transparent; + if (!IsOpaque && bgColor != SKColors.Transparent) { using var bgPaint = new SKPaint { - Color = BackgroundColor, + Color = bgColor, Style = SKPaintStyle.Fill }; canvas.DrawRect(bounds, bgPaint); @@ -176,7 +461,6 @@ public class SkiaImage : SkiaView { _isLoading = true; Invalidate(); - Console.WriteLine($"[SkiaImage] LoadFromFileAsync: {filePath}, WidthRequest={WidthRequest}, HeightRequest={HeightRequest}"); try { @@ -204,40 +488,63 @@ public class SkiaImage : SkiaView if (File.Exists(path)) { foundPath = path; - Console.WriteLine("[SkiaImage] Found file at: " + path); break; } } if (foundPath == null) { - Console.WriteLine("[SkiaImage] File not found: " + filePath); _isLoading = false; _isSvg = false; _currentFilePath = null; + _cacheKey = null; ImageLoadingError?.Invoke(this, new ImageLoadingErrorEventArgs(new FileNotFoundException(filePath))); return; } _isSvg = foundPath.EndsWith(".svg", StringComparison.OrdinalIgnoreCase); _currentFilePath = foundPath; + _cacheKey = foundPath; - if (!_isSvg) + // Check cache first + if (_imageCache.TryGetValue(foundPath, out var cached)) { - await Task.Run(() => + cached.LastAccessed = DateTime.UtcNow; + if (cached.IsAnimated && cached.Frames != null) { - using FileStream stream = File.OpenRead(foundPath); - SKBitmap? bitmap = SKBitmap.Decode(stream); - if (bitmap != null) + _isAnimatedImage = true; + _animationFrames = cached.Frames; + _currentFrameIndex = 0; + if (cached.Frames.Count > 0 && cached.Frames[0].Bitmap != null) { - Bitmap = bitmap; - Console.WriteLine("[SkiaImage] Loaded image: " + foundPath); + _image?.Dispose(); + _image = SKImage.FromBitmap(cached.Frames[0].Bitmap); } - }); + if (IsAnimationPlaying) + { + StartAnimation(); + } + } + else if (cached.Bitmap != null) + { + _isAnimatedImage = false; + _bitmap = cached.Bitmap; + _image?.Dispose(); + _image = SKImage.FromBitmap(cached.Bitmap); + } + _isLoading = false; + ImageLoaded?.Invoke(this, EventArgs.Empty); + Invalidate(); + return; + } + + if (_isSvg) + { + await LoadSvgAtSizeAsync(foundPath, WidthRequest, HeightRequest); } else { - await LoadSvgAtSizeAsync(foundPath, WidthRequest, HeightRequest); + await LoadImageWithAnimationSupportAsync(foundPath); } _isLoading = false; @@ -252,6 +559,103 @@ public class SkiaImage : SkiaView Invalidate(); } + private async Task LoadImageWithAnimationSupportAsync(string filePath) + { + await Task.Run(() => + { + using var stream = File.OpenRead(filePath); + using var codec = SKCodec.Create(stream); + + if (codec == null) + { + // Fallback to simple decode + stream.Position = 0; + var bitmap = SKBitmap.Decode(stream); + if (bitmap != null) + { + CacheAndSetBitmap(filePath, bitmap, false); + } + return; + } + + int frameCount = codec.FrameCount; + + if (frameCount > 1) + { + // Animated image (GIF) + _isAnimatedImage = true; + _animationFrames = new List(); + var info = codec.Info; + + for (int i = 0; i < frameCount; i++) + { + var frameInfo = codec.FrameInfo[i]; + var bitmap = new SKBitmap(info.Width, info.Height); + + var options = new SKCodecOptions(i); + codec.GetPixels(bitmap.Info, bitmap.GetPixels(), options); + + _animationFrames.Add(new AnimationFrame + { + Bitmap = bitmap, + Duration = frameInfo.Duration > 0 ? frameInfo.Duration : 100 + }); + } + + // Cache the animation frames + long memorySize = _animationFrames.Sum(f => (long)(f.Bitmap?.ByteCount ?? 0)); + _imageCache[filePath] = new CachedImage + { + Frames = _animationFrames, + IsAnimated = true, + LastAccessed = DateTime.UtcNow, + MemorySize = memorySize + }; + TrimCacheIfNeeded(); + + // Set first frame as current image + _currentFrameIndex = 0; + if (_animationFrames.Count > 0 && _animationFrames[0].Bitmap != null) + { + _image?.Dispose(); + _image = SKImage.FromBitmap(_animationFrames[0].Bitmap); + } + + // Start animation if requested + if (IsAnimationPlaying) + { + StartAnimation(); + } + } + else + { + // Static image + _isAnimatedImage = false; + var bitmap = SKBitmap.Decode(codec, codec.Info); + if (bitmap != null) + { + CacheAndSetBitmap(filePath, bitmap, false); + } + } + }); + } + + private void CacheAndSetBitmap(string cacheKey, SKBitmap bitmap, bool isAnimated) + { + _imageCache[cacheKey] = new CachedImage + { + Bitmap = bitmap, + IsAnimated = isAnimated, + LastAccessed = DateTime.UtcNow, + MemorySize = bitmap.ByteCount + }; + TrimCacheIfNeeded(); + + _bitmap = bitmap; + _image?.Dispose(); + _image = SKImage.FromBitmap(bitmap); + } + private async Task LoadSvgAtSizeAsync(string svgPath, double targetWidth, double targetHeight) { _loadCts?.Cancel(); @@ -293,8 +697,6 @@ public class SkiaImage : SkiaView canvas.Clear(SKColors.Transparent); canvas.Scale(scale); canvas.DrawPicture(svg.Picture, null); - - Console.WriteLine($"[SkiaImage] Loaded SVG: {svgPath} at {bitmapWidth}x{bitmapHeight} (requested {targetWidth}x{targetHeight})"); } }, cts.Token); @@ -302,6 +704,7 @@ public class SkiaImage : SkiaView { _svgLoadedWidth = (targetWidth > 0.0) ? targetWidth : newBitmap.Width; _svgLoadedHeight = (targetHeight > 0.0) ? targetHeight : newBitmap.Height; + _isAnimatedImage = false; Bitmap = newBitmap; } else @@ -318,16 +721,71 @@ public class SkiaImage : SkiaView public async Task LoadFromStreamAsync(Stream stream) { _isLoading = true; + _cacheKey = null; // Streams are not cached by default Invalidate(); try { await Task.Run(() => { - SKBitmap? bitmap = SKBitmap.Decode(stream); - if (bitmap != null) + using var codec = SKCodec.Create(stream); + + if (codec == null) { - Bitmap = bitmap; + stream.Position = 0; + var bitmap = SKBitmap.Decode(stream); + if (bitmap != null) + { + _isAnimatedImage = false; + Bitmap = bitmap; + } + return; + } + + int frameCount = codec.FrameCount; + + if (frameCount > 1) + { + // Animated image + _isAnimatedImage = true; + _animationFrames = new List(); + var info = codec.Info; + + for (int i = 0; i < frameCount; i++) + { + var frameInfo = codec.FrameInfo[i]; + var bitmap = new SKBitmap(info.Width, info.Height); + + var options = new SKCodecOptions(i); + codec.GetPixels(bitmap.Info, bitmap.GetPixels(), options); + + _animationFrames.Add(new AnimationFrame + { + Bitmap = bitmap, + Duration = frameInfo.Duration > 0 ? frameInfo.Duration : 100 + }); + } + + _currentFrameIndex = 0; + if (_animationFrames.Count > 0 && _animationFrames[0].Bitmap != null) + { + _image?.Dispose(); + _image = SKImage.FromBitmap(_animationFrames[0].Bitmap); + } + + if (IsAnimationPlaying) + { + StartAnimation(); + } + } + else + { + _isAnimatedImage = false; + var bitmap = SKBitmap.Decode(codec, codec.Info); + if (bitmap != null) + { + Bitmap = bitmap; + } } }); @@ -346,18 +804,121 @@ public class SkiaImage : SkiaView public async Task LoadFromUriAsync(Uri uri) { _isLoading = true; + _cacheKey = uri.ToString(); Invalidate(); try { - using HttpClient httpClient = new HttpClient(); - using MemoryStream stream = new MemoryStream(await httpClient.GetByteArrayAsync(uri)); - SKBitmap? bitmap = SKBitmap.Decode(stream); - if (bitmap != null) + // Check cache first + if (_imageCache.TryGetValue(_cacheKey, out var cached)) { - Bitmap = bitmap; + cached.LastAccessed = DateTime.UtcNow; + if (cached.IsAnimated && cached.Frames != null) + { + _isAnimatedImage = true; + _animationFrames = cached.Frames; + _currentFrameIndex = 0; + if (cached.Frames.Count > 0 && cached.Frames[0].Bitmap != null) + { + _image?.Dispose(); + _image = SKImage.FromBitmap(cached.Frames[0].Bitmap); + } + if (IsAnimationPlaying) + { + StartAnimation(); + } + } + else if (cached.Bitmap != null) + { + _isAnimatedImage = false; + _bitmap = cached.Bitmap; + _image?.Dispose(); + _image = SKImage.FromBitmap(cached.Bitmap); + } + _isLoading = false; + ImageLoaded?.Invoke(this, EventArgs.Empty); + Invalidate(); + return; } + using HttpClient httpClient = new HttpClient(); + var data = await httpClient.GetByteArrayAsync(uri); + using var stream = new MemoryStream(data); + + await Task.Run(() => + { + using var codec = SKCodec.Create(stream); + + if (codec == null) + { + stream.Position = 0; + var bitmap = SKBitmap.Decode(stream); + if (bitmap != null) + { + _isAnimatedImage = false; + CacheAndSetBitmap(_cacheKey, bitmap, false); + } + return; + } + + int frameCount = codec.FrameCount; + + if (frameCount > 1) + { + // Animated image + _isAnimatedImage = true; + _animationFrames = new List(); + var info = codec.Info; + + for (int i = 0; i < frameCount; i++) + { + var frameInfo = codec.FrameInfo[i]; + var bitmap = new SKBitmap(info.Width, info.Height); + + var options = new SKCodecOptions(i); + codec.GetPixels(bitmap.Info, bitmap.GetPixels(), options); + + _animationFrames.Add(new AnimationFrame + { + Bitmap = bitmap, + Duration = frameInfo.Duration > 0 ? frameInfo.Duration : 100 + }); + } + + // Cache the animation frames + long memorySize = _animationFrames.Sum(f => (long)(f.Bitmap?.ByteCount ?? 0)); + _imageCache[_cacheKey] = new CachedImage + { + Frames = _animationFrames, + IsAnimated = true, + LastAccessed = DateTime.UtcNow, + MemorySize = memorySize + }; + TrimCacheIfNeeded(); + + _currentFrameIndex = 0; + if (_animationFrames.Count > 0 && _animationFrames[0].Bitmap != null) + { + _image?.Dispose(); + _image = SKImage.FromBitmap(_animationFrames[0].Bitmap); + } + + if (IsAnimationPlaying) + { + StartAnimation(); + } + } + else + { + _isAnimatedImage = false; + var bitmap = SKBitmap.Decode(codec, codec.Info); + if (bitmap != null) + { + CacheAndSetBitmap(_cacheKey, bitmap, false); + } + } + }); + _isLoading = false; ImageLoaded?.Invoke(this, EventArgs.Empty); } @@ -374,12 +935,69 @@ public class SkiaImage : SkiaView { try { - using MemoryStream stream = new MemoryStream(data); - SKBitmap? bitmap = SKBitmap.Decode(stream); - if (bitmap != null) + _cacheKey = null; + using var stream = new MemoryStream(data); + using var codec = SKCodec.Create(stream); + + if (codec == null) { - Bitmap = bitmap; + stream.Position = 0; + var bitmap = SKBitmap.Decode(stream); + if (bitmap != null) + { + _isAnimatedImage = false; + Bitmap = bitmap; + } + ImageLoaded?.Invoke(this, EventArgs.Empty); + return; } + + int frameCount = codec.FrameCount; + + if (frameCount > 1) + { + // Animated image + _isAnimatedImage = true; + _animationFrames = new List(); + var info = codec.Info; + + for (int i = 0; i < frameCount; i++) + { + var frameInfo = codec.FrameInfo[i]; + var bitmap = new SKBitmap(info.Width, info.Height); + + var options = new SKCodecOptions(i); + codec.GetPixels(bitmap.Info, bitmap.GetPixels(), options); + + _animationFrames.Add(new AnimationFrame + { + Bitmap = bitmap, + Duration = frameInfo.Duration > 0 ? frameInfo.Duration : 100 + }); + } + + _currentFrameIndex = 0; + if (_animationFrames.Count > 0 && _animationFrames[0].Bitmap != null) + { + _image?.Dispose(); + _image = SKImage.FromBitmap(_animationFrames[0].Bitmap); + } + + if (IsAnimationPlaying) + { + StartAnimation(); + } + } + else + { + _isAnimatedImage = false; + var bitmap = SKBitmap.Decode(codec, codec.Info); + if (bitmap != null) + { + Bitmap = bitmap; + } + } + ImageLoaded?.Invoke(this, EventArgs.Empty); } catch (Exception ex) @@ -397,6 +1015,10 @@ public class SkiaImage : SkiaView { _isSvg = false; _currentFilePath = null; + _cacheKey = null; + _isAnimatedImage = false; + StopAnimation(); + _animationFrames = null; Bitmap = bitmap; _isLoading = false; ImageLoaded?.Invoke(this, EventArgs.Empty); @@ -426,7 +1048,6 @@ public class SkiaImage : SkiaView (width != _lastArrangedBounds.Width || height != _lastArrangedBounds.Height)) { _lastArrangedBounds = bounds; - Console.WriteLine($"[SkiaImage] Arrange detected larger bounds: {width}x{height} vs loaded {_svgLoadedWidth}x{_svgLoadedHeight}"); _ = LoadSvgAtSizeAsync(_currentFilePath, width, height); } } @@ -435,42 +1056,24 @@ public class SkiaImage : SkiaView protected override SKRect ArrangeOverride(SKRect bounds) { - // If we have explicit size requests, constrain to desired size - // This follows MAUI standard behavior - controls respect WidthRequest/HeightRequest var desiredWidth = DesiredSize.Width; var desiredHeight = DesiredSize.Height; - // If desired size is smaller than available bounds, align within bounds if (desiredWidth > 0 && desiredHeight > 0 && (desiredWidth < bounds.Width || desiredHeight < bounds.Height)) { float finalWidth = Math.Min(desiredWidth, bounds.Width); float finalHeight = Math.Min(desiredHeight, bounds.Height); - // Calculate position based on HorizontalOptions - // LayoutAlignment: Start=0, Center=1, End=2, Fill=3 float x = bounds.Left; var hAlignValue = (int)HorizontalOptions.Alignment; - if (hAlignValue == 1) // Center - { - x = bounds.Left + (bounds.Width - finalWidth) / 2; - } - else if (hAlignValue == 2) // End - { - x = bounds.Right - finalWidth; - } + if (hAlignValue == 1) x = bounds.Left + (bounds.Width - finalWidth) / 2; + else if (hAlignValue == 2) x = bounds.Right - finalWidth; - // Calculate position based on VerticalOptions float y = bounds.Top; var vAlignValue = (int)VerticalOptions.Alignment; - if (vAlignValue == 1) // Center - { - y = bounds.Top + (bounds.Height - finalHeight) / 2; - } - else if (vAlignValue == 2) // End - { - y = bounds.Bottom - finalHeight; - } + if (vAlignValue == 1) y = bounds.Top + (bounds.Height - finalHeight) / 2; + else if (vAlignValue == 2) y = bounds.Bottom - finalHeight; return new SKRect(x, y, x + finalWidth, y + finalHeight); } @@ -483,40 +1086,31 @@ public class SkiaImage : SkiaView double widthRequest = base.WidthRequest; double heightRequest = base.HeightRequest; - // If both dimensions explicitly requested, use them if (widthRequest > 0.0 && heightRequest > 0.0) - { return new SKSize((float)widthRequest, (float)heightRequest); - } - // If no image, return default or requested size if (_image == null) { - if (widthRequest > 0.0) - return new SKSize((float)widthRequest, (float)widthRequest); - if (heightRequest > 0.0) - return new SKSize((float)heightRequest, (float)heightRequest); + if (widthRequest > 0.0) return new SKSize((float)widthRequest, (float)widthRequest); + if (heightRequest > 0.0) return new SKSize((float)heightRequest, (float)heightRequest); return new SKSize(100f, 100f); } float imageWidth = _image.Width; float imageHeight = _image.Height; - // If only width requested, scale height proportionally if (widthRequest > 0.0) { float scale = (float)widthRequest / imageWidth; return new SKSize((float)widthRequest, imageHeight * scale); } - // If only height requested, scale width proportionally if (heightRequest > 0.0) { float scale = (float)heightRequest / imageHeight; return new SKSize(imageWidth * scale, (float)heightRequest); } - // Scale to fit available size if (availableSize.Width < float.MaxValue && availableSize.Height < float.MaxValue) { float scale = Math.Min(availableSize.Width / imageWidth, availableSize.Height / imageHeight); @@ -542,7 +1136,20 @@ public class SkiaImage : SkiaView { if (disposing) { - _bitmap?.Dispose(); + StopAnimation(); + + // Only dispose if not cached + if (_cacheKey == null || !_imageCache.ContainsKey(_cacheKey)) + { + _bitmap?.Dispose(); + if (_animationFrames != null) + { + foreach (var frame in _animationFrames) + { + frame.Bitmap?.Dispose(); + } + } + } _image?.Dispose(); } base.Dispose(disposing); diff --git a/Views/SkiaImageButton.cs b/Views/SkiaImageButton.cs index 5f2d0ec..2d0fcb0 100644 --- a/Views/SkiaImageButton.cs +++ b/Views/SkiaImageButton.cs @@ -6,6 +6,9 @@ using System.Collections.Generic; using System.IO; using System.Net.Http; using System.Threading.Tasks; +using System.Windows.Input; +using Microsoft.Maui.Controls; +using Microsoft.Maui.Graphics; using SkiaSharp; using Svg.Skia; @@ -14,12 +17,79 @@ namespace Microsoft.Maui.Platform; /// /// Skia-rendered image button control. /// Combines button behavior with image display. +/// Implements MAUI IImageButton interface requirements. /// public class SkiaImageButton : SkiaView { + #region Private Fields private SKBitmap? _bitmap; private SKImage? _image; private bool _isLoading; + #endregion + + #region SKColor Helper + private static SKColor ToSKColor(Color? color) + { + if (color == null) return SKColors.Transparent; + return new SKColor( + (byte)(color.Red * 255), + (byte)(color.Green * 255), + (byte)(color.Blue * 255), + (byte)(color.Alpha * 255)); + } + #endregion + + #region BindableProperties + + public static readonly BindableProperty AspectProperty = BindableProperty.Create( + nameof(Aspect), typeof(Aspect), typeof(SkiaImageButton), Aspect.AspectFit, + propertyChanged: (b, o, n) => ((SkiaImageButton)b).Invalidate()); + + public static readonly BindableProperty IsOpaqueProperty = BindableProperty.Create( + nameof(IsOpaque), typeof(bool), typeof(SkiaImageButton), false, + propertyChanged: (b, o, n) => ((SkiaImageButton)b).Invalidate()); + + public static readonly BindableProperty StrokeColorProperty = BindableProperty.Create( + nameof(StrokeColor), typeof(Color), typeof(SkiaImageButton), Colors.Transparent, + propertyChanged: (b, o, n) => ((SkiaImageButton)b).Invalidate()); + + public static readonly BindableProperty StrokeThicknessProperty = BindableProperty.Create( + nameof(StrokeThickness), typeof(double), typeof(SkiaImageButton), 0.0, + propertyChanged: (b, o, n) => ((SkiaImageButton)b).Invalidate()); + + public static readonly BindableProperty CornerRadiusProperty = BindableProperty.Create( + nameof(CornerRadius), typeof(int), typeof(SkiaImageButton), 0, + propertyChanged: (b, o, n) => ((SkiaImageButton)b).Invalidate()); + + public static readonly BindableProperty PaddingProperty = BindableProperty.Create( + nameof(Padding), typeof(Thickness), typeof(SkiaImageButton), new Thickness(0), + propertyChanged: (b, o, n) => ((SkiaImageButton)b).Invalidate()); + + public static readonly BindableProperty PressedBackgroundColorProperty = BindableProperty.Create( + nameof(PressedBackgroundColor), typeof(Color), typeof(SkiaImageButton), + Color.FromRgba(0, 0, 0, 30), + propertyChanged: (b, o, n) => ((SkiaImageButton)b).Invalidate()); + + public static readonly BindableProperty HoveredBackgroundColorProperty = BindableProperty.Create( + nameof(HoveredBackgroundColor), typeof(Color), typeof(SkiaImageButton), + Color.FromRgba(0, 0, 0, 15), + propertyChanged: (b, o, n) => ((SkiaImageButton)b).Invalidate()); + + public static readonly BindableProperty ImageBackgroundColorProperty = BindableProperty.Create( + nameof(ImageBackgroundColor), typeof(Color), typeof(SkiaImageButton), null, + propertyChanged: (b, o, n) => ((SkiaImageButton)b).Invalidate()); + + public static readonly BindableProperty CommandProperty = BindableProperty.Create( + nameof(Command), typeof(ICommand), typeof(SkiaImageButton), null, + propertyChanged: OnCommandChanged); + + public static readonly BindableProperty CommandParameterProperty = BindableProperty.Create( + nameof(CommandParameter), typeof(object), typeof(SkiaImageButton), null, + propertyChanged: (b, o, n) => ((SkiaImageButton)b).UpdateCommandCanExecute()); + + #endregion + + #region Properties public SKBitmap? Bitmap { @@ -34,57 +104,172 @@ public class SkiaImageButton : SkiaView } } - // Image properties - public Aspect Aspect { get; set; } = Aspect.AspectFit; - public bool IsOpaque { get; set; } + public Aspect Aspect + { + get => (Aspect)GetValue(AspectProperty); + set => SetValue(AspectProperty, value); + } + + public bool IsOpaque + { + get => (bool)GetValue(IsOpaqueProperty); + set => SetValue(IsOpaqueProperty, value); + } + public bool IsLoading => _isLoading; - // Button stroke properties - public SKColor StrokeColor { get; set; } = SKColors.Transparent; - public float StrokeThickness { get; set; } = 0; - public float CornerRadius { get; set; } = 0; + public Color StrokeColor + { + get => (Color)GetValue(StrokeColorProperty); + set => SetValue(StrokeColorProperty, value); + } + + public double StrokeThickness + { + get => (double)GetValue(StrokeThicknessProperty); + set => SetValue(StrokeThicknessProperty, value); + } + + public int CornerRadius + { + get => (int)GetValue(CornerRadiusProperty); + set => SetValue(CornerRadiusProperty, value); + } + + public Thickness Padding + { + get => (Thickness)GetValue(PaddingProperty); + set => SetValue(PaddingProperty, value); + } + + public Color PressedBackgroundColor + { + get => (Color)GetValue(PressedBackgroundColorProperty); + set => SetValue(PressedBackgroundColorProperty, value); + } + + public Color HoveredBackgroundColor + { + get => (Color)GetValue(HoveredBackgroundColorProperty); + set => SetValue(HoveredBackgroundColorProperty, value); + } + + public Color? ImageBackgroundColor + { + get => (Color?)GetValue(ImageBackgroundColorProperty); + set => SetValue(ImageBackgroundColorProperty, value); + } + + public ICommand? Command + { + get => (ICommand?)GetValue(CommandProperty); + set => SetValue(CommandProperty, value); + } + + public object? CommandParameter + { + get => GetValue(CommandParameterProperty); + set => SetValue(CommandParameterProperty, value); + } // Button state public bool IsPressed { get; private set; } public bool IsHovered { get; private set; } - // Visual state colors - public SKColor PressedBackgroundColor { get; set; } = new SKColor(0, 0, 0, 30); - public SKColor HoveredBackgroundColor { get; set; } = new SKColor(0, 0, 0, 15); - - // Padding for the image content - public float PaddingLeft { get; set; } - public float PaddingTop { get; set; } - public float PaddingRight { get; set; } - public float PaddingBottom { get; set; } + #endregion + #region Events public event EventHandler? Clicked; public event EventHandler? Pressed; public event EventHandler? Released; public event EventHandler? ImageLoaded; public event EventHandler? ImageLoadingError; + #endregion + + #region Constructor public SkiaImageButton() { IsFocusable = true; } + #endregion + + #region Command Support + + private static void OnCommandChanged(BindableObject bindable, object oldValue, object newValue) + { + var button = (SkiaImageButton)bindable; + + if (oldValue is ICommand oldCommand) + { + oldCommand.CanExecuteChanged -= button.OnCommandCanExecuteChanged; + } + + if (newValue is ICommand newCommand) + { + newCommand.CanExecuteChanged += button.OnCommandCanExecuteChanged; + } + + button.UpdateCommandCanExecute(); + } + + private void OnCommandCanExecuteChanged(object? sender, EventArgs e) + { + UpdateCommandCanExecute(); + } + + private void UpdateCommandCanExecute() + { + if (Command != null) + { + IsEnabled = Command.CanExecute(CommandParameter); + } + } + + private void ExecuteCommand() + { + if (Command?.CanExecute(CommandParameter) == true) + { + Command.Execute(CommandParameter); + } + } + + #endregion + + #region Rendering + protected override void OnDraw(SKCanvas canvas, SKRect bounds) { - // Apply padding + var padding = Padding; var contentBounds = new SKRect( - bounds.Left + PaddingLeft, - bounds.Top + PaddingTop, - bounds.Right - PaddingRight, - bounds.Bottom - PaddingBottom); + bounds.Left + (float)padding.Left, + bounds.Top + (float)padding.Top, + bounds.Right - (float)padding.Right, + bounds.Bottom - (float)padding.Bottom); - // Draw background based on state - if (IsPressed || IsHovered || !IsOpaque && BackgroundColor != SKColors.Transparent) + // Determine background color + SKColor bgColor; + if (IsPressed) { - var bgColor = IsPressed ? PressedBackgroundColor - : IsHovered ? HoveredBackgroundColor - : BackgroundColor; + bgColor = ToSKColor(PressedBackgroundColor); + } + else if (IsHovered) + { + bgColor = ToSKColor(HoveredBackgroundColor); + } + else if (ImageBackgroundColor != null) + { + bgColor = ToSKColor(ImageBackgroundColor); + } + else + { + bgColor = BackgroundColor; + } + // Draw background + if (bgColor != SKColors.Transparent || !IsOpaque) + { using var bgPaint = new SKPaint { Color = bgColor, @@ -130,13 +315,15 @@ public class SkiaImageButton : SkiaView } // Draw stroke/border - if (StrokeThickness > 0 && StrokeColor != SKColors.Transparent) + var strokeThickness = (float)StrokeThickness; + var strokeColor = ToSKColor(StrokeColor); + if (strokeThickness > 0 && strokeColor != SKColors.Transparent) { using var strokePaint = new SKPaint { - Color = StrokeColor, + Color = strokeColor, Style = SKPaintStyle.Stroke, - StrokeWidth = StrokeThickness, + StrokeWidth = strokeThickness, IsAntialias = true }; @@ -210,7 +397,10 @@ public class SkiaImageButton : SkiaView } } - // Image loading methods + #endregion + + #region Image Loading + public async Task LoadFromFileAsync(string filePath) { _isLoading = true; @@ -257,6 +447,7 @@ public class SkiaImageButton : SkiaView return; } + var padding = Padding; await Task.Run(() => { if (foundPath.EndsWith(".svg", StringComparison.OrdinalIgnoreCase)) @@ -272,10 +463,10 @@ public class SkiaImageButton : SkiaView // Default to 24x24 for icons when no size specified const float DefaultIconSize = 24f; float targetWidth = hasWidth - ? (float)(WidthRequest - PaddingLeft - PaddingRight) + ? (float)(WidthRequest - padding.Left - padding.Right) : DefaultIconSize; float targetHeight = hasHeight - ? (float)(HeightRequest - PaddingTop - PaddingBottom) + ? (float)(HeightRequest - padding.Top - padding.Bottom) : DefaultIconSize; float scale = Math.Min(targetWidth / cullRect.Width, targetHeight / cullRect.Height); @@ -376,6 +567,12 @@ public class SkiaImageButton : SkiaView { try { + if (data == null || data.Length == 0) + { + Bitmap = null; + return; + } + using var stream = new MemoryStream(data); var bitmap = SKBitmap.Decode(stream); if (bitmap != null) @@ -390,7 +587,16 @@ public class SkiaImageButton : SkiaView } } - // Pointer event handlers + public void LoadFromBitmap(SKBitmap bitmap) + { + Bitmap = bitmap; + ImageLoaded?.Invoke(this, EventArgs.Empty); + } + + #endregion + + #region Pointer Event Handlers + public override void OnPointerEntered(PointerEventArgs e) { if (!IsEnabled) return; @@ -438,10 +644,14 @@ public class SkiaImageButton : SkiaView if (wasPressed && Bounds.Contains(new SKPoint(e.X, e.Y))) { Clicked?.Invoke(this, EventArgs.Empty); + ExecuteCommand(); } } - // Keyboard event handlers + #endregion + + #region Keyboard Event Handlers + public override void OnKeyDown(KeyEventArgs e) { if (!IsEnabled) return; @@ -467,13 +677,22 @@ public class SkiaImageButton : SkiaView Invalidate(); Released?.Invoke(this, EventArgs.Empty); Clicked?.Invoke(this, EventArgs.Empty); + ExecuteCommand(); } e.Handled = true; } } + #endregion + + #region Layout + protected override SKSize MeasureOverride(SKSize availableSize) { + var padding = Padding; + var paddingWidth = (float)(padding.Left + padding.Right); + var paddingHeight = (float)(padding.Top + padding.Bottom); + // Respect explicit WidthRequest/HeightRequest first (MAUI standard behavior) if (WidthRequest > 0 && HeightRequest > 0) { @@ -497,10 +716,8 @@ public class SkiaImageButton : SkiaView } // No explicit size - calculate from content - var padding = new SKSize(PaddingLeft + PaddingRight, PaddingTop + PaddingBottom); - if (_image == null) - return new SKSize(44 + padding.Width, 44 + padding.Height); // Default touch target size + return new SKSize(44 + paddingWidth, 44 + paddingHeight); // Default touch target size var imageWidth = _image.Width; var imageHeight = _image.Height; @@ -508,25 +725,25 @@ public class SkiaImageButton : SkiaView if (availableSize.Width < float.MaxValue && availableSize.Height < float.MaxValue) { var availableContent = new SKSize( - availableSize.Width - padding.Width, - availableSize.Height - padding.Height); + availableSize.Width - paddingWidth, + availableSize.Height - paddingHeight); var scale = Math.Min(availableContent.Width / imageWidth, availableContent.Height / imageHeight); - return new SKSize(imageWidth * scale + padding.Width, imageHeight * scale + padding.Height); + return new SKSize(imageWidth * scale + paddingWidth, imageHeight * scale + paddingHeight); } else if (availableSize.Width < float.MaxValue) { - var availableWidth = availableSize.Width - padding.Width; + var availableWidth = availableSize.Width - paddingWidth; var scale = availableWidth / imageWidth; - return new SKSize(availableSize.Width, imageHeight * scale + padding.Height); + return new SKSize(availableSize.Width, imageHeight * scale + paddingHeight); } else if (availableSize.Height < float.MaxValue) { - var availableHeight = availableSize.Height - padding.Height; + var availableHeight = availableSize.Height - paddingHeight; var scale = availableHeight / imageHeight; - return new SKSize(imageWidth * scale + padding.Width, availableSize.Height); + return new SKSize(imageWidth * scale + paddingWidth, availableSize.Height); } - return new SKSize(imageWidth + padding.Width, imageHeight + padding.Height); + return new SKSize(imageWidth + paddingWidth, imageHeight + paddingHeight); } protected override SKRect ArrangeOverride(SKRect bounds) @@ -576,13 +793,25 @@ public class SkiaImageButton : SkiaView return bounds; } + #endregion + + #region Dispose + protected override void Dispose(bool disposing) { if (disposing) { + // Unsubscribe from command + if (Command != null) + { + Command.CanExecuteChanged -= OnCommandCanExecuteChanged; + } + _bitmap?.Dispose(); _image?.Dispose(); } base.Dispose(disposing); } + + #endregion }