From 656983ca128da1af555661c069891c27010b93fa Mon Sep 17 00:00:00 2001 From: Shewart Date: Sun, 18 Oct 2026 09:35:07 +0200 Subject: [PATCH 1/4] fix: name the Application type in full in shell and toast In an app with an .Application project (clean architecture), a bare `Application` inside .App.Components.UI resolves to that namespace and the app fails to build with CS0234. The MAUI shell and the Avalonia shell and toast now write Microsoft.Maui.Controls.Application / Avalonia.Application. A template test keeps bare `Application` out of every template. --- examples/Avalonia.Demo/Components/UI/Shell.cs | 10 ++++---- examples/Avalonia.Demo/Components/UI/Toast.cs | 2 +- examples/MAUI.Demo/Components/UI/Shell.cs | 14 +++++------ .../Templates/ShellTemplate.cs | 24 +++++++++---------- .../Templates/ToastTemplate.cs | 2 +- .../TemplateContentTests.cs | 13 ++++++++++ 6 files changed, 39 insertions(+), 26 deletions(-) diff --git a/examples/Avalonia.Demo/Components/UI/Shell.cs b/examples/Avalonia.Demo/Components/UI/Shell.cs index c1a2911..b5b4ff5 100644 --- a/examples/Avalonia.Demo/Components/UI/Shell.cs +++ b/examples/Avalonia.Demo/Components/UI/Shell.cs @@ -137,13 +137,13 @@ public static class ShellTheme // The app the palettes were published to. A new Application (e.g. one per headless test) // gets them too. - private static Application? _initialized; + private static Avalonia.Application? _initialized; // Raised when a palette is published or the theme variant changes, for anything resources // can't reach (e.g. a BoxShadow). public static event EventHandler? ThemeChanged; - public static bool IsDarkMode => Application.Current?.ActualThemeVariant == ThemeVariant.Dark; + public static bool IsDarkMode => Avalonia.Application.Current?.ActualThemeVariant == ThemeVariant.Dark; public static string ColorKey(ShellToken token) => $"ShellUI{token}"; public static string BrushKey(ShellToken token) => $"ShellUI{token}Brush"; @@ -163,7 +163,7 @@ public static Color Get(ShellToken token, double opacity) public static void EnsureInitialized() { - var app = Application.Current; + var app = Avalonia.Application.Current; if (app is null || ReferenceEquals(app, _initialized)) return; _initialized = app; app.ActualThemeVariantChanged += (_, _) => ThemeChanged?.Invoke(null, EventArgs.Empty); @@ -173,7 +173,7 @@ public static void EnsureInitialized() // Re-publishes both palettes. Call after editing Light/Dark at runtime. public static void Apply() { - var app = Application.Current; + var app = Avalonia.Application.Current; if (app is null) return; app.Resources.ThemeDictionaries[ThemeVariant.Light] = Build(Light); app.Resources.ThemeDictionaries[ThemeVariant.Dark] = Build(Dark); @@ -194,7 +194,7 @@ private static ResourceDictionary Build(Dictionary palette) // ThemeVariant.Default follows the OS setting. public static void SetTheme(ThemeVariant theme) { - if (Application.Current is not { } app) return; + if (Avalonia.Application.Current is not { } app) return; EnsureInitialized(); app.RequestedThemeVariant = theme; } diff --git a/examples/Avalonia.Demo/Components/UI/Toast.cs b/examples/Avalonia.Demo/Components/UI/Toast.cs index 11a1075..7650573 100644 --- a/examples/Avalonia.Demo/Components/UI/Toast.cs +++ b/examples/Avalonia.Demo/Components/UI/Toast.cs @@ -75,7 +75,7 @@ public static void Dismiss(string id) toaster.Remove(id); } - private static TopLevel? ActiveTopLevel() => Application.Current?.ApplicationLifetime switch + private static TopLevel? ActiveTopLevel() => Avalonia.Application.Current?.ApplicationLifetime switch { IClassicDesktopStyleApplicationLifetime desktop => desktop.Windows.FirstOrDefault(w => w.IsActive) ?? desktop.MainWindow, ISingleViewApplicationLifetime single when single.MainView is { } view => TopLevel.GetTopLevel(view), diff --git a/examples/MAUI.Demo/Components/UI/Shell.cs b/examples/MAUI.Demo/Components/UI/Shell.cs index e7136d0..99ae72a 100644 --- a/examples/MAUI.Demo/Components/UI/Shell.cs +++ b/examples/MAUI.Demo/Components/UI/Shell.cs @@ -126,7 +126,7 @@ public static class ShellTheme // anything they can't reach (shadows, native-control tweaks). public static event EventHandler? ThemeChanged; - public static bool IsDarkMode => Application.Current?.RequestedTheme == AppTheme.Dark; + public static bool IsDarkMode => Microsoft.Maui.Controls.Application.Current?.RequestedTheme == AppTheme.Dark; public static string ColorKey(ShellToken token) => $"ShellUI{token}"; public static string BrushKey(ShellToken token) => $"ShellUI{token}Brush"; @@ -137,7 +137,7 @@ public static class ShellTheme public static void EnsureInitialized() { if (_initialized) return; - var app = Application.Current; + var app = Microsoft.Maui.Controls.Application.Current; if (app is null) return; _initialized = true; app.RequestedThemeChanged += (_, _) => Apply(); @@ -150,7 +150,7 @@ public static void EnsureInitialized() // Re-publishes the active palette. Call after editing Light/Dark at runtime. public static void Apply() { - var app = Application.Current; + var app = Microsoft.Maui.Controls.Application.Current; if (app is null) return; foreach (var (token, color) in IsDarkMode ? Dark : Light) { @@ -169,7 +169,7 @@ private static void ApplySystemBars(int retries) { // No activity yet (theme applied from App's constructor): try again shortly. if (retries > 0) - Application.Current?.Dispatcher.DispatchDelayed(TimeSpan.FromMilliseconds(200), () => ApplySystemBars(retries - 1)); + Microsoft.Maui.Controls.Application.Current?.Dispatcher.DispatchDelayed(TimeSpan.FromMilliseconds(200), () => ApplySystemBars(retries - 1)); return; } #pragma warning disable CA1422 // Android 15+ draws edge-to-edge and ignores these; older versions need them. @@ -186,7 +186,7 @@ private static void ApplySystemBars(int retries) public static void SetTheme(AppTheme theme) { - if (Application.Current is not { } app) return; + if (Microsoft.Maui.Controls.Application.Current is not { } app) return; EnsureInitialized(); app.UserAppTheme = theme; Apply(); @@ -457,7 +457,7 @@ private static void AddStatusBarScrim(Grid layer) if (e is ContentPage found) return found; // Not in a tree (e.g. Toast.Show from a view model): use the page on screen. - var page = Application.Current?.Windows.FirstOrDefault()?.Page; + var page = Microsoft.Maui.Controls.Application.Current?.Windows.FirstOrDefault()?.Page; while (true) { switch (page) @@ -758,7 +758,7 @@ public static bool DismissTop() private static void Hook() { - if (Application.Current is not { } app) return; + if (Microsoft.Maui.Controls.Application.Current is not { } app) return; foreach (var window in app.Windows) { if (window.Handler?.PlatformView is not Microsoft.UI.Xaml.Window { Content: { } root }) continue; diff --git a/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs b/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs index c47d83d..1db9875 100644 --- a/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs @@ -147,7 +147,7 @@ public static class ShellTheme // anything they can't reach (shadows, native-control tweaks). public static event EventHandler? ThemeChanged; - public static bool IsDarkMode => Application.Current?.RequestedTheme == AppTheme.Dark; + public static bool IsDarkMode => Microsoft.Maui.Controls.Application.Current?.RequestedTheme == AppTheme.Dark; public static string ColorKey(ShellToken token) => $""ShellUI{token}""; public static string BrushKey(ShellToken token) => $""ShellUI{token}Brush""; @@ -158,7 +158,7 @@ public static class ShellTheme public static void EnsureInitialized() { if (_initialized) return; - var app = Application.Current; + var app = Microsoft.Maui.Controls.Application.Current; if (app is null) return; _initialized = true; app.RequestedThemeChanged += (_, _) => Apply(); @@ -171,7 +171,7 @@ public static void EnsureInitialized() // Re-publishes the active palette. Call after editing Light/Dark at runtime. public static void Apply() { - var app = Application.Current; + var app = Microsoft.Maui.Controls.Application.Current; if (app is null) return; foreach (var (token, color) in IsDarkMode ? Dark : Light) { @@ -190,7 +190,7 @@ private static void ApplySystemBars(int retries) { // No activity yet (theme applied from App's constructor): try again shortly. if (retries > 0) - Application.Current?.Dispatcher.DispatchDelayed(TimeSpan.FromMilliseconds(200), () => ApplySystemBars(retries - 1)); + Microsoft.Maui.Controls.Application.Current?.Dispatcher.DispatchDelayed(TimeSpan.FromMilliseconds(200), () => ApplySystemBars(retries - 1)); return; } #pragma warning disable CA1422 // Android 15+ draws edge-to-edge and ignores these; older versions need them. @@ -207,7 +207,7 @@ private static void ApplySystemBars(int retries) public static void SetTheme(AppTheme theme) { - if (Application.Current is not { } app) return; + if (Microsoft.Maui.Controls.Application.Current is not { } app) return; EnsureInitialized(); app.UserAppTheme = theme; Apply(); @@ -478,7 +478,7 @@ private static void AddStatusBarScrim(Grid layer) if (e is ContentPage found) return found; // Not in a tree (e.g. Toast.Show from a view model): use the page on screen. - var page = Application.Current?.Windows.FirstOrDefault()?.Page; + var page = Microsoft.Maui.Controls.Application.Current?.Windows.FirstOrDefault()?.Page; while (true) { switch (page) @@ -779,7 +779,7 @@ public static bool DismissTop() private static void Hook() { - if (Application.Current is not { } app) return; + if (Microsoft.Maui.Controls.Application.Current is not { } app) return; foreach (var window in app.Windows) { if (window.Handler?.PlatformView is not Microsoft.UI.Xaml.Window { Content: { } root }) continue; @@ -1211,13 +1211,13 @@ public static class ShellTheme // The app the palettes were published to. A new Application (e.g. one per headless test) // gets them too. - private static Application? _initialized; + private static Avalonia.Application? _initialized; // Raised when a palette is published or the theme variant changes, for anything resources // can't reach (e.g. a BoxShadow). public static event EventHandler? ThemeChanged; - public static bool IsDarkMode => Application.Current?.ActualThemeVariant == ThemeVariant.Dark; + public static bool IsDarkMode => Avalonia.Application.Current?.ActualThemeVariant == ThemeVariant.Dark; public static string ColorKey(ShellToken token) => $""ShellUI{token}""; public static string BrushKey(ShellToken token) => $""ShellUI{token}Brush""; @@ -1237,7 +1237,7 @@ public static Color Get(ShellToken token, double opacity) public static void EnsureInitialized() { - var app = Application.Current; + var app = Avalonia.Application.Current; if (app is null || ReferenceEquals(app, _initialized)) return; _initialized = app; app.ActualThemeVariantChanged += (_, _) => ThemeChanged?.Invoke(null, EventArgs.Empty); @@ -1247,7 +1247,7 @@ public static void EnsureInitialized() // Re-publishes both palettes. Call after editing Light/Dark at runtime. public static void Apply() { - var app = Application.Current; + var app = Avalonia.Application.Current; if (app is null) return; app.Resources.ThemeDictionaries[ThemeVariant.Light] = Build(Light); app.Resources.ThemeDictionaries[ThemeVariant.Dark] = Build(Dark); @@ -1268,7 +1268,7 @@ private static ResourceDictionary Build(Dictionary palette) // ThemeVariant.Default follows the OS setting. public static void SetTheme(ThemeVariant theme) { - if (Application.Current is not { } app) return; + if (Avalonia.Application.Current is not { } app) return; EnsureInitialized(); app.RequestedThemeVariant = theme; } diff --git a/src/ShellUI.Native.Templates/Templates/ToastTemplate.cs b/src/ShellUI.Native.Templates/Templates/ToastTemplate.cs index 54c70d4..ac97c33 100644 --- a/src/ShellUI.Native.Templates/Templates/ToastTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/ToastTemplate.cs @@ -389,7 +389,7 @@ public static void Dismiss(string id) toaster.Remove(id); } - private static TopLevel? ActiveTopLevel() => Application.Current?.ApplicationLifetime switch + private static TopLevel? ActiveTopLevel() => Avalonia.Application.Current?.ApplicationLifetime switch { IClassicDesktopStyleApplicationLifetime desktop => desktop.Windows.FirstOrDefault(w => w.IsActive) ?? desktop.MainWindow, ISingleViewApplicationLifetime single when single.MainView is { } view => TopLevel.GetTopLevel(view), diff --git a/tests/ShellUI.Native.Tests/TemplateContentTests.cs b/tests/ShellUI.Native.Tests/TemplateContentTests.cs index 3f98ec3..4ee5797 100644 --- a/tests/ShellUI.Native.Tests/TemplateContentTests.cs +++ b/tests/ShellUI.Native.Tests/TemplateContentTests.cs @@ -267,4 +267,17 @@ public void MAUI_templates_name_the_Layout_type_in_full(string name) var content = ComponentRegistry.GetComponentContent(name, NativePlatform.MAUI)!; Assert.DoesNotMatch(@"(\b(is|as|case|new)\s+|typeof\(|[<(,]\s*)Layout\b(?![.\w])", content); } + + // An app with an .Application project (clean architecture) has that namespace, and a bare + // `Application` inside .App.Components.* resolves to it (CS0234), so templates name the type in full. + [Theory] + [MemberData(nameof(AllRegisteredComponents))] + public void Templates_name_the_Application_type_in_full(string name) + { + foreach (var platform in new[] { NativePlatform.MAUI, NativePlatform.Avalonia }) + { + if (ComponentRegistry.GetComponentContent(name, platform) is { } content) + Assert.DoesNotMatch(@"(? Date: Sun, 18 Oct 2026 09:35:50 +0200 Subject: [PATCH 2/4] fix(maui): keep focus in input, textarea and input-group on Android The focus glow was added on focus and cleared on blur. Adding or removing a Shadow on Android makes MAUI re-wrap the Border's native view (removeView + add), which detaches the focused EditText: focus jumps to the first focusable view, the keyboard closes and typing goes nowhere. The shadow is now always set and only its opacity changes (0 idle, 0.35 focused or in error). --- examples/MAUI.Demo/Components/UI/Input.cs | 21 +++++++------------ .../MAUI.Demo/Components/UI/InputGroup.cs | 21 +++++++------------ examples/MAUI.Demo/Components/UI/Textarea.cs | 21 +++++++------------ .../Templates/InputGroupTemplate.cs | 21 +++++++------------ .../Templates/InputTemplate.cs | 21 +++++++------------ .../Templates/TextareaTemplate.cs | 21 +++++++------------ .../TemplateContentTests.cs | 11 ++++++++++ 7 files changed, 59 insertions(+), 78 deletions(-) diff --git a/examples/MAUI.Demo/Components/UI/Input.cs b/examples/MAUI.Demo/Components/UI/Input.cs index 623345a..9ac84d5 100644 --- a/examples/MAUI.Demo/Components/UI/Input.cs +++ b/examples/MAUI.Demo/Components/UI/Input.cs @@ -144,20 +144,15 @@ private void UpdateVisualState() { var stroke = HasError ? ShellToken.Destructive : _isFocused ? ShellToken.Ring : ShellToken.Input; _border.Token(Border.StrokeProperty, stroke); - if (_isFocused || HasError) + // The glow is always set and only turns transparent: adding or removing a shadow on Android + // re-wraps the native view, which takes focus away from the field being typed in. + _border.Shadow = new Shadow { - _border.Shadow = new Shadow - { - Brush = new SolidColorBrush(ShellTheme.Get(HasError ? ShellToken.Destructive : ShellToken.Ring)), - Offset = new Point(0, 0), - Radius = 6, - Opacity = 0.35f - }; - } - else - { - _border.ClearValue(VisualElement.ShadowProperty); - } + Brush = new SolidColorBrush(ShellTheme.Get(HasError ? ShellToken.Destructive : ShellToken.Ring)), + Offset = new Point(0, 0), + Radius = 6, + Opacity = _isFocused || HasError ? 0.35f : 0f + }; } public new void Focus() => _entry.Focus(); diff --git a/examples/MAUI.Demo/Components/UI/InputGroup.cs b/examples/MAUI.Demo/Components/UI/InputGroup.cs index df854c9..2ef7fc1 100644 --- a/examples/MAUI.Demo/Components/UI/InputGroup.cs +++ b/examples/MAUI.Demo/Components/UI/InputGroup.cs @@ -230,19 +230,14 @@ private void UpdateVisualState() { var stroke = HasError ? ShellToken.Destructive : _isFocused ? ShellToken.Ring : ShellToken.Input; _border.Token(Border.StrokeProperty, stroke); - if (_isFocused || HasError) + // The glow is always set and only turns transparent: adding or removing a shadow on Android + // re-wraps the native view, which takes focus away from the field being typed in. + _border.Shadow = new Shadow { - _border.Shadow = new Shadow - { - Brush = new SolidColorBrush(ShellTheme.Get(HasError ? ShellToken.Destructive : ShellToken.Ring)), - Offset = new Point(0, 0), - Radius = 6, - Opacity = 0.35f - }; - } - else - { - _border.ClearValue(VisualElement.ShadowProperty); - } + Brush = new SolidColorBrush(ShellTheme.Get(HasError ? ShellToken.Destructive : ShellToken.Ring)), + Offset = new Point(0, 0), + Radius = 6, + Opacity = _isFocused || HasError ? 0.35f : 0f + }; } } diff --git a/examples/MAUI.Demo/Components/UI/Textarea.cs b/examples/MAUI.Demo/Components/UI/Textarea.cs index 3cd5220..f3740d8 100644 --- a/examples/MAUI.Demo/Components/UI/Textarea.cs +++ b/examples/MAUI.Demo/Components/UI/Textarea.cs @@ -92,19 +92,14 @@ private void UpdateVisualState() { var stroke = HasError ? ShellToken.Destructive : _isFocused ? ShellToken.Ring : ShellToken.Input; _border.Token(Border.StrokeProperty, stroke); - if (_isFocused || HasError) + // The glow is always set and only turns transparent: adding or removing a shadow on Android + // re-wraps the native view, which takes focus away from the field being typed in. + _border.Shadow = new Shadow { - _border.Shadow = new Shadow - { - Brush = new SolidColorBrush(ShellTheme.Get(HasError ? ShellToken.Destructive : ShellToken.Ring)), - Offset = new Point(0, 0), - Radius = 6, - Opacity = 0.35f - }; - } - else - { - _border.ClearValue(VisualElement.ShadowProperty); - } + Brush = new SolidColorBrush(ShellTheme.Get(HasError ? ShellToken.Destructive : ShellToken.Ring)), + Offset = new Point(0, 0), + Radius = 6, + Opacity = _isFocused || HasError ? 0.35f : 0f + }; } } diff --git a/src/ShellUI.Native.Templates/Templates/InputGroupTemplate.cs b/src/ShellUI.Native.Templates/Templates/InputGroupTemplate.cs index 5ea5214..c3485fe 100644 --- a/src/ShellUI.Native.Templates/Templates/InputGroupTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/InputGroupTemplate.cs @@ -249,20 +249,15 @@ private void UpdateVisualState() { var stroke = HasError ? ShellToken.Destructive : _isFocused ? ShellToken.Ring : ShellToken.Input; _border.Token(Border.StrokeProperty, stroke); - if (_isFocused || HasError) + // The glow is always set and only turns transparent: adding or removing a shadow on Android + // re-wraps the native view, which takes focus away from the field being typed in. + _border.Shadow = new Shadow { - _border.Shadow = new Shadow - { - Brush = new SolidColorBrush(ShellTheme.Get(HasError ? ShellToken.Destructive : ShellToken.Ring)), - Offset = new Point(0, 0), - Radius = 6, - Opacity = 0.35f - }; - } - else - { - _border.ClearValue(VisualElement.ShadowProperty); - } + Brush = new SolidColorBrush(ShellTheme.Get(HasError ? ShellToken.Destructive : ShellToken.Ring)), + Offset = new Point(0, 0), + Radius = 6, + Opacity = _isFocused || HasError ? 0.35f : 0f + }; } } ", diff --git a/src/ShellUI.Native.Templates/Templates/InputTemplate.cs b/src/ShellUI.Native.Templates/Templates/InputTemplate.cs index ee4e25c..e0ee924 100644 --- a/src/ShellUI.Native.Templates/Templates/InputTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/InputTemplate.cs @@ -165,20 +165,15 @@ private void UpdateVisualState() { var stroke = HasError ? ShellToken.Destructive : _isFocused ? ShellToken.Ring : ShellToken.Input; _border.Token(Border.StrokeProperty, stroke); - if (_isFocused || HasError) + // The glow is always set and only turns transparent: adding or removing a shadow on Android + // re-wraps the native view, which takes focus away from the field being typed in. + _border.Shadow = new Shadow { - _border.Shadow = new Shadow - { - Brush = new SolidColorBrush(ShellTheme.Get(HasError ? ShellToken.Destructive : ShellToken.Ring)), - Offset = new Point(0, 0), - Radius = 6, - Opacity = 0.35f - }; - } - else - { - _border.ClearValue(VisualElement.ShadowProperty); - } + Brush = new SolidColorBrush(ShellTheme.Get(HasError ? ShellToken.Destructive : ShellToken.Ring)), + Offset = new Point(0, 0), + Radius = 6, + Opacity = _isFocused || HasError ? 0.35f : 0f + }; } public new void Focus() => _entry.Focus(); diff --git a/src/ShellUI.Native.Templates/Templates/TextareaTemplate.cs b/src/ShellUI.Native.Templates/Templates/TextareaTemplate.cs index 62ff7f7..711f54e 100644 --- a/src/ShellUI.Native.Templates/Templates/TextareaTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/TextareaTemplate.cs @@ -111,20 +111,15 @@ private void UpdateVisualState() { var stroke = HasError ? ShellToken.Destructive : _isFocused ? ShellToken.Ring : ShellToken.Input; _border.Token(Border.StrokeProperty, stroke); - if (_isFocused || HasError) + // The glow is always set and only turns transparent: adding or removing a shadow on Android + // re-wraps the native view, which takes focus away from the field being typed in. + _border.Shadow = new Shadow { - _border.Shadow = new Shadow - { - Brush = new SolidColorBrush(ShellTheme.Get(HasError ? ShellToken.Destructive : ShellToken.Ring)), - Offset = new Point(0, 0), - Radius = 6, - Opacity = 0.35f - }; - } - else - { - _border.ClearValue(VisualElement.ShadowProperty); - } + Brush = new SolidColorBrush(ShellTheme.Get(HasError ? ShellToken.Destructive : ShellToken.Ring)), + Offset = new Point(0, 0), + Radius = 6, + Opacity = _isFocused || HasError ? 0.35f : 0f + }; } } ", diff --git a/tests/ShellUI.Native.Tests/TemplateContentTests.cs b/tests/ShellUI.Native.Tests/TemplateContentTests.cs index 4ee5797..55031d2 100644 --- a/tests/ShellUI.Native.Tests/TemplateContentTests.cs +++ b/tests/ShellUI.Native.Tests/TemplateContentTests.cs @@ -268,6 +268,17 @@ public void MAUI_templates_name_the_Layout_type_in_full(string name) Assert.DoesNotMatch(@"(\b(is|as|case|new)\s+|typeof\(|[<(,]\s*)Layout\b(?![.\w])", content); } + // Adding or removing a Shadow on Android re-wraps the native view, which takes focus from the field. + [Theory] + [InlineData("input")] + [InlineData("textarea")] + [InlineData("input-group")] + public void MAUI_text_fields_keep_their_focus_shadow(string name) + { + var content = ComponentRegistry.GetComponentContent(name, NativePlatform.MAUI)!; + Assert.DoesNotContain("ClearValue(VisualElement.ShadowProperty)", content); + } + // An app with an .Application project (clean architecture) has that namespace, and a bare // `Application` inside .App.Components.* resolves to it (CS0234), so templates name the type in full. [Theory] From c81357a38a2f99e803bc89350abdb96f54e0d7e4 Mon Sep 17 00:00:00 2001 From: Shewart Date: Sun, 18 Oct 2026 09:36:50 +0200 Subject: [PATCH 3/4] fix(maui): theme the system bars of a new Android activity With ShellTheme.SyncSystemBars, backing out of the app at the root and reopening it creates a new activity whose bars were never themed (black, page no longer edge to edge), so apps needed `window.Created += ShellTheme.Apply()`. The shell now applies the bars again whenever a page appears. --- docs/COMPONENTS.md | 1 + examples/MAUI.Demo/Components/UI/Shell.cs | 7 ++++++- src/ShellUI.Native.Templates/Templates/ShellTemplate.cs | 7 ++++++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index 532c06d..15a16df 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -61,6 +61,7 @@ In code, bind any Color/Brush property to a token with the `Token` extension: `EnsureInitialized()` to make the status and navigation bars follow the theme: the bars turn transparent over the edge-to-edge page, the page layer draws a `Background`-colored strip behind the status bar (so dialog backdrops and sheets cover it), and the bar icons flip with light/dark. +A new activity (the app reopened after Back) gets the same bars when its first page appears. Also set `colorPrimary` / `colorPrimaryDark` in `Platforms/Android/Resources/values/colors.xml` to your background so the splash-to-app transition doesn't flash the template's purple. diff --git a/examples/MAUI.Demo/Components/UI/Shell.cs b/examples/MAUI.Demo/Components/UI/Shell.cs index 99ae72a..22521c3 100644 --- a/examples/MAUI.Demo/Components/UI/Shell.cs +++ b/examples/MAUI.Demo/Components/UI/Shell.cs @@ -143,7 +143,12 @@ public static void EnsureInitialized() app.RequestedThemeChanged += (_, _) => Apply(); // Set up each page's overlay layer as it appears. Doing it on first use instead would // re-parent the page content under the user's finger (Android drops the scroll position). - app.PageAppearing += (_, page) => page.Dispatcher.Dispatch(() => ShellPortal.GetLayer(page)); + // Android starts a new activity, with unthemed bars, when the app is reopened after Back. + app.PageAppearing += (_, page) => page.Dispatcher.Dispatch(() => + { + ShellPortal.GetLayer(page); + if (SyncSystemBars) ApplySystemBars(retries: 0); + }); Apply(); } diff --git a/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs b/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs index 1db9875..fb44769 100644 --- a/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs @@ -164,7 +164,12 @@ public static void EnsureInitialized() app.RequestedThemeChanged += (_, _) => Apply(); // Set up each page's overlay layer as it appears. Doing it on first use instead would // re-parent the page content under the user's finger (Android drops the scroll position). - app.PageAppearing += (_, page) => page.Dispatcher.Dispatch(() => ShellPortal.GetLayer(page)); + // Android starts a new activity, with unthemed bars, when the app is reopened after Back. + app.PageAppearing += (_, page) => page.Dispatcher.Dispatch(() => + { + ShellPortal.GetLayer(page); + if (SyncSystemBars) ApplySystemBars(retries: 0); + }); Apply(); } From c3c76e3336162c88a90db60c6a0fa3b6a51690a0 Mon Sep 17 00:00:00 2001 From: Shewart Date: Sun, 18 Oct 2026 09:38:02 +0200 Subject: [PATCH 4/4] fix(maui): keep transforms on Android's wrapper view Android wraps an InputTransparent (or shadowed / clipped) view in a WrapperView after its handler has applied the view's translation, scale and rotation to the view itself. Later changes move only the wrapper, so the inner view keeps the first values for good: - theme-toggle: the moon stayed at half size and turned after switching to dark (its hidden state when the handler was created); - bottom-nav: the Default indicator sat about 25 dp right of the active item after the handler was re-created with the page kept (a new activity), and stayed offset on every later switch. ShellPlatform.KeepTransformsOnWrapper (Android only) resets the inner view when the handler connects and re-applies the transforms to the wrapper. Icon uses it, which also covers chevrons and spinners, and so do bottom-nav's indicator, glyph, circle and icon host. --- examples/MAUI.Demo/Components/UI/BottomNav.cs | 4 +++ examples/MAUI.Demo/Components/UI/Icon.cs | 1 + examples/MAUI.Demo/Components/UI/Shell.cs | 26 +++++++++++++++++++ .../Templates/BottomNavTemplate.cs | 4 +++ .../Templates/IconTemplate.cs | 1 + .../Templates/ShellTemplate.cs | 26 +++++++++++++++++++ 6 files changed, 62 insertions(+) diff --git a/examples/MAUI.Demo/Components/UI/BottomNav.cs b/examples/MAUI.Demo/Components/UI/BottomNav.cs index 2cae79e..4280c0e 100644 --- a/examples/MAUI.Demo/Components/UI/BottomNav.cs +++ b/examples/MAUI.Demo/Components/UI/BottomNav.cs @@ -144,6 +144,7 @@ public bool IsHidden public BottomNav() { _indicator = new Border { StrokeThickness = 0, Opacity = 0, InputTransparent = true, HorizontalOptions = LayoutOptions.Start }; + ShellPlatform.KeepTransformsOnWrapper(_indicator); _layer.Children.Add(_indicator); _layer.Children.Add(_row); @@ -552,6 +553,9 @@ public BottomNavItem() _badge.Token(VisualElement.BackgroundColorProperty, ShellToken.Destructive); _badge.Token(Border.StrokeProperty, ShellToken.Background); _iconHost = new Grid { Children = { _pill, _circle, _glyph, _badge }, InputTransparent = true }; + ShellPlatform.KeepTransformsOnWrapper(_glyph); + ShellPlatform.KeepTransformsOnWrapper(_circle); + ShellPlatform.KeepTransformsOnWrapper(_iconHost); _label = new Label { diff --git a/examples/MAUI.Demo/Components/UI/Icon.cs b/examples/MAUI.Demo/Components/UI/Icon.cs index e127a6f..1931402 100644 --- a/examples/MAUI.Demo/Components/UI/Icon.cs +++ b/examples/MAUI.Demo/Components/UI/Icon.cs @@ -310,6 +310,7 @@ public Icon() HorizontalOptions = LayoutOptions.Center; VerticalOptions = LayoutOptions.Center; InputTransparent = true; + ShellPlatform.KeepTransformsOnWrapper(this); UpdateGeometry(); UpdateColor(); } diff --git a/examples/MAUI.Demo/Components/UI/Shell.cs b/examples/MAUI.Demo/Components/UI/Shell.cs index 22521c3..5c72397 100644 --- a/examples/MAUI.Demo/Components/UI/Shell.cs +++ b/examples/MAUI.Demo/Components/UI/Shell.cs @@ -224,6 +224,32 @@ public static void StripNativeChrome(View view, bool keepPadding = false) Strip(view, keepPadding); } + /* Android wraps a view (InputTransparent, Shadow, Clip) after its handler has applied the + view's translation, scale and rotation to the view itself. Later changes move only the + wrapper, so the inner view keeps the first values: an icon stuck at half size, an indicator + offset by where it first was. Call once for a view that is moved, scaled or turned. */ + public static void KeepTransformsOnWrapper(View view) + { +#if ANDROID + view.HandlerChanged += (_, _) => + { + if (view.Handler is not { ContainerView: not null, PlatformView: Android.Views.View inner } handler) return; + inner.TranslationX = inner.TranslationY = 0; + inner.ScaleX = inner.ScaleY = 1; + inner.Rotation = inner.RotationX = inner.RotationY = 0; + foreach (var property in Transforms) handler.UpdateValue(property); + }; +#endif + } + +#if ANDROID + private static readonly string[] Transforms = + [ + nameof(IView.TranslationX), nameof(IView.TranslationY), nameof(IView.Scale), nameof(IView.ScaleX), nameof(IView.ScaleY), + nameof(IView.Rotation), nameof(IView.RotationX), nameof(IView.RotationY), nameof(IView.AnchorX), nameof(IView.AnchorY) + ]; +#endif + private static void Strip(View view, bool keepPadding) { #if WINDOWS diff --git a/src/ShellUI.Native.Templates/Templates/BottomNavTemplate.cs b/src/ShellUI.Native.Templates/Templates/BottomNavTemplate.cs index 7924604..86bc018 100644 --- a/src/ShellUI.Native.Templates/Templates/BottomNavTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/BottomNavTemplate.cs @@ -163,6 +163,7 @@ public bool IsHidden public BottomNav() { _indicator = new Border { StrokeThickness = 0, Opacity = 0, InputTransparent = true, HorizontalOptions = LayoutOptions.Start }; + ShellPlatform.KeepTransformsOnWrapper(_indicator); _layer.Children.Add(_indicator); _layer.Children.Add(_row); @@ -571,6 +572,9 @@ public BottomNavItem() _badge.Token(VisualElement.BackgroundColorProperty, ShellToken.Destructive); _badge.Token(Border.StrokeProperty, ShellToken.Background); _iconHost = new Grid { Children = { _pill, _circle, _glyph, _badge }, InputTransparent = true }; + ShellPlatform.KeepTransformsOnWrapper(_glyph); + ShellPlatform.KeepTransformsOnWrapper(_circle); + ShellPlatform.KeepTransformsOnWrapper(_iconHost); _label = new Label { diff --git a/src/ShellUI.Native.Templates/Templates/IconTemplate.cs b/src/ShellUI.Native.Templates/Templates/IconTemplate.cs index a7d2c93..5132915 100644 --- a/src/ShellUI.Native.Templates/Templates/IconTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/IconTemplate.cs @@ -329,6 +329,7 @@ public Icon() HorizontalOptions = LayoutOptions.Center; VerticalOptions = LayoutOptions.Center; InputTransparent = true; + ShellPlatform.KeepTransformsOnWrapper(this); UpdateGeometry(); UpdateColor(); } diff --git a/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs b/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs index fb44769..ca46d59 100644 --- a/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs @@ -245,6 +245,32 @@ public static void StripNativeChrome(View view, bool keepPadding = false) Strip(view, keepPadding); } + /* Android wraps a view (InputTransparent, Shadow, Clip) after its handler has applied the + view's translation, scale and rotation to the view itself. Later changes move only the + wrapper, so the inner view keeps the first values: an icon stuck at half size, an indicator + offset by where it first was. Call once for a view that is moved, scaled or turned. */ + public static void KeepTransformsOnWrapper(View view) + { +#if ANDROID + view.HandlerChanged += (_, _) => + { + if (view.Handler is not { ContainerView: not null, PlatformView: Android.Views.View inner } handler) return; + inner.TranslationX = inner.TranslationY = 0; + inner.ScaleX = inner.ScaleY = 1; + inner.Rotation = inner.RotationX = inner.RotationY = 0; + foreach (var property in Transforms) handler.UpdateValue(property); + }; +#endif + } + +#if ANDROID + private static readonly string[] Transforms = + [ + nameof(IView.TranslationX), nameof(IView.TranslationY), nameof(IView.Scale), nameof(IView.ScaleX), nameof(IView.ScaleY), + nameof(IView.Rotation), nameof(IView.RotationX), nameof(IView.RotationY), nameof(IView.AnchorX), nameof(IView.AnchorY) + ]; +#endif + private static void Strip(View view, bool keepPadding) { #if WINDOWS