Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/Controls/tests/TestCases.HostApp/Issues/Issue35826.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ protected override void OnCreate(Android.OS.Bundle savedInstanceState)
? "Photo Picker: Available"
: "Photo Picker: Unavailable"
};
pickerAvailabilityLabel.SetPadding(0, 0, 0, 50);
pickerAvailabilityLabel.SetPadding(0, 150, 0, 50);
SetViewIdResourceName(pickerAvailabilityLabel, "PhotoPickerAvailabilityLabel");

var pickButton = new Android.Widget.Button(this)
Expand Down
72 changes: 25 additions & 47 deletions src/Controls/tests/TestCases.HostApp/Issues/Issue36749.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,6 @@ public class Issue36749 : ContentPage

readonly Label _resultLabel;
readonly Button _button;
#if IOS || MACCATALYST
bool _hasRunVerification;
#endif

public Issue36749()
{
Expand All @@ -38,7 +35,7 @@ public Issue36749()
Text = "Test Button",
AutomationId = "Issue36749Button"
};
_button.Loaded += OnButtonLoaded;
_button.Loaded += OnButtonLoaded;

Content = new VerticalStackLayout
{
Expand All @@ -48,45 +45,26 @@ public Issue36749()
};
}

void OnButtonLoaded(object sender, EventArgs e)
void OnButtonLoaded(object sender, EventArgs e)
{
#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";
if (_button.Handler?.PlatformView is UIButton platformButton)
{
// 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.
platformButton.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 isCyan = r < 0.01 && g > 0.99 && b > 0.99 && a > 0.99;
_resultLabel.Text = isCyan ? "PASS" : "FAIL";
}
else
{
// Handler or platform view unavailable — mark as FAIL so the test fails
// immediately rather than timing out on "Checking...".
_resultLabel.Text = "FAIL";
}
#endif
}
}
Expand All @@ -100,16 +78,16 @@ public class Issue36749Button : Button { }
// 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);
}
public Issue36749NativeStyledButton() : base(UIButtonType.System)
{
BackgroundColor = UIColor.Cyan;
SetTitleColor(UIColor.DarkGray, UIControlState.Normal);
}
}

class Issue36749ButtonHandler : ButtonHandler
{
protected override UIButton CreatePlatformView() => new Issue36749NativeStyledButton();
protected override UIButton CreatePlatformView() => new Issue36749NativeStyledButton();
}

#endif
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,15 @@ public Issue36749(TestDevice device) : base(device) { }

[Test]
[Category(UITestCategories.Button)]
public void NativeStyledButtonPreservesInitialAndClearsDetachedBackground()
public void NativeStyledButtonShouldPreserveBackgroundColorOnInitialRender()
{
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. " +
"when no cross-platform Background is assigned to the Button. " +
"Regression: PR #33346 unconditionally reset BackgroundColor to UIColor.Clear " +
"on the initial null-paint mapping, wiping native styling.");
}
Expand Down
27 changes: 10 additions & 17 deletions src/Core/src/Platform/iOS/ButtonExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
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 @@ -61,32 +58,28 @@ public static void UpdateTextColor(this UIButton platformButton, ITextStyle butt
internal static void UpdateBackground(this UIButton platformButton, Graphics.Paint? paint)
{
// 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.
// Safe to call unconditionally even when Window is null (initial-render path): at that
// point 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.
platformButton.RemoveBackgroundLayer();

if (paint.IsNullOrEmpty())
{
// 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)
// Only reset to UIColor.Clear when the button is already attached to a window.
// During initial property mapping (ConnectHandler), Window is null because the view
// hasn't been added to the hierarchy yet — skipping here preserves native BackgroundColor
// set by custom UIButton subclasses. Once live on screen, null means a VisualState
// transition back to Normal, so we do reset. Mirrors the same guard in UpdateTextColor.
if (platformButton.Window is not null)
{
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,85 +130,6 @@ 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