diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Issue19690_BackToNormalState.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Issue19690_BackToNormalState.png new file mode 100644 index 000000000000..b77041422d7a Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Issue19690_BackToNormalState.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Issue19690_CustomState.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Issue19690_CustomState.png new file mode 100644 index 000000000000..ed0d3f8819cf Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Issue19690_CustomState.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Issue19690_InitialNormalState.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Issue19690_InitialNormalState.png new file mode 100644 index 000000000000..7316f79dbb7e Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Issue19690_InitialNormalState.png differ diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue19690.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue19690.cs new file mode 100644 index 000000000000..f3321c210106 --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue19690.cs @@ -0,0 +1,114 @@ +using Microsoft.Maui.Controls; +using Microsoft.Maui.Graphics; + +namespace Maui.Controls.Sample.Issues; + +[Issue(IssueTracker.Github, 19690, "Button VisualStates do not work", PlatformAffected.iOS | PlatformAffected.Android | PlatformAffected.macOS)] +public class Issue19690 : ContentPage +{ + const string CustomStateName = "Custom"; + + public Issue19690() + { + var button = new Issue19690CustomButton + { + Text = "Click Me", + AutomationId = "TestButton" + }; + + var statusLabel = new Label + { + Text = "Initial State", + AutomationId = "StatusLabel", + HorizontalOptions = LayoutOptions.Center, + Margin = new Thickness(0, 20, 0, 0) + }; + + button.Clicked += (s, e) => + { + statusLabel.Text = button.IsCustom ? "Custom State" : "Normal State"; + }; + + Content = new VerticalStackLayout + { + Padding = 20, + Children = + { + new Label + { + Text = "Click the button multiple times. The button should toggle between Normal (default colors) and Custom (Purple background, Yellow text) states.", + AutomationId = "InstructionLabel" + }, + button, + statusLabel + } + }; + } + + class Issue19690CustomButton : Button + { + public static readonly BindableProperty IsCustomProperty = + BindableProperty.Create(nameof(IsCustom), typeof(bool), typeof(Issue19690CustomButton), false, propertyChanged: OnIsCustomChanged); + + public bool IsCustom + { + get => (bool)GetValue(IsCustomProperty); + set => SetValue(IsCustomProperty, value); + } + + public Issue19690CustomButton() + { + var visualStateGroups = new VisualStateGroupList(); + var commonStates = new VisualStateGroup { Name = "CommonStates" }; + + commonStates.States.Add(new VisualState { Name = "Normal" }); + commonStates.States.Add(new VisualState { Name = "Disabled" }); + commonStates.States.Add(new VisualState { Name = "PointerOver" }); + commonStates.States.Add(new VisualState { Name = "Pressed" }); + commonStates.States.Add(new VisualState { Name = "Focused" }); + + var customState = new VisualState { Name = CustomStateName }; + customState.Setters.Add(new Setter + { + Property = TextColorProperty, + Value = Colors.Blue + }); + customState.Setters.Add(new Setter + { + Property = BackgroundColorProperty, + Value = Colors.Purple + }); + commonStates.States.Add(customState); + + visualStateGroups.Add(commonStates); + VisualStateManager.SetVisualStateGroups(this, visualStateGroups); + + Clicked += OnButtonClicked; + } + + void OnButtonClicked(object sender, EventArgs e) + { + IsCustom = !IsCustom; + } + + protected internal override void ChangeVisualState() + { + if (IsCustom && IsEnabled) + { + VisualStateManager.GoToState(this, CustomStateName); + } + else + { + base.ChangeVisualState(); + } + } + + static void OnIsCustomChanged(BindableObject bindable, object oldValue, object newValue) + { + if (bindable is Issue19690CustomButton button) + { + button.ChangeVisualState(); + } + } + } +} diff --git a/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/Issue19690_BackToNormalState.png b/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/Issue19690_BackToNormalState.png new file mode 100644 index 000000000000..5df4cef9581c Binary files /dev/null and b/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/Issue19690_BackToNormalState.png differ diff --git a/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/Issue19690_CustomState.png b/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/Issue19690_CustomState.png new file mode 100644 index 000000000000..f8cbe26be0fe Binary files /dev/null and b/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/Issue19690_CustomState.png differ diff --git a/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/Issue19690_InitialNormalState.png b/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/Issue19690_InitialNormalState.png new file mode 100644 index 000000000000..43aa1cf6307a Binary files /dev/null and b/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/Issue19690_InitialNormalState.png differ diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue19690.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue19690.cs new file mode 100644 index 000000000000..0c25f17f6e70 --- /dev/null +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue19690.cs @@ -0,0 +1,33 @@ +using NUnit.Framework; +using UITest.Appium; +using UITest.Core; + +namespace Microsoft.Maui.TestCases.Tests.Issues; + +public class Issue19690 : _IssuesUITest +{ + public Issue19690(TestDevice device) : base(device) { } + + public override string Issue => "Button VisualStates do not work"; + + [Test] + [Category(UITestCategories.Button)] + public void ButtonVisualStatesShouldToggleBetweenNormalAndCustom() + { + App.WaitForElement("TestButton"); + + Exception? exception = null; + VerifyScreenshotOrSetException(ref exception, "Issue19690_InitialNormalState"); + + App.Tap("TestButton"); + VerifyScreenshotOrSetException(ref exception, "Issue19690_CustomState"); + + App.Tap("TestButton"); + VerifyScreenshotOrSetException(ref exception, "Issue19690_BackToNormalState"); + + if (exception != null) + { + throw exception; + } + } +} diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/Issue19690_BackToNormalState.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/Issue19690_BackToNormalState.png new file mode 100644 index 000000000000..a73f973df248 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/Issue19690_BackToNormalState.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/Issue19690_CustomState.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/Issue19690_CustomState.png new file mode 100644 index 000000000000..17fa2fe847a8 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/Issue19690_CustomState.png differ diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/Issue19690_InitialNormalState.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/Issue19690_InitialNormalState.png new file mode 100644 index 000000000000..5e847c5d1410 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/Issue19690_InitialNormalState.png differ diff --git a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/Issue19690_BackToNormalState.png b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/Issue19690_BackToNormalState.png new file mode 100644 index 000000000000..295268e1f654 Binary files /dev/null and b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/Issue19690_BackToNormalState.png differ diff --git a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/Issue19690_CustomState.png b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/Issue19690_CustomState.png new file mode 100644 index 000000000000..7ec491ff33e5 Binary files /dev/null and b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/Issue19690_CustomState.png differ diff --git a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/Issue19690_InitialNormalState.png b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/Issue19690_InitialNormalState.png new file mode 100644 index 000000000000..93f4e856b185 Binary files /dev/null and b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/Issue19690_InitialNormalState.png differ diff --git a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/Issue19690_BackToNormalState.png b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/Issue19690_BackToNormalState.png new file mode 100644 index 000000000000..1bda06364608 Binary files /dev/null and b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/Issue19690_BackToNormalState.png differ diff --git a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/Issue19690_CustomState.png b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/Issue19690_CustomState.png new file mode 100644 index 000000000000..0c3369f564d2 Binary files /dev/null and b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/Issue19690_CustomState.png differ diff --git a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/Issue19690_InitialNormalState.png b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/Issue19690_InitialNormalState.png new file mode 100644 index 000000000000..c75c17b403e6 Binary files /dev/null and b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/Issue19690_InitialNormalState.png differ diff --git a/src/Core/src/Handlers/Button/ButtonHandler.Android.cs b/src/Core/src/Handlers/Button/ButtonHandler.Android.cs index bd69e420f11f..a252317626af 100644 --- a/src/Core/src/Handlers/Button/ButtonHandler.Android.cs +++ b/src/Core/src/Handlers/Button/ButtonHandler.Android.cs @@ -23,6 +23,10 @@ public partial class ButtonHandler : ViewHandler ButtonClickListener ClickListener { get; } = new ButtonClickListener(); ButtonTouchListener TouchListener { get; } = new ButtonTouchListener(); + // Cached default Material theme text colors, captured before any MAUI property mapping. + // Restored when TextColor is set to null (e.g. when a VisualState setter is unapplied). + ColorStateList? _defaultTextColors; + protected override MaterialButton CreatePlatformView() { MaterialButton platformButton = new MauiMaterialButton(Context) @@ -47,6 +51,9 @@ protected override void ConnectHandler(MaterialButton platformView) platformView.FocusChange += OnNativeViewFocusChange; platformView.LayoutChange += OnPlatformViewLayoutChange; + // Capture Material theme defaults before MAUI property mapping is applied + _defaultTextColors = platformView.TextColors; + base.ConnectHandler(platformView); } @@ -61,6 +68,8 @@ protected override void DisconnectHandler(MaterialButton platformView) platformView.FocusChange -= OnNativeViewFocusChange; platformView.LayoutChange -= OnPlatformViewLayoutChange; + _defaultTextColors = null; + ImageSourceLoader.Reset(); base.DisconnectHandler(platformView); @@ -94,7 +103,16 @@ public static void MapText(IButtonHandler handler, IText button) public static void MapTextColor(IButtonHandler handler, ITextStyle button) { - handler.PlatformView?.UpdateTextColor(button); + if (button.TextColor is null) + { + // Restore the Material theme default colors captured before any MAUI mapping + if (handler is ButtonHandler buttonHandler && buttonHandler._defaultTextColors is not null) + handler.PlatformView?.SetTextColor(buttonHandler._defaultTextColors); + } + else + { + handler.PlatformView?.UpdateTextColor(button); + } } public static void MapCharacterSpacing(IButtonHandler handler, ITextStyle button) diff --git a/src/Core/src/Handlers/Button/ButtonHandler.iOS.cs b/src/Core/src/Handlers/Button/ButtonHandler.iOS.cs index 67857555be26..678e953c4acc 100644 --- a/src/Core/src/Handlers/Button/ButtonHandler.iOS.cs +++ b/src/Core/src/Handlers/Button/ButtonHandler.iOS.cs @@ -73,9 +73,15 @@ public static void MapBackground(IButtonHandler handler, IButton button) } else { - handler.PlatformView?.UpdateBackground(button); + handler.PlatformView?.UpdateBackground(button.Background); } } +#else + // TODO: Make this public in .NET 11 + internal static void MapBackground(IButtonHandler handler, IButton button) + { + handler.PlatformView?.UpdateBackground(button.Background); + } #endif public static void MapStrokeColor(IButtonHandler handler, IButtonStroke buttonStroke) diff --git a/src/Core/src/Platform/iOS/ButtonExtensions.cs b/src/Core/src/Platform/iOS/ButtonExtensions.cs index 91ba7fd4eaef..ac5a00c452c0 100644 --- a/src/Core/src/Platform/iOS/ButtonExtensions.cs +++ b/src/Core/src/Platform/iOS/ButtonExtensions.cs @@ -1,4 +1,5 @@ using System; +using Microsoft.Maui.Graphics; using UIKit; namespace Microsoft.Maui.Platform @@ -31,7 +32,19 @@ public static void UpdateText(this UIButton platformButton, IText button) => public static void UpdateTextColor(this UIButton platformButton, ITextStyle button) { if (button.TextColor is null) + { + // Only clear explicit overrides when attached to a window. + // Skipping during initial render prevents clearing Appearance-proxy colors. + if (platformButton.Window is UIWindow window) + { + platformButton.SetTitleColor(null, UIControlState.Normal); + platformButton.SetTitleColor(null, UIControlState.Highlighted); + platformButton.SetTitleColor(null, UIControlState.Disabled); + platformButton.TintColor = window.TintColor; + } + return; + } var color = button.TextColor.ToPlatform(); @@ -42,6 +55,24 @@ public static void UpdateTextColor(this UIButton platformButton, ITextStyle butt platformButton.TintColor = color; } + // TODO: Make this public in .NET 11 + internal static void UpdateBackground(this UIButton platformButton, Graphics.Paint? paint) + { + // Remove previous background gradient layer if any + platformButton.RemoveBackgroundLayer(); + + if (paint.IsNullOrEmpty()) + { + // Reset to clear background for buttons when paint is null. + // UIColor.Clear ensures proper transparency when VisualState setters are unapplied. + platformButton.BackgroundColor = UIColor.Clear; + return; + } + + // Delegate to the standard view background update + ViewExtensions.UpdateBackground(platformButton, paint); + } + public static void UpdateCharacterSpacing(this UIButton platformButton, ITextStyle textStyle) { var attributedText = platformButton?.TitleLabel.AttributedText?.WithCharacterSpacing(textStyle.CharacterSpacing);