// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using SkiaSharp; using Microsoft.Maui.Platform.Linux.Rendering; namespace Microsoft.Maui.Platform; /// /// Skia-rendered button control with full XAML styling support. /// public class SkiaButton : SkiaView { #region BindableProperties /// /// Bindable property for Text. /// public static readonly BindableProperty TextProperty = BindableProperty.Create( nameof(Text), typeof(string), typeof(SkiaButton), "", propertyChanged: (b, o, n) => ((SkiaButton)b).OnTextChanged()); /// /// Bindable property for TextColor. /// public static readonly BindableProperty TextColorProperty = BindableProperty.Create( nameof(TextColor), typeof(SKColor), typeof(SkiaButton), SKColors.White, propertyChanged: (b, o, n) => ((SkiaButton)b).Invalidate()); /// /// Bindable property for ButtonBackgroundColor (distinct from base BackgroundColor). /// public static readonly BindableProperty ButtonBackgroundColorProperty = BindableProperty.Create( nameof(ButtonBackgroundColor), typeof(SKColor), typeof(SkiaButton), new SKColor(0x21, 0x96, 0xF3), // Material Blue propertyChanged: (b, o, n) => ((SkiaButton)b).Invalidate()); /// /// Bindable property for PressedBackgroundColor. /// public static readonly BindableProperty PressedBackgroundColorProperty = BindableProperty.Create( nameof(PressedBackgroundColor), typeof(SKColor), typeof(SkiaButton), new SKColor(0x19, 0x76, 0xD2), propertyChanged: (b, o, n) => ((SkiaButton)b).Invalidate()); /// /// Bindable property for DisabledBackgroundColor. /// public static readonly BindableProperty DisabledBackgroundColorProperty = BindableProperty.Create( nameof(DisabledBackgroundColor), typeof(SKColor), typeof(SkiaButton), new SKColor(0xBD, 0xBD, 0xBD), propertyChanged: (b, o, n) => ((SkiaButton)b).Invalidate()); /// /// Bindable property for HoveredBackgroundColor. /// public static readonly BindableProperty HoveredBackgroundColorProperty = BindableProperty.Create( nameof(HoveredBackgroundColor), typeof(SKColor), typeof(SkiaButton), new SKColor(0x42, 0xA5, 0xF5), propertyChanged: (b, o, n) => ((SkiaButton)b).Invalidate()); /// /// Bindable property for BorderColor. /// public static readonly BindableProperty BorderColorProperty = BindableProperty.Create( nameof(BorderColor), typeof(SKColor), typeof(SkiaButton), SKColors.Transparent, propertyChanged: (b, o, n) => ((SkiaButton)b).Invalidate()); /// /// Bindable property for FontFamily. /// public static readonly BindableProperty FontFamilyProperty = BindableProperty.Create( nameof(FontFamily), typeof(string), typeof(SkiaButton), "Sans", propertyChanged: (b, o, n) => ((SkiaButton)b).OnFontChanged()); /// /// Bindable property for FontSize. /// public static readonly BindableProperty FontSizeProperty = BindableProperty.Create( nameof(FontSize), typeof(float), typeof(SkiaButton), 14f, propertyChanged: (b, o, n) => ((SkiaButton)b).OnFontChanged()); /// /// Bindable property for IsBold. /// public static readonly BindableProperty IsBoldProperty = BindableProperty.Create( nameof(IsBold), typeof(bool), typeof(SkiaButton), false, propertyChanged: (b, o, n) => ((SkiaButton)b).OnFontChanged()); /// /// Bindable property for IsItalic. /// public static readonly BindableProperty IsItalicProperty = BindableProperty.Create( nameof(IsItalic), typeof(bool), typeof(SkiaButton), false, propertyChanged: (b, o, n) => ((SkiaButton)b).OnFontChanged()); /// /// Bindable property for CharacterSpacing. /// public static readonly BindableProperty CharacterSpacingProperty = BindableProperty.Create( nameof(CharacterSpacing), typeof(float), typeof(SkiaButton), 0f, propertyChanged: (b, o, n) => ((SkiaButton)b).Invalidate()); /// /// Bindable property for CornerRadius. /// public static readonly BindableProperty CornerRadiusProperty = BindableProperty.Create( nameof(CornerRadius), typeof(float), typeof(SkiaButton), 4f, propertyChanged: (b, o, n) => ((SkiaButton)b).Invalidate()); /// /// Bindable property for BorderWidth. /// public static readonly BindableProperty BorderWidthProperty = BindableProperty.Create( nameof(BorderWidth), typeof(float), typeof(SkiaButton), 0f, propertyChanged: (b, o, n) => ((SkiaButton)b).Invalidate()); /// /// Bindable property for Padding. /// public static readonly BindableProperty PaddingProperty = BindableProperty.Create( nameof(Padding), typeof(SKRect), typeof(SkiaButton), new SKRect(16, 8, 16, 8), propertyChanged: (b, o, n) => ((SkiaButton)b).InvalidateMeasure()); /// /// Bindable property for Command. /// public static readonly BindableProperty CommandProperty = BindableProperty.Create( nameof(Command), typeof(System.Windows.Input.ICommand), typeof(SkiaButton), null, propertyChanged: (b, o, n) => ((SkiaButton)b).OnCommandChanged((System.Windows.Input.ICommand?)o, (System.Windows.Input.ICommand?)n)); /// /// Bindable property for CommandParameter. /// public static readonly BindableProperty CommandParameterProperty = BindableProperty.Create( nameof(CommandParameter), typeof(object), typeof(SkiaButton), null); #endregion #region Properties /// /// Gets or sets the button text. /// public string Text { get => (string)GetValue(TextProperty); set => SetValue(TextProperty, value); } /// /// Gets or sets the text color. /// public SKColor TextColor { get => (SKColor)GetValue(TextColorProperty); set => SetValue(TextColorProperty, value); } /// /// Gets or sets the button background color. /// public SKColor ButtonBackgroundColor { get => (SKColor)GetValue(ButtonBackgroundColorProperty); set => SetValue(ButtonBackgroundColorProperty, value); } /// /// Gets or sets the pressed background color. /// public SKColor PressedBackgroundColor { get => (SKColor)GetValue(PressedBackgroundColorProperty); set => SetValue(PressedBackgroundColorProperty, value); } /// /// Gets or sets the disabled background color. /// public SKColor DisabledBackgroundColor { get => (SKColor)GetValue(DisabledBackgroundColorProperty); set => SetValue(DisabledBackgroundColorProperty, value); } /// /// Gets or sets the hovered background color. /// public SKColor HoveredBackgroundColor { get => (SKColor)GetValue(HoveredBackgroundColorProperty); set => SetValue(HoveredBackgroundColorProperty, value); } /// /// Gets or sets the border color. /// public SKColor BorderColor { get => (SKColor)GetValue(BorderColorProperty); set => SetValue(BorderColorProperty, value); } /// /// Gets or sets the font family. /// public string FontFamily { get => (string)GetValue(FontFamilyProperty); set => SetValue(FontFamilyProperty, value); } /// /// Gets or sets the font size. /// public float FontSize { get => (float)GetValue(FontSizeProperty); set => SetValue(FontSizeProperty, value); } /// /// Gets or sets whether the text is bold. /// public bool IsBold { get => (bool)GetValue(IsBoldProperty); set => SetValue(IsBoldProperty, value); } /// /// Gets or sets whether the text is italic. /// public bool IsItalic { get => (bool)GetValue(IsItalicProperty); set => SetValue(IsItalicProperty, value); } /// /// Gets or sets the character spacing. /// public float CharacterSpacing { get => (float)GetValue(CharacterSpacingProperty); set => SetValue(CharacterSpacingProperty, value); } /// /// Gets or sets the corner radius. /// public float CornerRadius { get => (float)GetValue(CornerRadiusProperty); set => SetValue(CornerRadiusProperty, value); } /// /// Gets or sets the border width. /// public float BorderWidth { get => (float)GetValue(BorderWidthProperty); set => SetValue(BorderWidthProperty, value); } /// /// Gets or sets the padding. /// public SKRect Padding { get => (SKRect)GetValue(PaddingProperty); set => SetValue(PaddingProperty, value); } /// /// Gets or sets the command to execute when clicked. /// public System.Windows.Input.ICommand? Command { get => (System.Windows.Input.ICommand?)GetValue(CommandProperty); set => SetValue(CommandProperty, value); } /// /// Gets or sets the command parameter. /// public object? CommandParameter { get => GetValue(CommandParameterProperty); set => SetValue(CommandParameterProperty, value); } /// /// Gets whether the button is currently pressed. /// public bool IsPressed { get; private set; } /// /// Gets whether the pointer is currently over the button. /// public bool IsHovered { get; private set; } #endregion private bool _focusFromKeyboard; /// /// Event raised when the button is clicked. /// public event EventHandler? Clicked; /// /// Event raised when the button is pressed. /// public event EventHandler? Pressed; /// /// Event raised when the button is released. /// public event EventHandler? Released; public SkiaButton() { IsFocusable = true; } private void OnTextChanged() { InvalidateMeasure(); Invalidate(); } private void OnFontChanged() { InvalidateMeasure(); Invalidate(); } private void OnCommandChanged(System.Windows.Input.ICommand? oldCommand, System.Windows.Input.ICommand? newCommand) { if (oldCommand != null) { oldCommand.CanExecuteChanged -= OnCanExecuteChanged; } if (newCommand != null) { newCommand.CanExecuteChanged += OnCanExecuteChanged; UpdateIsEnabled(); } } private void OnCanExecuteChanged(object? sender, EventArgs e) { UpdateIsEnabled(); } private void UpdateIsEnabled() { if (Command != null) { IsEnabled = Command.CanExecute(CommandParameter); } } protected override void OnDraw(SKCanvas canvas, SKRect bounds) { // Check if this is a "text only" button (transparent background) var isTextOnly = ButtonBackgroundColor.Alpha == 0; // Determine background color based on state SKColor bgColor; if (!IsEnabled) { bgColor = isTextOnly ? SKColors.Transparent : DisabledBackgroundColor; } else if (IsPressed) { // For text-only buttons, use a subtle press effect bgColor = isTextOnly ? new SKColor(0, 0, 0, 20) : PressedBackgroundColor; } else if (IsHovered) { // For text-only buttons, use a subtle hover effect instead of full background bgColor = isTextOnly ? new SKColor(0, 0, 0, 10) : HoveredBackgroundColor; } else { bgColor = ButtonBackgroundColor; } // Draw shadow (for elevation effect) - skip for text-only buttons if (IsEnabled && !IsPressed && !isTextOnly) { DrawShadow(canvas, bounds); } // Create rounded rect for background and border var rect = new SKRoundRect(bounds, CornerRadius); // Draw background with rounded corners (skip if fully transparent) if (bgColor.Alpha > 0) { using var bgPaint = new SKPaint { Color = bgColor, IsAntialias = true, Style = SKPaintStyle.Fill }; canvas.DrawRoundRect(rect, bgPaint); } // Draw border if (BorderWidth > 0 && BorderColor != SKColors.Transparent) { using var borderPaint = new SKPaint { Color = BorderColor, IsAntialias = true, Style = SKPaintStyle.Stroke, StrokeWidth = BorderWidth }; canvas.DrawRoundRect(rect, borderPaint); } // Draw focus ring only for keyboard focus if (IsFocused && _focusFromKeyboard) { using var focusPaint = new SKPaint { Color = new SKColor(0x21, 0x96, 0xF3, 0x80), IsAntialias = true, Style = SKPaintStyle.Stroke, StrokeWidth = 2 }; var focusRect = new SKRoundRect(bounds, CornerRadius + 2); focusRect.Inflate(2, 2); canvas.DrawRoundRect(focusRect, focusPaint); } // Draw text if (!string.IsNullOrEmpty(Text)) { var fontStyle = new SKFontStyle( IsBold ? SKFontStyleWeight.Bold : SKFontStyleWeight.Normal, SKFontStyleWidth.Normal, IsItalic ? SKFontStyleSlant.Italic : SKFontStyleSlant.Upright); var typeface = SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(FontFamily, fontStyle) ?? SKTypeface.Default; using var font = new SKFont(typeface, FontSize); // For text-only buttons, darken text on hover/press for feedback SKColor textColorToUse; if (!IsEnabled) { textColorToUse = TextColor.WithAlpha(128); } else if (isTextOnly && (IsHovered || IsPressed)) { // Darken the text color slightly for hover/press feedback textColorToUse = new SKColor( (byte)Math.Max(0, TextColor.Red - 40), (byte)Math.Max(0, TextColor.Green - 40), (byte)Math.Max(0, TextColor.Blue - 40), TextColor.Alpha); } else { textColorToUse = TextColor; } using var paint = new SKPaint(font) { Color = textColorToUse, IsAntialias = true }; // Measure text var textBounds = new SKRect(); paint.MeasureText(Text, ref textBounds); // Center text var x = bounds.MidX - textBounds.MidX; var y = bounds.MidY - textBounds.MidY; canvas.DrawText(Text, x, y, paint); } } private void DrawShadow(SKCanvas canvas, SKRect bounds) { using var shadowPaint = new SKPaint { Color = new SKColor(0, 0, 0, 50), IsAntialias = true, MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, 4) }; var shadowRect = new SKRect( bounds.Left + 2, bounds.Top + 4, bounds.Right + 2, bounds.Bottom + 4); var roundRect = new SKRoundRect(shadowRect, CornerRadius); canvas.DrawRoundRect(roundRect, shadowPaint); } 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) { Console.WriteLine($"[SkiaButton] OnPointerPressed - Text='{Text}', IsEnabled={IsEnabled}"); if (!IsEnabled) return; IsPressed = true; _focusFromKeyboard = false; 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); // Fire click if button was pressed // Note: Hit testing already verified the pointer is over this button, // so we don't need to re-check bounds (which would fail due to coordinate system differences) if (wasPressed) { Clicked?.Invoke(this, EventArgs.Empty); Command?.Execute(CommandParameter); } } public override void OnKeyDown(KeyEventArgs e) { if (!IsEnabled) return; // Activate on Enter or Space if (e.Key == Key.Enter || e.Key == Key.Space) { IsPressed = true; _focusFromKeyboard = true; SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.Pressed); 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; SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.Normal); Invalidate(); Released?.Invoke(this, EventArgs.Empty); Clicked?.Invoke(this, EventArgs.Empty); Command?.Execute(CommandParameter); } e.Handled = true; } } protected override void OnEnabledChanged() { base.OnEnabledChanged(); SkiaVisualStateManager.GoToState(this, IsEnabled ? SkiaVisualStateManager.CommonStates.Normal : SkiaVisualStateManager.CommonStates.Disabled); } protected override SKSize MeasureOverride(SKSize availableSize) { // Ensure we never return NaN - use safe defaults var paddingLeft = float.IsNaN(Padding.Left) ? 16f : Padding.Left; var paddingRight = float.IsNaN(Padding.Right) ? 16f : Padding.Right; var paddingTop = float.IsNaN(Padding.Top) ? 8f : Padding.Top; var paddingBottom = float.IsNaN(Padding.Bottom) ? 8f : Padding.Bottom; var fontSize = float.IsNaN(FontSize) || FontSize <= 0 ? 14f : FontSize; if (string.IsNullOrEmpty(Text)) { return new SKSize( paddingLeft + paddingRight + 40, // Minimum width paddingTop + paddingBottom + fontSize); } var fontStyle = new SKFontStyle( IsBold ? SKFontStyleWeight.Bold : SKFontStyleWeight.Normal, SKFontStyleWidth.Normal, IsItalic ? SKFontStyleSlant.Italic : SKFontStyleSlant.Upright); var typeface = SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(FontFamily, fontStyle) ?? SKTypeface.Default; using var font = new SKFont(typeface, fontSize); using var paint = new SKPaint(font); var textBounds = new SKRect(); paint.MeasureText(Text, ref textBounds); var width = textBounds.Width + paddingLeft + paddingRight; var height = textBounds.Height + paddingTop + paddingBottom; // Ensure valid, non-NaN return values if (float.IsNaN(width) || width < 0) width = 72f; if (float.IsNaN(height) || height < 0) height = 30f; // Respect WidthRequest and HeightRequest when set if (WidthRequest >= 0) width = (float)WidthRequest; if (HeightRequest >= 0) height = (float)HeightRequest; return new SKSize(width, height); } }