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/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/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/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/Shell.cs b/examples/MAUI.Demo/Components/UI/Shell.cs index e7136d0..5c72397 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,20 +137,25 @@ 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(); // 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(); } // 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 +174,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 +191,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(); @@ -219,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 @@ -457,7 +488,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 +789,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/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/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/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/ShellTemplate.cs b/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs index c47d83d..ca46d59 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,20 +158,25 @@ 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(); // 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(); } // 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 +195,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 +212,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(); @@ -240,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 @@ -478,7 +509,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 +810,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 +1242,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 +1268,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 +1278,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 +1299,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/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/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..55031d2 100644 --- a/tests/ShellUI.Native.Tests/TemplateContentTests.cs +++ b/tests/ShellUI.Native.Tests/TemplateContentTests.cs @@ -267,4 +267,28 @@ 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); } + + // 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] + [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(@"(?