Skip to content
108 changes: 108 additions & 0 deletions src/Controls/tests/TestCases.HostApp/Issues/Issue36749.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
#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;

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 (_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: the Window-null check skips the reset, preserving Cyan.
Comment thread
kubaflo marked this conversation as resolved.
Outdated
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. Window-based initial-map detection incorrectly leaves this color red.
_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
1 change: 1 addition & 0 deletions src/Controls/tests/TestCases.HostApp/MauiProgram.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Original file line number Diff line number Diff line change
@@ -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
27 changes: 23 additions & 4 deletions src/Core/src/Platform/iOS/ButtonExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
using System;
using System.Runtime.CompilerServices;
using Microsoft.Maui.Graphics;
using UIKit;

namespace Microsoft.Maui.Platform
{
public static class ButtonExtensions
{
static readonly ConditionalWeakTable<UIButton, BackgroundUpdateState> BackgroundUpdateStates = new();

public const double AlmostZero = 0.00001;

public static void UpdateStrokeColor(this UIButton platformButton, IButtonStroke buttonStroke)
Expand Down Expand Up @@ -57,21 +60,37 @@ 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
var updateState = BackgroundUpdateStates.GetValue(platformButton, static _ => new());
var isInitialUpdate = updateState.IsInitialUpdate;
updateState.IsInitialUpdate = false;

// Remove previous background gradient layer if any.
// Safe to call unconditionally during the initial mapping because MAUI has not yet
// inserted a named gradient layer, so this is a no-op.
// Running it before the Window/paint guard ensures any previously-applied gradient is
// cleaned up regardless of the new paint value.
Comment thread
kubaflo marked this conversation as resolved.
Outdated
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 only during the first property
// mapping. Later null updates must clear prior MAUI state even while detached.
if (!isInitialUpdate)
{
platformButton.BackgroundColor = UIColor.Clear;
}
return;
}

// Delegate to the standard view background update
ViewExtensions.UpdateBackground(platformButton, paint);
}

sealed class BackgroundUpdateState
{
public bool IsInitialUpdate { get; set; } = true;
}

public static void UpdateCharacterSpacing(this UIButton platformButton, ITextStyle textStyle)
{
var attributedText = platformButton?.TitleLabel.AttributedText?.WithCharacterSpacing(textStyle.CharacterSpacing);
Expand Down
Loading