diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue36749.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue36749.cs new file mode 100644 index 000000000000..1f138ddfc10b --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue36749.cs @@ -0,0 +1,116 @@ +#if IOS || MACCATALYST +using Microsoft.Maui.Handlers; +using UIKit; +#endif + +namespace Maui.Controls.Sample.Issues; + +// Regression: ButtonHandler.iOS.cs (introduced in 10.0.80 via PR #33346) unconditionally reset +// BackgroundColor to UIColor.Clear when the cross-platform Background was null. This wiped native +// colors set by a custom UIButton subclass returned from CreatePlatformView(). +[Issue(IssueTracker.Github, 36749, + "ButtonHandler clears native platform-view styling set by custom handlers when Background/TextColor are null", + PlatformAffected.iOS | PlatformAffected.macOS)] +public class Issue36749 : ContentPage +{ + public const string ResultLabelId = "Issue36749Result"; + + readonly Label _resultLabel; + readonly Button _button; +#if IOS || MACCATALYST + bool _hasRunVerification; +#endif + + public Issue36749() + { + _resultLabel = new Label + { + AutomationId = ResultLabelId, + Text = "Checking...", + HorizontalTextAlignment = TextAlignment.Center + }; + + // Issue36749Button routes to Issue36749ButtonHandler (registered in MauiProgram.cs). + // The handler's CreatePlatformView() returns a UIButton subclass that self-styles with + // BackgroundColor = UIColor.Cyan. No cross-platform Background is set on this button. + _button = new Issue36749Button + { + Text = "Test Button", + AutomationId = "Issue36749Button" + }; + + Content = new VerticalStackLayout + { + Padding = 20, + Spacing = 20, + Children = { _button, _resultLabel } + }; + } + + protected override void OnAppearing() + { + base.OnAppearing(); + +#if IOS || MACCATALYST + if (_hasRunVerification) + return; + + _hasRunVerification = true; + + if (_button.Handler?.PlatformView is not UIButton platformButton || + platformButton.BackgroundColor is not UIColor backgroundColor) + { + // Handler, platform view, or background unavailable — mark as FAIL so the test fails + // immediately rather than timing out on "Checking...". + _resultLabel.Text = "FAIL"; + return; + } + + // The native UIButton subclass sets BackgroundColor = UIColor.Cyan in its constructor. + // With the regression: initial null-Background mapping resets it to UIColor.Clear. + // With the fix: no clear occurs until MAUI has applied a background, preserving Cyan. + backgroundColor.GetRGBA(out var r, out var g, out var b, out var a); + + // UIColor.Cyan = R:0, G:1, B:1, A:1 + bool preservedInitialColor = r < 0.01 && g > 0.99 && b > 0.99 && a > 0.99; + + // A later null mapping must clear MAUI-applied state even while the native view is + // detached. Tracking prior MAUI background application keeps this clear deterministic. + _button.Background = new SolidColorBrush(Colors.Red); + platformButton.RemoveFromSuperview(); + _button.Background = null; + + bool clearedDetachedColor = false; + if (platformButton.BackgroundColor is UIColor detachedColor) + { + detachedColor.GetRGBA(out _, out _, out _, out var detachedAlpha); + clearedDetachedColor = detachedAlpha < 0.01; + } + + _resultLabel.Text = preservedInitialColor && clearedDetachedColor ? "PASS" : "FAIL"; +#endif + } +} + +// Custom Button subtype used purely to route to Issue36749ButtonHandler. +public class Issue36749Button : Button { } + +#if IOS || MACCATALYST + +// Simulates a design-system UIButton subclass that self-styles in its constructor. +// The cross-platform Button intentionally leaves Background and TextColor unset. +class Issue36749NativeStyledButton : UIButton +{ + public Issue36749NativeStyledButton() : base(UIButtonType.System) + { + BackgroundColor = UIColor.Cyan; + SetTitleColor(UIColor.DarkGray, UIControlState.Normal); + } +} + +class Issue36749ButtonHandler : ButtonHandler +{ + protected override UIButton CreatePlatformView() => new Issue36749NativeStyledButton(); +} + +#endif diff --git a/src/Controls/tests/TestCases.HostApp/MauiProgram.cs b/src/Controls/tests/TestCases.HostApp/MauiProgram.cs index df6a43e25b79..e94c322049e8 100644 --- a/src/Controls/tests/TestCases.HostApp/MauiProgram.cs +++ b/src/Controls/tests/TestCases.HostApp/MauiProgram.cs @@ -57,6 +57,7 @@ public static MauiApp CreateMauiApp() #endif #if IOS || MACCATALYST handlers.AddHandler(typeof(Issue11132Control), typeof(Issue11132ControlHandler)); + handlers.AddHandler(typeof(Issue36749Button), typeof(Issue36749ButtonHandler)); #endif #if IOS || MACCATALYST || ANDROID handlers.AddHandler(typeof(UITestEditor), typeof(UITestEditorHandler)); diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue36749.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue36749.cs new file mode 100644 index 000000000000..00eb7d1c2089 --- /dev/null +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue36749.cs @@ -0,0 +1,30 @@ +#if IOS || MACCATALYST //This is an iOS-specific issue and can be reproduced by extending UIButton. Therefore, the test was added only for iOS and Mac Catalyst. +using NUnit.Framework; +using UITest.Appium; +using UITest.Core; + +namespace Microsoft.Maui.TestCases.Tests.Issues; + +public class Issue36749 : _IssuesUITest +{ + public Issue36749(TestDevice device) : base(device) { } + + public override string Issue => "ButtonHandler clears native platform-view styling set by custom handlers when Background/TextColor are null"; + + [Test] + [Category(UITestCategories.Button)] + public void NativeStyledButtonPreservesInitialAndClearsDetachedBackground() + { + App.WaitForElement("Issue36749Result"); + + var resultText = App.FindElement("Issue36749Result").GetText(); + + Assert.That(resultText, Is.EqualTo("PASS"), + "Native BackgroundColor set in a UIButton subclass constructor should be preserved " + + "during the initial mapping, while later null Background updates must clear " + + "MAUI-applied state even when the native button is detached. " + + "Regression: PR #33346 unconditionally reset BackgroundColor to UIColor.Clear " + + "on the initial null-paint mapping, wiping native styling."); + } +} +#endif \ No newline at end of file diff --git a/src/Core/src/Platform/iOS/ButtonExtensions.cs b/src/Core/src/Platform/iOS/ButtonExtensions.cs index 2c9d636d9f47..6d123f18ac48 100644 --- a/src/Core/src/Platform/iOS/ButtonExtensions.cs +++ b/src/Core/src/Platform/iOS/ButtonExtensions.cs @@ -1,4 +1,5 @@ using System; +using System.Runtime.CompilerServices; using Microsoft.Maui.Graphics; using UIKit; @@ -6,6 +7,8 @@ namespace Microsoft.Maui.Platform { public static class ButtonExtensions { + static readonly ConditionalWeakTable BackgroundUpdateStates = new(); + public const double AlmostZero = 0.00001; public static void UpdateStrokeColor(this UIButton platformButton, IButtonStroke buttonStroke) @@ -57,19 +60,33 @@ public static void UpdateTextColor(this UIButton platformButton, ITextStyle butt // TODO: Make this public in .NET 11 internal static void UpdateBackground(this UIButton platformButton, Graphics.Paint? paint) { - // Remove previous background gradient layer if any + // Remove previous background gradient layer if any. + // Safe to call when MAUI has not applied a gradient because this is a no-op. + // Running it before the paint guard ensures any previously-applied gradient is cleaned + // up regardless of the new paint value. 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; + // Preserve constructor or appearance-proxy styling until MAUI applies a background. + // This also covers handlers reconnected to another virtual view with no background. + if (BackgroundUpdateStates.TryGetValue(platformButton, out var updateState) && updateState.HasMauiBackground) + { + platformButton.BackgroundColor = UIColor.Clear; + BackgroundUpdateStates.Remove(platformButton); + } return; } // Delegate to the standard view background update ViewExtensions.UpdateBackground(platformButton, paint); + if (paint is SolidPaint or LinearGradientPaint or RadialGradientPaint) + BackgroundUpdateStates.GetValue(platformButton, static _ => new()).HasMauiBackground = true; + } + + sealed class BackgroundUpdateState + { + public bool HasMauiBackground { get; set; } } public static void UpdateCharacterSpacing(this UIButton platformButton, ITextStyle textStyle) diff --git a/src/Core/tests/DeviceTests/Handlers/Button/ButtonHandlerTests.iOS.cs b/src/Core/tests/DeviceTests/Handlers/Button/ButtonHandlerTests.iOS.cs index 635c482f9090..d6f9df3b685f 100644 --- a/src/Core/tests/DeviceTests/Handlers/Button/ButtonHandlerTests.iOS.cs +++ b/src/Core/tests/DeviceTests/Handlers/Button/ButtonHandlerTests.iOS.cs @@ -130,6 +130,85 @@ public async Task ValidateDefaultAccessibilityTraits() Assert.Equal(UIAccessibilityTrait.Button, trait); } + [Fact] + public async Task NativeBackgroundIsPreservedWhenHandlerIsReused() + { + await InvokeOnMainThreadAsync(() => + { + var handler = new NativeStyledButtonHandler(); + handler.SetMauiContext(MauiContext); + handler.SetVirtualView(new ButtonStub()); + + var platformButton = handler.PlatformView; + Assert.Equal(Colors.Cyan, platformButton.BackgroundColor.ToColor()); + + handler.SetVirtualView(new ButtonStub()); + + Assert.Same(platformButton, handler.PlatformView); + Assert.Equal(Colors.Cyan, platformButton.BackgroundColor.ToColor()); + }); + } + + [Fact] + public async Task UnsupportedPaintDoesNotClearNativeBackground() + { + await InvokeOnMainThreadAsync(() => + { + var platformButton = new UIButton(UIButtonType.System) + { + BackgroundColor = UIColor.Cyan + }; + + platformButton.UpdateBackground(new PatternPaint { Pattern = new NoOpPattern() }); + platformButton.UpdateBackground(null); + + Assert.Equal(Colors.Cyan, platformButton.BackgroundColor.ToColor()); + }); + } + + [Fact] + public async Task UnsupportedGradientDoesNotClearNativeBackground() + { + await InvokeOnMainThreadAsync(() => + { + var platformButton = new UIButton(UIButtonType.System) + { + BackgroundColor = UIColor.Cyan + }; + + platformButton.UpdateBackground(new UnsupportedGradientPaint()); + platformButton.UpdateBackground(null); + + Assert.Equal(Colors.Cyan, platformButton.BackgroundColor.ToColor()); + }); + } + + sealed class NativeStyledButtonHandler : ButtonHandler + { + protected override UIButton CreatePlatformView() + { + var button = new UIButton(UIButtonType.System) + { + BackgroundColor = UIColor.Cyan + }; + + return button; + } + } + + sealed class NoOpPattern : IPattern + { + public float Width => 1; + public float Height => 1; + public float StepX => 1; + public float StepY => 1; + public void Draw(ICanvas canvas) { } + } + + sealed class UnsupportedGradientPaint : GradientPaint + { + } + bool ImageSourceLoaded(ButtonHandler buttonHandler) => buttonHandler.PlatformView.ImageView.Image != null;