Skip to content
116 changes: 116 additions & 0 deletions src/Controls/tests/TestCases.HostApp/Issues/Issue36749.cs
Original file line number Diff line number Diff line change
@@ -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
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
25 changes: 21 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,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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Loading