[iOS] Fix ButtonHandler clearing custom UIButton styling when Background null#36769
[iOS] Fix ButtonHandler clearing custom UIButton styling when Background null#36769devanathan-vaithiyanathan wants to merge 4 commits into
Conversation
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 36769Or
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 36769" |
|
Azure Pipelines: Successfully started running 1 pipeline(s). There may be pipelines that require an authorized user to comment /azp run to run. |
|
Azure Pipelines: Successfully started running 1 pipeline(s). There may be pipelines that require an authorized user to comment /azp run to run. |
|
/azp run maui-pr-uitests , maui-pr-devicetests |
|
Azure Pipelines: Successfully started running 2 pipeline(s). |
There was a problem hiding this comment.
Pull request overview
Fixes an iOS regression in ButtonHandler where mapping a null Background during initial handler connection clears constructor-applied UIButton styling (e.g., in custom ButtonHandler subclasses). The change aligns background-reset behavior with the existing UpdateTextColor “only when attached to a window” guard.
Changes:
- Add a
Window != nullguard toButtonExtensions.UpdateBackground(UIButton, Paint?)before resettingBackgroundColortoUIColor.Clearwhen paint is null/empty. - Add a HostApp issue page + custom handler to reproduce/verify the behavior on iOS/MacCatalyst.
- Add a corresponding UI test for issue #36749.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| src/Core/src/Platform/iOS/ButtonExtensions.cs | Adds a window-attachment guard to avoid wiping native background styling during initial mapping when paint is null. |
| src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue36749.cs | Adds a UI test asserting native-styled UIButton background is preserved on initial render. |
| src/Controls/tests/TestCases.HostApp/MauiProgram.cs | Registers the custom handler used by the HostApp repro page on iOS/MacCatalyst. |
| src/Controls/tests/TestCases.HostApp/Issues/Issue36749.cs | Adds the repro page + native-styled UIButton subclass + handler used to validate the regression/fix. |
| #if IOS || MACCATALYST | ||
| 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"; | ||
| } | ||
| #endif |
| [Issue(IssueTracker.Github, 36749, | ||
| "ButtonHandler clears native platform-view styling set by custom handlers when Background/TextColor are null", | ||
| PlatformAffected.iOS)] |
| 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 " + | ||
| "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."); |
MauiBot
left a comment
There was a problem hiding this comment.
AI Review Summary
@devanathan-vaithiyanathan — new AI review results are available based on this last commit:
b818f35.
🗂️ Review Sessions — click to expand
🚦 Gate — Test Before & After Fix
Gate Result: ⚠️ INCONCLUSIVE
Platform: ANDROID · Base: main · Merge base: 04f72196
🌐 Fix not relevant to the ANDROID gate — every changed code file is platform-specific for a different platform (an iOS/MacCatalyst/Android/Windows-only change). On ANDROID the change is a no-op, so the repro test behaves identically with and without the fix and the gate cannot verify it here. This is inconclusive, not a fix failure — verify this PR on its own platform.
⏭️ Gate skipped up front — because the fix cannot affect this platform's binary, the gate skipped the build + device-test cycle instead of running it to a guaranteed INCONCLUSIVE result, saving the full test-time budget.
| Test | Status |
|---|---|
Issue36749 (UITest) |
⏭️ SKIPPED (not run on this platform) |
Changed fix file(s) — all platform-specific for another platform:
src/Core/src/Platform/iOS/ButtonExtensions.cs
📱 UI Tests — Button,ViewBaseTests
Detected UI test categories: Button,ViewBaseTests
✅ Deep UI tests — 189 passed, 0 failed across 2 categories on platform-pool agent (replaces in-process counts above).
🧪 UI Test Execution Results (deep, platform pool)
| Category | Tests | Snapshot diffs |
|---|---|---|
Button |
71/73 ✓ | — |
ViewBaseTests |
118/119 ✓ | — |
📎 Download drop-deep-uitests artifact (TRX + snapshot diffs) |
📋 Pre-Flight — Context & Validation
Issue: #36749 - [iOS] ButtonHandler now clears platform-view styling set by custom handlers when Button.Background/TextColor are null (regression in 10.0.80)
PR: #36769 - [iOS] Fix ButtonHandler clearing custom UIButton styling when Background null
Platforms Affected: iOS, MacCatalyst; requested try-fix test platform: Android
Files Changed: 1 implementation, 3 test/sample
Key Findings
- PR fixes a regression from #33346 where iOS
ButtonExtensions.UpdateBackground(UIButton, Paint?)cleared native constructor-setUIButton.BackgroundColorduring initial null-background mapping. - The PR's production fix gates the null-background reset on
platformButton.Window is not null, matching the existingUpdateTextColorinitial-render guard and preserving visible-state reset behavior once attached. - Added UI test coverage targets the custom
ButtonHandler.CreatePlatformView()scenario, but the code-review pass found the test can read the label beforeOnAppearingupdates it fromChecking.... - Prior gate was inconclusive and must not be treated as a failing fix per the caller-provided gate result; the code-review finding is recorded as candidate guidance, not as a gate rerun.
Code Review Summary
Verdict: NEEDS_CHANGES
Confidence: low
Errors: 1 | Warnings: 1 | Suggestions: 1
Key code review findings:
- ✗
src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue36749.cs:18waits only for the result label to exist; CI evidence from the review found actual textChecking..., so candidate fixes should avoid asynchronous/ambiguous UI-test state. - ⚠
src/Controls/tests/TestCases.HostApp/Issues/Issue36749.cs:13compiles for iOS and MacCatalyst but the issue attribute lists only iOS. - ℹ Production fix appears behaviorally sound: initial
Window == nullpreserves native styling; attachedWindow != nullstill clears null background for VisualState restore.
Fix Candidates
| # | Source | Approach | Test Result | Files Changed | Notes |
|---|---|---|---|---|---|
| PR | PR #36769 | Skip UIColor.Clear reset for null/empty paint until UIButton.Window is non-null |
src/Core/src/Platform/iOS/ButtonExtensions.cs, UI test files |
Original PR; not re-running gate per caller instruction |
🔬 Code Review — Deep Analysis
Code Review — PR #36769
Independent Assessment
What this changes: iOS UIButton background mapping now skips clearing BackgroundColor during initial handler setup when Window is null, preserving native styling from custom UIButton subclasses. Adds an issue repro page and UI test.
Inferred motivation: Fix a regression where default/null MAUI Button.Background wiped custom native button styling introduced in CreatePlatformView().
Reconciliation with PR Narrative
Author claims: PR fixes #36749 by adding a Window guard matching UpdateTextColor.
Agreement/disagreement: The core production fix matches the claimed root cause and appears sound. However, the added MacCatalyst UI test is currently failing in CI.
Prior Review Reconciliation
| Prior ❌ Error Finding | Source | Status | Evidence |
|---|---|---|---|
| No prior ❌ Error findings found. | — | — | Queried review bodies, inline review comments, and PR issue comments. Prior Copilot comments flagged the same test-settling risk but not as ❌ Error. |
Blast Radius Assessment
- Runs for all instances: Yes, for iOS/MacCatalyst buttons using this mapping.
- Startup impact: Yes, during initial handler property mapping.
- Static/shared state: No.
CI Status
- Required-check result: fail
- Classification: PR-caused failure ❌
- Action taken: Invoked
azdo-build-investigator; public AzDO logs show the new testNativeStyledButtonShouldPreserveBackgroundColorOnInitialRenderfailing inmaui-pr-uitestsmacOS Button leg with actual text"Checking...".
Findings
❌ Error — New UI test is failing in CI due reading the result before it settles
src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue36749.cs:18
The test waits only for the result label to exist, but the label is initialized as "Checking..." and updated later by the page. CI confirms the new test fails twice on macOS with:
Expected: "PASS" But was: "Checking..."
Use an existing text wait/retry pattern, e.g. wait for "PASS" or a final "FAIL" state before asserting, and make the host page set an explicit failure if the handler/platform view is unavailable.
⚠️ Warning — MacCatalyst issue metadata is incomplete
src/Controls/tests/TestCases.HostApp/Issues/Issue36749.cs:13
The page and test compile for IOS || MACCATALYST, but the issue attribute lists only PlatformAffected.iOS. If this is intended to run on MacCatalyst, include the Mac/macOS affected flag for accurate test metadata.
💡 Suggestion — Add coverage for the live VisualState/null-background restore path
The production change preserves native initial styling, but the original #33346 behavior was meant to clear the background when a visible button transitions back to a null background. A follow-up assertion covering “set background while visible, then clear it” would protect that regression.
Failure-Mode Probing
- Initial
Window == null: native constructor-setBackgroundColoris preserved; named MAUI background layers are still removed. - Visible button clearing
Background:Window != null, soBackgroundColorstill resets toUIColor.Clear. - Test page handler unavailable during
OnAppearing: current page leaves"Checking...", causing ambiguous failures/timeouts; should set explicitFAIL.
Verdict: NEEDS_CHANGES
Confidence: low
Summary: The production fix looks correct, but CI shows a PR-caused failure in the newly added UI test. The test needs to wait for a final result and/or explicitly handle missing platform-view state before this can merge.
🛠️ Fix — Analysis & Comparison
Fix Candidates
| # | Source | Approach | Test Result | Files Changed | Notes |
|---|---|---|---|---|---|
| 1 | maui-expert-reviewer | Handler-state initial-map suppression: skip null background in ButtonHandler.MapBackground while handler.IsConnectingHandler() |
2 files | Different from PR's Window guard; preserves live updates after connection, but depends on mapper lifecycle semantics. Android build probe blocked by missing restore assets. |
|
| 2 | maui-expert-reviewer | Track MAUI-owned background state with ConditionalWeakTable<UIButton, ButtonBackgroundState> and restore native baseline on null |
1 file | More semantically precise than lifecycle checks, but adds state and must handle native mutations while MAUI owns background. Android build probe blocked by missing restore assets. | |
| 3 | maui-expert-reviewer | For custom UIButton subclasses, render explicit MAUI backgrounds as named layers and never mutate native BackgroundColor |
1 file | Most isolated to custom subclass scenario, but significantly more invasive and higher-risk due new layer-rendering path. Android build probe blocked by missing restore assets. | |
| PR | PR #36769 | In ButtonExtensions.UpdateBackground, clear null/empty background only when platformButton.Window is not null |
4 files | Original PR. Simpler and lower risk than candidates; gate was already inconclusive and was not rerun per instructions. |
Cross-Pollination / Learning
| Round | Input | Outcome |
|---|---|---|
| 1 | Pre-flight code review found production fix sound but UI test can read Checking...; requested alternatives excluding PR Window guard |
Generated Candidate 1 at mapper lifecycle layer. |
| 2 | Candidate 1 build probe blocked by NETSDK1005 missing net10.0-android assets |
Generated Candidate 2 using MAUI-owned background state, not lifecycle/attachment state. |
| 3 | Candidate 2 hit same environment blocker | Generated Candidate 3 using non-destructive custom-subclass layer rendering; identified as viable but higher-risk/invasive. |
| Exhaustion | Asked for another meaningfully different production approach excluding lifecycle and ownership-state variants | Remaining viable space collapses into lifecycle/attachment heuristics, ownership tracking, or rendering-strategy changes. No further non-trivial production alternatives were pursued. |
Exhausted: Yes
Selected Fix: PR #36769 — The PR's production change remains the simplest and least risky candidate. None of the alternatives could be validated in this environment, and Candidate 3 is demonstrably more invasive. Candidate 2 is the strongest theoretical alternative if exact native-baseline restoration is desired, but it adds state and edge cases not required by the reported regression.
Per-Candidate Details
try-fix-1 — Handler-state initial-map suppression
Approach: Move the initial null-background suppression from ButtonExtensions.UpdateBackground into ButtonHandler.MapBackground using handler.IsConnectingHandler() && button.Background.IsNullOrEmpty(). This avoids calling the UIButton background updater during the initial full mapper pass, but leaves the updater itself free to clear null backgrounds for live property/VisualState updates.
Why different from PR: The PR uses platform attachment state (platformButton.Window) inside the platform extension. This candidate uses MAUI handler lifecycle state at the mapper boundary and restores the extension method to unconditional null clearing.
Expected behavior: Native subclass constructor-set BackgroundColor is preserved during initial handler connection because MapBackground returns before touching the platform view. Later null-background updates still call UpdateBackground(null) and clear UIColor.Clear, preserving #33346's live VisualState restore behavior.
diff --git a/src/Core/src/Handlers/Button/ButtonHandler.iOS.cs b/src/Core/src/Handlers/Button/ButtonHandler.iOS.cs
index 678e953c4a..4f05eb675d 100644
--- a/src/Core/src/Handlers/Button/ButtonHandler.iOS.cs
+++ b/src/Core/src/Handlers/Button/ButtonHandler.iOS.cs
@@ -51,6 +51,9 @@ namespace Microsoft.Maui.Handlers
#if MACCATALYST
public static void MapBackground(IButtonHandler handler, IButton button)
{
-
if (handler.IsConnectingHandler() && button.Background.IsNullOrEmpty()) -
return; -
//If this is a Mac optimized interface if (OperatingSystem.IsIOSVersionAtLeast(15) && UIDevice.CurrentDevice.UserInterfaceIdiom == UIUserInterfaceIdiom.Mac) {
@@ -80,6 +83,9 @@ namespace Microsoft.Maui.Handlers
// TODO: Make this public in .NET 11
internal static void MapBackground(IButtonHandler handler, IButton button)
{
-
if (handler.IsConnectingHandler() && button.Background.IsNullOrEmpty()) -
return; -
handler.PlatformView?.UpdateBackground(button.Background); }
#endif
diff --git a/src/Core/src/Platform/iOS/ButtonExtensions.cs b/src/Core/src/Platform/iOS/ButtonExtensions.cs
index 956f4e1eef..8d9f7570db 100644
--- a/src/Core/src/Platform/iOS/ButtonExtensions.cs
+++ b/src/Core/src/Platform/iOS/ButtonExtensions.cs
@@ -66,15 +66,7 @@ namespace Microsoft.Maui.Platform
if (paint.IsNullOrEmpty())
{
-
// 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; -
}
-
platformButton.BackgroundColor = UIColor.Clear; return; }
Test Results
Command: dotnet build src/Core/src/Core.csproj -f net10.0-android --no-restore /v:minimal
/home/vsts/work/1/s/.dotnet/sdk/10.0.100/Sdks/Microsoft.NET.Sdk/targets/Microsoft.PackageDependencyResolution.targets(266,5): error NETSDK1005: Assets file '/home/vsts/work/1/s/artifacts/obj/Core/project.assets.json' doesn't have a target for 'net10.0-android'. Ensure that restore has run and that you have included 'net10.0-android' in the TargetFrameworks for your project. [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net10.0-android]
Build FAILED.
/home/vsts/work/1/s/.dotnet/sdk/10.0.100/Sdks/Microsoft.NET.Sdk/targets/Microsoft.PackageDependencyResolution.targets(266,5): error NETSDK1005: Assets file '/home/vsts/work/1/s/artifacts/obj/Core/project.assets.json' doesn't have a target for 'net10.0-android'. Ensure that restore has run and that you have included 'net10.0-android' in the TargetFrameworks for your project. [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net10.0-android]
0 Warning(s)
1 Error(s)
Time Elapsed 00:00:01.34
Result:
Failure Analysis
Android-targeted Core build did not complete successfully in this environment. Because the changed files are iOS-only and the caller supplied an inconclusive gate with instructions not to rerun gate verification, this is recorded as an environment/build blocker rather than a candidate correctness failure unless the output identifies candidate-touched code.
try-fix-2 — Track MAUI-owned background state
Approach: Track per-UIButton background ownership with a ConditionalWeakTable. The first null/default background mapping records the native BackgroundColor and returns without clearing because MAUI has not yet applied a background. When MAUI later applies a non-null background, the state is marked as MAUI-owned; a subsequent null/default mapping removes MAUI background layers and restores the recorded native baseline.
Why different from PR and try-fix-1: This candidate does not use platformButton.Window and does not suppress mapper execution based on IsConnectingHandler(). It distinguishes native/user-owned styling from MAUI-owned background state inside the background updater.
Expected behavior: Initial custom native styling is preserved because no MAUI background has been applied yet. Live VisualState restore is preserved because null mapping after a MAUI-applied background removes MAUI state and restores the platform baseline rather than leaving stale VisualState color/layers.
diff --git a/src/Core/src/Platform/iOS/ButtonExtensions.cs b/src/Core/src/Platform/iOS/ButtonExtensions.cs
index 956f4e1eef..25923ace6e 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;
@@ -7,6 +8,13 @@ namespace Microsoft.Maui.Platform
public static class ButtonExtensions
{
public const double AlmostZero = 0.00001;
-
static readonly ConditionalWeakTable<UIButton, ButtonBackgroundState> BackgroundStates = new(); -
sealed class ButtonBackgroundState -
{ -
public UIColor? NativeBackgroundColor { get; set; } -
public bool HasMauiAppliedBackground { get; set; } -
} public static void UpdateStrokeColor(this UIButton platformButton, IButtonStroke buttonStroke) {
@@ -57,29 +65,24 @@ namespace Microsoft.Maui.Platform
// TODO: Make this public in .NET 11
internal static void UpdateBackground(this UIButton platformButton, Graphics.Paint? paint)
{
-
// Remove previous background gradient layer if any. -
// 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();
-
var backgroundState = BackgroundStates.GetOrCreateValue(platformButton); -
if (!backgroundState.HasMauiAppliedBackground) -
backgroundState.NativeBackgroundColor = platformButton.BackgroundColor; if (paint.IsNullOrEmpty()) {
-
// 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)
-
if (backgroundState.HasMauiAppliedBackground) {
-
platformButton.BackgroundColor = UIColor.Clear;
-
platformButton.RemoveBackgroundLayer(); -
platformButton.BackgroundColor = backgroundState.NativeBackgroundColor; -
backgroundState.HasMauiAppliedBackground = false; } return; } // Delegate to the standard view background update ViewExtensions.UpdateBackground(platformButton, paint); -
backgroundState.HasMauiAppliedBackground = true; } public static void UpdateCharacterSpacing(this UIButton platformButton, ITextStyle textStyle)
Test Results
Command: dotnet build src/Core/src/Core.csproj -f net10.0-android --no-restore /v:minimal
/home/vsts/work/1/s/.dotnet/sdk/10.0.100/Sdks/Microsoft.NET.Sdk/targets/Microsoft.PackageDependencyResolution.targets(266,5): error NETSDK1005: Assets file '/home/vsts/work/1/s/artifacts/obj/Core/project.assets.json' doesn't have a target for 'net10.0-android'. Ensure that restore has run and that you have included 'net10.0-android' in the TargetFrameworks for your project. [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net10.0-android]
Build FAILED.
/home/vsts/work/1/s/.dotnet/sdk/10.0.100/Sdks/Microsoft.NET.Sdk/targets/Microsoft.PackageDependencyResolution.targets(266,5): error NETSDK1005: Assets file '/home/vsts/work/1/s/artifacts/obj/Core/project.assets.json' doesn't have a target for 'net10.0-android'. Ensure that restore has run and that you have included 'net10.0-android' in the TargetFrameworks for your project. [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net10.0-android]
0 Warning(s)
1 Error(s)
Time Elapsed 00:00:01.42
Result:
Failure Analysis
Android-targeted Core build did not complete because restore assets do not include net10.0-android. The output does not identify candidate-touched code, so this is treated as an environment/build blocker rather than proof the candidate is incorrect.
try-fix-3 — Non-destructive MAUI background layer for custom UIButton subclasses
Approach: Detect custom UIButton subclasses in ButtonExtensions.UpdateBackground. For custom subclasses, never mutate the native BackgroundColor; null/default paint removes only the MAUI-owned background layer, while non-null solid/gradient paint is rendered through a named MAUI background layer. Default MAUI-created UIButton instances keep the current PR behavior.
Why different from PR, try-fix-1, and try-fix-2: The bug path is handled by changing the rendering strategy for custom native subclasses, not by using Window, handler lifecycle state, or stored native-background ownership state.
Expected behavior: A custom UIButton's constructor styling remains untouched during initial null-background mapping. If a MAUI background is explicitly applied, MAUI draws its own layer; clearing the background removes that layer and reveals the unchanged native styling.
diff --git a/src/Core/src/Platform/iOS/ButtonExtensions.cs b/src/Core/src/Platform/iOS/ButtonExtensions.cs
index 956f4e1eef..fda3e33f1d 100644
--- a/src/Core/src/Platform/iOS/ButtonExtensions.cs
+++ b/src/Core/src/Platform/iOS/ButtonExtensions.cs
@@ -1,4 +1,5 @@
using System;
+using CoreAnimation;
using Microsoft.Maui.Graphics;
using UIKit;
@@ -57,6 +58,12 @@ namespace Microsoft.Maui.Platform
// TODO: Make this public in .NET 11
internal static void UpdateBackground(this UIButton platformButton, Graphics.Paint? paint)
{
-
if (platformButton.GetType() != typeof(UIButton)) -
{ -
platformButton.UpdateCustomButtonBackground(paint); -
return; -
} -
// Remove previous background gradient layer if any. // 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.
@@ -82,6 +89,38 @@ namespace Microsoft.Maui.Platform
ViewExtensions.UpdateBackground(platformButton, paint);
}
-
static void UpdateCustomButtonBackground(this UIButton platformButton, Graphics.Paint? paint) -
{ -
if (paint.IsNullOrEmpty()) -
{ -
platformButton.RemoveBackgroundLayer(); -
return; -
} -
if (paint is SolidPaint solidPaint) -
{ -
var backgroundLayer = new CALayer -
{ -
Name = ViewExtensions.BackgroundLayerName, -
BackgroundColor = (solidPaint.Color ?? ColorExtensions.BackgroundColor).ToCGColor() -
}; -
platformButton.InsertBackgroundLayer(backgroundLayer, 0); -
return; -
} -
if (paint is GradientPaint gradientPaint) -
{ -
var backgroundLayer = gradientPaint.ToCALayer(platformButton.Bounds); -
if (backgroundLayer is not null) -
{ -
backgroundLayer.Name = ViewExtensions.BackgroundLayerName; -
platformButton.InsertBackgroundLayer(backgroundLayer, 0); -
} -
} -
} -
public static void UpdateCharacterSpacing(this UIButton platformButton, ITextStyle textStyle) { var attributedText = platformButton?.TitleLabel.AttributedText?.WithCharacterSpacing(textStyle.CharacterSpacing);
Test Results
Command: dotnet build src/Core/src/Core.csproj -f net10.0-android --no-restore /v:minimal
/home/vsts/work/1/s/.dotnet/sdk/10.0.100/Sdks/Microsoft.NET.Sdk/targets/Microsoft.PackageDependencyResolution.targets(266,5): error NETSDK1005: Assets file '/home/vsts/work/1/s/artifacts/obj/Core/project.assets.json' doesn't have a target for 'net10.0-android'. Ensure that restore has run and that you have included 'net10.0-android' in the TargetFrameworks for your project. [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net10.0-android]
Build FAILED.
/home/vsts/work/1/s/.dotnet/sdk/10.0.100/Sdks/Microsoft.NET.Sdk/targets/Microsoft.PackageDependencyResolution.targets(266,5): error NETSDK1005: Assets file '/home/vsts/work/1/s/artifacts/obj/Core/project.assets.json' doesn't have a target for 'net10.0-android'. Ensure that restore has run and that you have included 'net10.0-android' in the TargetFrameworks for your project. [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net10.0-android]
0 Warning(s)
1 Error(s)
Time Elapsed 00:00:01.59
Result:
Failure Analysis
Android-targeted Core build did not complete because restore assets do not include net10.0-android. The output does not identify candidate-touched code, so this is treated as an environment/build blocker. Candidate 3 also carries higher implementation risk because it adds new button-specific layer rendering for custom subclasses.
Temporary candidate edit check
✅ Temporary candidate markers removed; source files restored to PR baseline for candidate surfaces.
📝 Recommended PR Title & Description
Assessment: ✏️ Recommend updating — the winning pr-plus-reviewer fix no longer uses the submitted PR's Window check, so the current description would be stale.
Recommended title
[iOS] ButtonHandler: Preserve custom UIButton background when Background is null
Recommended description
### Issue Details
PR #33346 changed UpdateBackground(UIButton, Paint?) to unconditionally reset BackgroundColor = UIColor.Clear when paint is null. Since MAUI maps every property during initial handler setup — including Background = null (the default) — a custom UIButton subclass's constructor-set BackgroundColor could be wiped before the button was shown.
### Description of Change
Skip the initial null-background mapping in ButtonHandler.MapBackground while the handler is connecting and Button.Background is null/empty. This preserves native BackgroundColor set by custom UIButton subclasses during the initial full mapper pass.
After the initial connection phase, UpdateBackground(UIButton, Paint?) continues to clear BackgroundColor to UIColor.Clear when paint is null. This keeps the VisualState/unapplied-setter behavior from PR #33346 for later updates, including visible transitions back to a null Background.
The regression test adds a custom ButtonHandler whose CreatePlatformView() returns a native UIButton subclass with BackgroundColor = UIColor.Cyan, then verifies that the initial render preserves that native color. The test waits for the PASS result text before asserting so it does not read the initial "Checking..." label state.
### Issues Fixed
Fixes #36749
**Tested the behavior in the following platforms.**
- [ ] Android
- [ ] Windows
- [x] iOS
- [x] Mac
| Before | After |
|---------|--------|
| **iOS**<br> <img src="https://github.com/user-attachments/assets/ddffc469-e4ee-4ead-ac27-5f04599b0409" width="300" height="600"> | **iOS**<br> <img src="https://github.com/user-attachments/assets/f08ca4c8-12a5-4ab4-8baa-f16e7139df5a" width="300" height="600"> |
🏁 Report — Final Recommendation
Comparative Analysis — PR #36769
Candidate ranking
| Rank | Candidate | Regression test status | Assessment |
|---|---|---|---|
| 1 | pr-plus-reviewer |
Best balance. It preserves custom native UIButton.BackgroundColor during initial handler connection, avoids the PR's Window == null lifecycle heuristic for later null updates, and cleans up misleading/flaky test coverage. |
|
| 2 | try-fix-1 |
Production approach is essentially the same lifecycle-bound fix as pr-plus-reviewer: skip initial null background mapping in ButtonHandler.MapBackground, then keep UpdateBackground(null) clearing normally. Ranked below pr-plus-reviewer because it lacks the reviewer-applied test cleanup. |
|
| 3 | pr |
Simple and likely fixes the reported initial-render regression. Ranked below the reviewer-adjusted candidate because Window is not null is a broader heuristic than "not initial handler mapping" and can preserve stale native background if background changes occur repeatedly before window attachment. |
|
| 4 | try-fix-2 |
Semantically precise ownership-tracking idea, but it introduces persistent per-UIButton state via ConditionalWeakTable and edge cases around native mutations while MAUI owns the background. More complexity than needed for this regression. |
|
| 5 | try-fix-3 |
Most invasive. Rendering custom subclass backgrounds through a new named layer path avoids mutating native BackgroundColor, but adds new button-specific rendering behavior and higher layout/layer risk. |
No candidate has a confirmed failing regression-test result. The blocked/inconclusive results came from the existing environment/build limitation and are not treated as proof of incorrectness.
Candidate details
pr
The raw PR gates the null/empty background reset in ButtonExtensions.UpdateBackground on platformButton.Window is not null. This is small and aligns conceptually with the existing UpdateTextColor initial-render guard. Its downside is precision: attachment state is not the same as mapper lifecycle state, so a non-initial pre-window null update can leave a stale native background in place.
The added UI test covers the reported custom UIButton initial-render case, but the raw test waits only for the result label to exist and includes native TextColor setup that is not asserted.
pr-plus-reviewer
The reviewer-adjusted candidate applies the initial-null skip at ButtonHandler.MapBackground while handler.IsConnectingHandler() is true. That targets the actual problematic phase: MAUI's initial full mapper pass with default Background == null. After that phase, UpdateBackground(null) again clears to UIColor.Clear, preserving PR #33346's VisualState/unapplied-setter behavior even if the view has not yet reached a UIWindow.
It also improves test clarity and synchronization by removing unasserted TextColor setup and waiting for the result text to become "PASS".
try-fix-1
This candidate is the strongest STEP 5a alternative and independently converges on the same production strategy as pr-plus-reviewer: mapper-lifecycle suppression instead of a Window guard. It remains a good fallback, but pr-plus-reviewer wins because it additionally incorporates the expert reviewer/test feedback.
try-fix-2
The ownership-state model would distinguish native baseline styling from MAUI-applied backgrounds, but adds mutable state and restoration semantics to a hot platform extension. That creates additional correctness questions around repeated native changes, handler reuse, and exactly what color should be restored.
try-fix-3
The custom-subclass layer-rendering approach is too broad for this bug. It changes how explicit MAUI backgrounds render for custom UIButton subclasses and adds new CALayer behavior where a targeted mapper-lifecycle fix is sufficient.
Winner
pr-plus-reviewer is the winning candidate. It keeps the PR's intended behavior, addresses the expert production concern, and improves the regression test without adopting the heavier stateful or rendering-strategy alternatives.
🧭 Next Steps — review latest findings
No alternative fix was selected for this run. Review the session findings and CI results before merging.
Note
Are you waiting for the changes in this PR to be merged?
It would be very helpful if you could test the resulting artifacts from this PR and let us know in a comment if this change resolves your issue. Thank you!
Issue Details
PR #33346 changed UpdateBackground(UIButton, Paint?) to unconditionally reset BackgroundColor = UIColor.Clear when paint is null. Since MAUI maps every property during initial setup — including Background = null (the default) — the native constructor-set color gets wiped before the button is ever shown.
Description of Change
Added a Window check to UpdateBackground, matching the identical guard already present in UpdateTextColor.
Issues Fixed
Fixes #36749
Tested the behavior in the following platforms.