// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System; 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 Microsoft.Maui.Platform.Linux.Services; using SkiaSharp; using Svg.Skia; 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 /// /// Converts a MAUI Color to SkiaSharp SKColor. /// Uses the ToSKColor() extension from ColorExtensions for MAUI-compliant theming. /// private static SKColor ToSKColor(Color? color) { if (color == null) return SKColors.Transparent; return color.ToSKColor(); } #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 { get => _bitmap; set { _bitmap?.Dispose(); _bitmap = value; _image?.Dispose(); _image = value != null ? SKImage.FromBitmap(value) : null; Invalidate(); } } 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; 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; } #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 DrawBackground(SKCanvas canvas, SKRect bounds) { // Skip base background drawing if button is transparent var baseBgColor = ImageBackgroundColor != null ? ToSKColor(ImageBackgroundColor) : GetEffectiveBackgroundColor(); if (baseBgColor.Alpha < 10) return; // Otherwise let base class draw base.DrawBackground(canvas, bounds); } protected override void OnDraw(SKCanvas canvas, SKRect bounds) { var padding = Padding; var contentBounds = new SKRect( bounds.Left + (float)padding.Left, bounds.Top + (float)padding.Top, bounds.Right - (float)padding.Right, bounds.Bottom - (float)padding.Bottom); // Determine background color var baseBgColor = ImageBackgroundColor != null ? ToSKColor(ImageBackgroundColor) : GetEffectiveBackgroundColor(); var isTransparentButton = baseBgColor.Alpha < 10; SKColor bgColor; if (IsPressed && !isTransparentButton) { // Only show pressed state for non-transparent buttons bgColor = ToSKColor(PressedBackgroundColor); } else if (IsHovered && !isTransparentButton) { // Only show hovered state for non-transparent buttons bgColor = ToSKColor(HoveredBackgroundColor); } else { bgColor = baseBgColor; } // Draw background (skip if fully transparent) if (bgColor.Alpha > 0) { using var bgPaint = new SKPaint { Color = bgColor, Style = SKPaintStyle.Fill, IsAntialias = true }; if (CornerRadius > 0) { var roundRect = new SKRoundRect(bounds, CornerRadius); canvas.DrawRoundRect(roundRect, bgPaint); } else { canvas.DrawRect(bounds, bgPaint); } } // Draw image if (_image != null) { var imageWidth = _image.Width; var imageHeight = _image.Height; if (imageWidth > 0 && imageHeight > 0) { var destRect = CalculateDestRect(contentBounds, imageWidth, imageHeight); using var paint = new SKPaint { IsAntialias = true, FilterQuality = SKFilterQuality.High }; // Apply opacity when disabled if (!IsEnabled) { paint.Color = paint.Color.WithAlpha(128); } canvas.DrawImage(_image, destRect, paint); } } // Draw stroke/border var strokeThickness = (float)StrokeThickness; var strokeColor = ToSKColor(StrokeColor); if (strokeThickness > 0 && strokeColor != SKColors.Transparent) { using var strokePaint = new SKPaint { Color = strokeColor, Style = SKPaintStyle.Stroke, StrokeWidth = strokeThickness, IsAntialias = true }; if (CornerRadius > 0) { var roundRect = new SKRoundRect(bounds, CornerRadius); canvas.DrawRoundRect(roundRect, strokePaint); } else { canvas.DrawRect(bounds, strokePaint); } } // Draw focus ring if (IsFocused) { using var focusPaint = new SKPaint { Color = SkiaTheme.Shadow25SK, Style = SKPaintStyle.Stroke, StrokeWidth = 2, IsAntialias = true }; var focusBounds = new SKRect(bounds.Left - 2, bounds.Top - 2, bounds.Right + 2, bounds.Bottom + 2); if (CornerRadius > 0) { var focusRect = new SKRoundRect(focusBounds, CornerRadius + 2); canvas.DrawRoundRect(focusRect, focusPaint); } else { canvas.DrawRect(focusBounds, focusPaint); } } } private SKRect CalculateDestRect(SKRect bounds, float imageWidth, float imageHeight) { float destX, destY, destWidth, destHeight; switch (Aspect) { case Aspect.Fill: return bounds; case Aspect.AspectFit: var fitScale = Math.Min(bounds.Width / imageWidth, bounds.Height / imageHeight); destWidth = imageWidth * fitScale; destHeight = imageHeight * fitScale; destX = bounds.Left + (bounds.Width - destWidth) / 2; destY = bounds.Top + (bounds.Height - destHeight) / 2; return new SKRect(destX, destY, destX + destWidth, destY + destHeight); case Aspect.AspectFill: var fillScale = Math.Max(bounds.Width / imageWidth, bounds.Height / imageHeight); destWidth = imageWidth * fillScale; destHeight = imageHeight * fillScale; destX = bounds.Left + (bounds.Width - destWidth) / 2; destY = bounds.Top + (bounds.Height - destHeight) / 2; return new SKRect(destX, destY, destX + destWidth, destY + destHeight); case Aspect.Center: destX = bounds.Left + (bounds.Width - imageWidth) / 2; destY = bounds.Top + (bounds.Height - imageHeight) / 2; return new SKRect(destX, destY, destX + imageWidth, destY + imageHeight); default: return bounds; } } #endregion #region Image Loading public async Task LoadFromFileAsync(string filePath) { _isLoading = true; Invalidate(); DiagnosticLog.Debug("SkiaImageButton", "LoadFromFileAsync: " + filePath); try { var searchPaths = new List { filePath, Path.Combine(AppContext.BaseDirectory, filePath), Path.Combine(AppContext.BaseDirectory, "Resources", "Images", filePath), Path.Combine(AppContext.BaseDirectory, "Resources", filePath) }; // Also check for SVG version if PNG was requested if (filePath.EndsWith(".png", StringComparison.OrdinalIgnoreCase)) { var svgPath = Path.ChangeExtension(filePath, ".svg"); searchPaths.Add(svgPath); searchPaths.Add(Path.Combine(AppContext.BaseDirectory, svgPath)); searchPaths.Add(Path.Combine(AppContext.BaseDirectory, "Resources", "Images", svgPath)); searchPaths.Add(Path.Combine(AppContext.BaseDirectory, "Resources", svgPath)); } string? foundPath = null; foreach (var path in searchPaths) { if (File.Exists(path)) { foundPath = path; DiagnosticLog.Debug("SkiaImageButton", "Found file at: " + path); break; } } if (foundPath == null) { DiagnosticLog.Warn("SkiaImageButton", "File not found: " + filePath); DiagnosticLog.Debug("SkiaImageButton", "Searched paths: " + string.Join(", ", searchPaths)); _isLoading = false; ImageLoadingError?.Invoke(this, new ImageLoadingErrorEventArgs(new FileNotFoundException(filePath))); return; } var padding = Padding; await Task.Run(() => { if (foundPath.EndsWith(".svg", StringComparison.OrdinalIgnoreCase)) { using var svg = new SKSvg(); svg.Load(foundPath); if (svg.Picture != null) { var cullRect = svg.Picture.CullRect; bool hasWidth = WidthRequest > 0; bool hasHeight = HeightRequest > 0; // Default to 24x24 for icons when no size specified const float DefaultIconSize = 24f; float targetWidth = hasWidth ? (float)(WidthRequest - padding.Left - padding.Right) : DefaultIconSize; float targetHeight = hasHeight ? (float)(HeightRequest - padding.Top - padding.Bottom) : DefaultIconSize; float scale = Math.Min(targetWidth / cullRect.Width, targetHeight / cullRect.Height); int width = Math.Max(1, (int)(cullRect.Width * scale)); int height = Math.Max(1, (int)(cullRect.Height * scale)); var bitmap = new SKBitmap(width, height, false); using var canvas = new SKCanvas(bitmap); canvas.Clear(SKColors.Transparent); canvas.Scale(scale); // Translate to handle negative viewBox coordinates (e.g., Material icons use 0 -960 960 960) canvas.Translate(-cullRect.Left, -cullRect.Top); canvas.DrawPicture(svg.Picture); Bitmap = bitmap; DiagnosticLog.Debug("SkiaImageButton", $"Loaded SVG: {foundPath} ({width}x{height}), cullRect={cullRect}"); } } else { using var stream = File.OpenRead(foundPath); var bitmap = SKBitmap.Decode(stream); if (bitmap != null) { Bitmap = bitmap; DiagnosticLog.Debug("SkiaImageButton", "Loaded image: " + foundPath); } } }); _isLoading = false; ImageLoaded?.Invoke(this, EventArgs.Empty); } catch (Exception ex) { _isLoading = false; ImageLoadingError?.Invoke(this, new ImageLoadingErrorEventArgs(ex)); } Invalidate(); } public async Task LoadFromStreamAsync(Stream stream) { _isLoading = true; Invalidate(); try { await Task.Run(() => { var bitmap = SKBitmap.Decode(stream); if (bitmap != null) { Bitmap = bitmap; } }); _isLoading = false; ImageLoaded?.Invoke(this, EventArgs.Empty); } catch (Exception ex) { _isLoading = false; ImageLoadingError?.Invoke(this, new ImageLoadingErrorEventArgs(ex)); } Invalidate(); } public async Task LoadFromUriAsync(Uri uri) { _isLoading = true; Invalidate(); try { using var httpClient = new HttpClient(); var data = await httpClient.GetByteArrayAsync(uri); using var stream = new MemoryStream(data); var bitmap = SKBitmap.Decode(stream); if (bitmap != null) { Bitmap = bitmap; } _isLoading = false; ImageLoaded?.Invoke(this, EventArgs.Empty); } catch (Exception ex) { _isLoading = false; ImageLoadingError?.Invoke(this, new ImageLoadingErrorEventArgs(ex)); } Invalidate(); } public void LoadFromData(byte[] data) { try { if (data == null || data.Length == 0) { Bitmap = null; return; } using var stream = new MemoryStream(data); var bitmap = SKBitmap.Decode(stream); if (bitmap != null) { Bitmap = bitmap; } ImageLoaded?.Invoke(this, EventArgs.Empty); } catch (Exception ex) { ImageLoadingError?.Invoke(this, new ImageLoadingErrorEventArgs(ex)); } } 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; IsHovered = true; SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.PointerOver); Invalidate(); } public override void OnPointerExited(PointerEventArgs e) { IsHovered = false; if (IsPressed) { IsPressed = false; } SkiaVisualStateManager.GoToState(this, IsEnabled ? SkiaVisualStateManager.CommonStates.Normal : SkiaVisualStateManager.CommonStates.Disabled); Invalidate(); } public override void OnPointerPressed(PointerEventArgs e) { if (!IsEnabled) return; IsPressed = true; SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.Pressed); Invalidate(); Pressed?.Invoke(this, EventArgs.Empty); } public override void OnPointerReleased(PointerEventArgs e) { if (!IsEnabled) return; var wasPressed = IsPressed; IsPressed = false; SkiaVisualStateManager.GoToState(this, IsHovered ? SkiaVisualStateManager.CommonStates.PointerOver : SkiaVisualStateManager.CommonStates.Normal); Invalidate(); Released?.Invoke(this, EventArgs.Empty); if (wasPressed && Bounds.Contains(e.X, e.Y)) { Clicked?.Invoke(this, EventArgs.Empty); ExecuteCommand(); } } #endregion #region Keyboard Event Handlers public override void OnKeyDown(KeyEventArgs e) { if (!IsEnabled) return; if (e.Key == Key.Enter || e.Key == Key.Space) { IsPressed = true; Invalidate(); Pressed?.Invoke(this, EventArgs.Empty); e.Handled = true; } } public override void OnKeyUp(KeyEventArgs e) { if (!IsEnabled) return; if (e.Key == Key.Enter || e.Key == Key.Space) { if (IsPressed) { IsPressed = false; Invalidate(); Released?.Invoke(this, EventArgs.Empty); Clicked?.Invoke(this, EventArgs.Empty); ExecuteCommand(); } e.Handled = true; } } #endregion #region Layout protected override Size MeasureOverride(Size availableSize) { var padding = Padding; var paddingWidth = padding.Left + padding.Right; var paddingHeight = padding.Top + padding.Bottom; // Respect explicit WidthRequest/HeightRequest first (MAUI standard behavior) if (WidthRequest > 0 && HeightRequest > 0) { return new Size(WidthRequest, HeightRequest); } if (WidthRequest > 0) { // Fixed width, calculate height from aspect ratio or use width double height = HeightRequest > 0 ? HeightRequest : _image != null ? WidthRequest * _image.Height / _image.Width : WidthRequest; return new Size(WidthRequest, height); } if (HeightRequest > 0) { // Fixed height, calculate width from aspect ratio or use height double width = WidthRequest > 0 ? WidthRequest : _image != null ? HeightRequest * _image.Width / _image.Height : HeightRequest; return new Size(width, HeightRequest); } // No explicit size - calculate from content if (_image == null) return new Size(44 + paddingWidth, 44 + paddingHeight); // Default touch target size var imageWidth = _image.Width; var imageHeight = _image.Height; if (availableSize.Width < double.MaxValue && availableSize.Height < double.MaxValue) { var availableContentW = availableSize.Width - paddingWidth; var availableContentH = availableSize.Height - paddingHeight; var scale = Math.Min(availableContentW / imageWidth, availableContentH / imageHeight); return new Size(imageWidth * scale + paddingWidth, imageHeight * scale + paddingHeight); } else if (availableSize.Width < double.MaxValue) { var availableWidth = availableSize.Width - paddingWidth; var scale = availableWidth / imageWidth; return new Size(availableSize.Width, imageHeight * scale + paddingHeight); } else if (availableSize.Height < double.MaxValue) { var availableHeight = availableSize.Height - paddingHeight; var scale = availableHeight / imageHeight; return new Size(imageWidth * scale + paddingWidth, availableSize.Height); } return new Size(imageWidth + paddingWidth, imageHeight + paddingHeight); } protected override Rect ArrangeOverride(Rect 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)) { double finalWidth = Math.Min(desiredWidth, bounds.Width); double finalHeight = Math.Min(desiredHeight, bounds.Height); // Calculate position based on HorizontalOptions // LayoutAlignment: Start=0, Center=1, End=2, Fill=3 double 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; } // Fill (3) and Start (0) both use x = bounds.Left // Calculate position based on VerticalOptions double 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; } // Fill (3) and Start (0) both use y = bounds.Top return new Rect(x, y, finalWidth, finalHeight); } 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 }