[Android] Make system chrome follow MAUI bar colors (opt-in) - #35463
Conversation
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 35463Or
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 35463" |
|
/review -b feature/regression-check -p android |
|
/review -b feature/refactor-copilot-yml |
|
/review -b feature/refactor-copilot-yml |
MauiBot
left a comment
There was a problem hiding this comment.
Expert Review — 1 findings
See inline comments for details.
|
/review -b feature/refactor-copilot-yml |
MauiBot
left a comment
There was a problem hiding this comment.
Expert Review — 1 findings
See inline comments for details.
This comment has been minimized.
This comment has been minimized.
kubaflo
left a comment
There was a problem hiding this comment.
We probably need to update snapshots
|
/azp run maui-pr-uitests |
|
Azure Pipelines successfully started running 1 pipeline(s). |
Restore the captured native AppBar drawable before applying solid-color tints so non-solid brush drawables cannot remain underneath. Prefer the toolbar style title text color before falling back to theme-derived foreground colors when BarTextColor is unset. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add a RuntimeFeature-backed MSBuild switch so apps can keep Android theme/platform ownership of system bar background colors while MAUI keeps matching system bars to MAUI chrome by default. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Merge the latest net11.0 changes and remove the fixed delay from the Android system bar opt-out regression test so the negative assertion runs synchronously after changing BarBackgroundColor. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Refresh Android and Android API 36 visual baselines for the new system chrome behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Correct the Android accessibility checked-state assignment after the AndroidX API update and register the current Activity for the Window handler status-bar test setup. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f200d22-045a-4896-97a0-65fb75fbc45d
Keep existing Android system chrome behavior by default, explicitly opt test apps into the new behavior, isolate modal windows from their underlying Activity, and keep Shell system bars aligned with effective default chrome colors. Remove the unrelated RadioButton accessibility change from this PR. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f200d22-045a-4896-97a0-65fb75fbc45d
2f0d15b to
efb5346
Compare
Final rebase and empirical Android proofRebased onto current Compatibility contract proven
API 30 emulator results
The opt-in build also passed a broad 88/88 Modal-category run, including a regression assertion proving modal chrome updates the dialog window without recoloring the underlying Activity. Visual evidence
Legacy/default behavior recording: Explicit opt-in behavior recording: I also removed an unrelated RadioButton accessibility change from the PR, preserved the newer upstream Shell brush behavior during conflict resolution, fixed Shell default-color synchronization, added dialog-vs-Activity isolation, and removed the broad Android snapshot refresh so the final PR changes only 18 source/test files. Based on the compiled switch values, centralized disabled guards, visual comparison, and clean 87/87 matrices in both configurations, the activation boundary is demonstrated: existing behavior remains the default, and the new MAUI-owned Android system-chrome behavior only appears after explicit opt-in. |
Keep the standard UI test host on the legacy default for this opt-in feature and restore unrelated Android visual baselines. Explicit opt-in and opt-out behavior remains covered by separately compiled device-test configurations. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f200d22-045a-4896-97a0-65fb75fbc45d
There was a problem hiding this comment.
Copilot's findings
Suppressed comments (3)
src/Controls/src/Core/Platform/Android/Extensions/ToolbarExtensions.cs:303
- UpdateOverflowIconColor applies a black/white fallback tint (via GetDefaultForegroundColor) even when neither IconColor nor BarTextColor is set. That overwrites the native/theme default overflow icon tint and is a behavior change outside the new system-bar feature gate.
var iconColor = toolbar.IconColor ?? toolbar.BarTextColor ?? GetDefaultForegroundColor();
if (iconColor is null)
{
overflowIcon.ClearColorFilter();
}
else
{
overflowIcon.SetColorFilter(iconColor.ToPlatform(), FilterMode.SrcAtop);
}
src/Controls/src/Core/Platform/Android/Extensions/ToolbarExtensions.cs:255
- UpdateNavigationIconColor falls back to GetDefaultForegroundColor (black/white by RequestedTheme) before consulting the theme-derived DrawerArrowDrawable default. This can override a custom toolbar theme's default navigation icon color when IconColor/BarTextColor are unset.
else if (GetDefaultForegroundColor() is { } defaultForegroundColor)
{
icon.Color = defaultForegroundColor.ToPlatform().ToArgb();
}
else if (GetDefaultNavigationIconColor(nativeToolbar) is int defaultNavigationIconColor)
{
icon.Color = defaultNavigationIconColor;
}
src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarAppearanceTracker.cs:60
- ShellToolbarAppearanceTracker only updates system chrome when the appearance explicitly provided a background (passes null otherwise), even though it always assigns a default BarBackground. This prevents status-bar sync with Shell's effective default background when no explicit Shell background is supplied.
AndroidSystemChrome.UpdateTopChrome(
toolbar,
background is null ? null : shellToolbar.BarBackground);
- Files reviewed: 17/18 changed files
- Comments generated: 0 new
Android revalidation after new commits — ❌ NOT PASSValidated current head Environment: Google Matrix: NavigationPage solid/live update/gradient; top TabbedPage solid/gradient/solid; bottom TabbedPage initial/live update; modal LightGreen→Blue with distinct dialog window/root, icon flags, and Activity restoration; Shell explicit toolbar/bottom colors; Shell effective defaults with mixed icon flags. Results:
Verdict: current head is not a PASS. The failure pattern is consistent with an initial bottom-view attach/window-resolution timing issue; subsequent property changes synchronize the navigation bar correctly. Explicit opt-in confirmation recording (current head): Default/property-omitted control recording: Caveats: the control is the documented default/property-omitted legacy behavior, not a historical base checkout. I locally changed only the Sandbox script target from |
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f200d22-045a-4896-97a0-65fb75fbc45d
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3f200d22-045a-4896-97a0-65fb75fbc45d
|
Addressed the initial bottom-edge failures reported in the API 36 empirical revalidation at head Root cause: initial bottom TabbedPage/Shell appearance updates can run before the native bottom view is attached. The Activity-window ownership check intentionally returns no window at that point, so the initial system-chrome update was dropped. Later property changes worked because the view was attached by then. Fix: Empirical proof:
The three API 36 failures all share this central bottom-chrome path (TabbedPage initial color, Shell explicit initial color, and Shell effective default initial color), while their later live updates already passed. Please rerun the same rendered API 36 matrix against |
There was a problem hiding this comment.
Copilot's findings
Suppressed comments (2)
src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarAppearanceTracker.cs:60
- When
appearance.Backgroundandappearance.BackgroundColorare both unset,shellToolbar.BarBackgroundis still set to the default background, butUpdateTopChromeis called withnull. That restores native system bar background rather than aligning it to the effective Shell toolbar background, which makes Shell inconsistent withShellTabLayoutAppearanceTracker(and the intended default-color behavior).
shellToolbar.BarTextColor = title ?? ShellRenderer.DefaultTitleColor;
shellToolbar.BarBackground = background ?? new SolidColorBrush(ShellRenderer.DefaultBackgroundColor);
shellToolbar.IconColor = foreground ?? ShellRenderer.DefaultForegroundColor;
AndroidSystemChrome.UpdateTopChrome(
toolbar,
background is null ? null : shellToolbar.BarBackground);
src/Core/src/Platform/Android/WindowExtensions.cs:201
RestoreNavigationBarColorusesTypedValue.Datadirectly afterResolveAttribute(ColorBackground, ...). If the resolved attribute is a resource reference (common for themes),Datamay be a resource ID rather than an ARGB color int, leading to an incorrect navigation bar color. This should validatetypedValue.Type(color-int) and/or resolvetypedValue.ResourceIdvia the context.
if (_navigationBarColor == 0 && window.Context?.Theme is { } theme)
{
var typedValue = new TypedValue();
if (theme.ResolveAttribute(global::Android.Resource.Attribute.ColorBackground, typedValue, true)
&& typedValue.Data != 0)
{
restoreColor = new AColor(typedValue.Data);
}
}
- Files reviewed: 17/18 changed files
- Comments generated: 0 new
Third empirical Android validation — updated head
|
| Validated SHA | ed2c18786e4c6fe2b091d9c8096923bf34808c24 |
| Previous run SHA | 607caf7db1f863f25b50270bfb2dcad8bd8f0ee2 (run 2 comment) |
| First run SHA | 2f0d15b386e0 (run 1 comment) |
Commits added since 607caf7db1f:
09a533c483a— Fix initial Android bottom system chrome updateed2c18786e4— Strengthen initial bottom chrome regression coverage
Diff since 607caf7db1f — 2 files, +101/−1:
src/Controls/src/Core/Platform/Android/AndroidSystemChrome.cs(+62/−1)src/Controls/tests/DeviceTests/Elements/TabbedPage/TabbedPageTests.Android.cs(+40)
The functional change adds a PendingBottomChromeUpdate queue: when UpdateBottomChrome runs while the chrome view has no resolvable Activity window and is not yet attached (window is null && !chromeView.IsAttachedToWindow), the brush is stored and re-applied via view.OnLoaded(...), with cancellation + ConditionalWeakTable removal once a real window resolves. That is precisely the code path behind all three run‑2 failures, which were all initial bottom-edge renders.
Environment
| Device | Android emulator emulator-5554, AVD Pixel_9_API_36_-_screenshots |
| Model / ABI | sdk_gphone64_arm64 / arm64-v8a |
| OS / API | Android 16 / API 36 |
| Display | 1080×2424 @ 420 dpi |
| TFM | net11.0-android (base branch net11.0) |
| Config | Debug, MauiAndroidSystemBarsUseMauiChrome=true (explicit opt-in) |
| Harness | Sandbox app + Appium UIAutomator2, rendered-pixel sampling of the top/bottom system-bar bands |
Core 17-check matrix — numerically comparable with run 2
Sampling: top band = 4.5% of height, bottom band = 1.8% of height, middle 75% of width, 3px stride. A check matches when ≥55% of sampled pixels are within tolerance or mean color distance ≤ 0.8×tolerance.
| # | Scenario | Check | Edge | Expected | matchRatio | avgDist | Run 2 | Run 3 |
|---|---|---|---|---|---|---|---|---|
| 1 | NavigationPage | initial solid | Top | #FF8C00 |
97.4% | 4.3 | ✅ | ✅ |
| 2 | NavigationPage | live update | Top | #1E88E5 |
97.4% | 4.0 | ✅ | ✅ |
| 3 | NavigationPage | vertical gradient top edge | Top | #FF006E |
97.3% | 38.1 | ✅ | ✅ |
| 4 | Top TabbedPage | initial solid | Top | #FF8C00 |
97.4% | 4.3 | ✅ | ✅ |
| 5 | Top TabbedPage | gradient | Top | #FF006E |
94.7% | 40.8 | ✅ | ✅ |
| 6 | Top TabbedPage | gradient→solid restoration | Top | #1E88E5 |
97.4% | 4.0 | ✅ | ✅ |
| 7 | Bottom TabbedPage | initial (nav-bar edge) | Bottom | #2E7D32 |
93.0% | 20.6 | ❌ | ✅ |
| 8 | Bottom TabbedPage | live update | Bottom | #6A1B9A |
93.0% | 18.7 | ✅ | ✅ |
| 9 | Modal NavigationPage | initial color | Top | #90EE90 |
97.4% | 4.6 | ✅ | ✅ |
| 10 | Modal NavigationPage | live update | Top | #1E88E5 |
97.5% | 4.0 | ✅ | ✅ |
| 11 | Modal NavigationPage | Activity restored after dismissal | Top | #FF8C00 |
97.4% | 4.3 | ✅ | ✅ |
| 12 | Shell explicit | toolbar initial | Top | #FF8C00 |
97.4% | 4.3 | ✅ | ✅ |
| 13 | Shell explicit | tab bar initial | Bottom | #00897B |
93.0% | 20.0 | ❌ | ✅ |
| 14 | Shell explicit | toolbar live update | Top | #1E88E5 |
97.5% | 4.0 | ✅ | ✅ |
| 15 | Shell explicit | tab bar live update | Bottom | #2E7D32 |
93.0% | 20.6 | ✅ | ✅ |
| 16 | Shell defaults | effective default toolbar | Top | #2C3E50 |
97.2% | 8.3 | ✅ | ✅ |
| 17 | Shell defaults | effective default nav bar | Bottom | #FFFFFF |
93.0% | 18.5 | ❌ | ✅ |
Core: 17/17 matched, 0 mismatched (run 2: 14/17).
Comparable-13 subset (run‑1 parity): 13/13 matched, 0 mismatched (run 2: 11/13; run 1: 13/13).
Modal isolation (check 9–11) additionally asserted from device logs: status=True nav=True; separateWindow=True separateRoot=True — the modal uses a distinct dialog window/root and inherits icon-appearance flags. Shell mixed icon flags asserted as lightStatus=False lightNav=True.
Explicit status of each run‑2 failure
| Run‑2 failure | Run 2 (607caf7db1f) |
Run 3 (ed2c18786e4) |
Status |
|---|---|---|---|
| Bottom TabbedPage initial Green rendered orange-toned | 0.0% match, avgDist 166.6 | 93.0% match, avgDist 20.6 | ✅ Fixed |
| Shell initial Teal rendered orange-toned | 0.0% match, avgDist 221.8 | 93.0% match, avgDist 20.0 | ✅ Fixed |
| Shell effective-default White nav bar rendered orange | 0.0% match, avgDist 231.8 | 93.0% match, avgDist 18.5 | ✅ Fixed |
Added timing / lifecycle coverage for the new commits — 12/12 matched
Because the new commits defer the bottom update to OnLoaded, I added targeted cold-start, repeat and settle cases beyond the comparable matrix.
| # | Case | Edge | Expected | matchRatio | avgDist | Result |
|---|---|---|---|---|---|---|
| E1 | Repeat #2 — bottom tabs initial | Bottom | #2E7D32 |
93.0% | 20.6 | ✅ |
| E2 | Repeat #2 — Shell Teal initial | Bottom | #00897B |
93.0% | 20.0 | ✅ |
| E3 | Repeat #2 — Shell defaults initial | Bottom | #FFFFFF |
93.0% | 18.5 | ✅ |
| E4 | Settle probe 700 ms — bottom tabs | Bottom | #2E7D32 |
93.0% | 20.6 | ✅ |
| E5 | Settle probe 2500 ms — bottom tabs | Bottom | #2E7D32 |
93.0% | 20.6 | ✅ |
| E6 | Settle probe 700 ms — Shell Teal | Bottom | #00897B |
93.0% | 20.0 | ✅ |
| E7 | Settle probe 2500 ms — Shell Teal | Bottom | #00897B |
93.0% | 20.0 | ✅ |
| E8 | Cold start into bottom TabbedPage root | Bottom | #2E7D32 |
93.0% | 20.6 | ✅ |
| E9 | Cold start into Shell root (tab bar) | Bottom | #00897B |
93.0% | 20.0 | ✅ |
| E10 | Cold start into Shell root (toolbar) | Top | #FF8C00 |
97.4% | 4.3 | ✅ |
| E11 | Cold start into Shell defaults root | Bottom | #FFFFFF |
93.0% | 18.5 | ✅ |
| E12 | Cold start #2 into bottom TabbedPage root | Bottom | #2E7D32 |
93.0% | 20.6 | ✅ |
Cold start = terminateApp + activateApp, with the app booting directly into the scenario as the window root (persisted startup preference), so the bottom chrome view is created before attachment — the exact deferred path the fix introduces.
Grand total: 29/29 matched, 0 mismatched.
Repeatability & timing
- The primary regression (bottom TabbedPage initial Green) was measured 6× in the opt-in run — core, in-session repeat, 2 settle probes, and 2 cold starts — matching every time at an identical 93.0% / avgDist 20.6.
- Shell Teal initial measured 5×, Shell defaults nav bar 3×; all identical.
- Settle probes show no timing dependence: the 700 ms and 2500 ms samples are numerically identical (20.6 / 20.0), so the bar is already correct at first paint rather than converging late.
- The full 17-check matrix was executed twice end-to-end on this SHA (two independent app sessions); both produced core 17/17 and comparable 13/13.
Control
A fresh control was captured on this same SHA with MauiAndroidSystemBarsUseMauiChrome removed from the Sandbox project (property-omitted / legacy default). This is not a historical base checkout — same PR code, opt-in disabled — so it isolates the feature flag rather than the commit.
- Control core: 2/17 matched, 15 mismatched
- Control comparable-13: 0/13 matched, 13 mismatched (mirrors run 1's 13/13 legacy mismatch)
- Control grand: 4/29 matched, 25 mismatched
- Representative legacy distances: NavigationPage Orange avgDist 239.4, gradient 222.7, Blue 168.4 (vs 4.3 / 38.1 / 4.0 opt-in)
The 2 control "matches" are the Shell effective-default checks (#2C3E50 top, #FFFFFF bottom) — those expected values coincide with the legacy theme-owned colors, so they are non-discriminating in the control. They were still meaningful as regression checks, since run 2 rendered the same bottom bar orange.
Videos
Explicit opt-in, full interactive run on ed2c18786e4 (169 s):
pr35463_ed2c1878_explicit-opt-in-confirmation.mp4
Property-omitted legacy control on ed2c18786e4 (162 s):
pr35463_ed2c1878_default-omitted-control.mp4
Caveats
- Single device: API 36 emulator only. Not re-verified on API ≤ 34 (pre-edge-to-edge, where
StatusBarColor/NavigationBarColorare still honored) nor on physical hardware, 3-button navigation, landscape, or dark theme. - On API 36,
Window.StatusBarColor/NavigationBarColorread back as#00000000for every scenario (edge-to-edge; the setters are deprecated no-ops). Rendered-pixel sampling is therefore the authoritative signal here, not those properties. - Bottom-edge checks plateau at 93.0% rather than ~97% because the gesture-navigation pill occupies part of the sampled band. It is a constant offset present in passing and failing runs alike, and does not affect match/mismatch classification.
- Gradient checks legitimately carry a higher avgDist (38–41) since the band spans a color ramp; tolerance is widened accordingly (70–75).
- Verification is color/appearance only — no assertions on inset handling, contrast/accessibility, or
MauiAndroidSystemBarsUseMauiChrome=falseexplicitly set (as opposed to omitted). - Sandbox harness changes are local to my validation worktree and are not part of this PR.
Verdict
PASS on ed2c18786e4c6fe2b091d9c8096923bf34808c24.
Core 17/17, comparable-13 13/13, timing/lifecycle 12/12, grand 29/29 — versus 14/17 and 11/13 on 607caf7db1f. All three previously reported failures are resolved, no previously passing check regressed, and the fresh legacy control still reproduces the opt-out behavior (0/13 comparable).
Automated Sandbox validation on a real Android emulator. Run 3 of 3.
|
@AlleSchonWeg FYI this was merged, a little different than originally planned, but this should set us up for a safe path to make this amazing. Unfortunately, despite what you hoped for, this is for .NET 11. It will appear in RC1, so please test it out and let us know. We had to add it to .NET 11 because we need to add new APIs one way or the other. And new APIs are only allowed in major versions. As also already discussed, this is a major breaking (functional) change. Something that people usually don't like to be surprised with. Which brings me to the next thing. We made this an opt-in feature for now. Mainly for 2 reasons. First, already mentioned, people don't like to be surprised with these things so opt-in mean people will make a conscious choice to do this. Second, because it's so late in the .NET 11 cycle I'm not sure if we have seen all the edge-cases for this. This way, we can ensure that the current behavior is preserved, no one should be broken, but the people interested in this can still use it. Additionally, if there are any bugs still in here, we now have both the ability to fix it and you as a user will have the option to fallback to a working state while we look into the bugfixes needed. Thank you for your understanding, I do love this functionality and I think this really is a good change and will take away confusion especially for people new to MAUI, but it's also a drastic change that we have to consider carefully. Thank you for pushing for this and please try it out and let us know the results and what else we should look into for this according to you. |









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!
Description of Change
This PR adds an explicitly opt-in Android behavior that makes status/navigation bar background colors follow effective MAUI NavigationPage, Shell, TabbedPage, and modal chrome colors.
Behavior model:
<MauiAndroidSystemBarsUseMauiChrome>true</MauiAndroidSystemBarsUseMauiChrome>.The Android Controls device-test app opts in conditionally so the new behavior remains covered in CI, while an explicit
falseoverride validates legacy behavior. The standard UI-test HostApp remains on the legacy default, so this opt-in feature does not require broad snapshot churn.Implementation details
AndroidSystemChromehelper to centralize gated status/navigation bar background updates.Compatibility
RuntimeFeature.UseMauiAndroidSystemBarBackgroundsdefaults tofalse. All new chrome-update entry points return without changing AppBar or system-bar state when disabled. The MSBuild property is emitted as the corresponding runtime host configuration option only when supplied.Do not try to toggle this feature per test after a trimmed Android app starts:
FeatureSwitchDefinitioncan be linker constant-folded. Disabled and enabled behavior must be tested as separate compiled configurations, as done here.Tests and validation
Validated after rebasing onto current
net11.0using MAUI's API 30Emulator_30, the same emulator profile used by Android UI tests, plus an API 35 Pixel emulator.Explicitly disabled (
MauiAndroidSystemBarsUseMauiChrome=false):Microsoft.Maui.RuntimeFeature.UseMauiAndroidSystemBarBackgrounds=false.net11.0baseline by 78 of 1,873,800 pixels (0.0042%) and was approximately 56× closer to the legacy baseline than the opt-in image.Explicitly enabled (
MauiAndroidSystemBarsUseMauiChrome=true):MovingBetweenMultiplePagesWithNestedNavigationPagestimeout variant failed, while the new regression passed.The Shell Appium scenario was attempted, but the harness timed out during accessibility/root-window setup before reaching its chrome assertion. This was not a product assertion failure; device tests and recorded manual interaction cover the affected Shell paths.
Issues Fixed
N/A