From 61ce485cb70cfd1297f5a4b8946e63dd00b43e10 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:39:32 +0200 Subject: [PATCH 01/22] Add SwipeItem.IconColor to make swipe item icon tinting explicit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .NET 10 SR9 shipped #35632, which stopped tinting SwipeItem icons with a color derived from the item background and instead let image icons render with their own colors. That fixed #23074, but it also silently changed how existing apps look: an icon that used to be recolored to contrast the background now keeps its authored color, which is how #36766 ended up with a dark icon on a dark background in dark mode. The behavior itself is right — it matches the authored asset and it is what users asked for — but it is not something that should arrive unannounced in a servicing release, so #36883 reverts it on the servicing line. This change keeps the new behavior for .NET 11 and closes the gap that made the old implicit tint necessary in the first place, by making the tint configurable instead of inferred. SwipeItem.IconColor is a new bindable property: - unset (default): a FontImageSource uses its own Color, falling back to a color contrasting the item background, and image icons such as PNG and SVG render with their original colors. This is exactly the .NET 11 behavior today, so nothing changes for apps that do not opt in. - set: the color tints every icon type, and because it is a bindable property it can be driven by an AppThemeBinding to follow the current theme — the scenario #36766 was really after. All three platforms now resolve the tint through a single shared helper, ISwipeItemMenuItem.GetIconTintColor(), so the rule is defined once instead of being reimplemented per handler: - Android applies it as a SrcAtop color filter, and now clears the filter when there is no tint. Drawables can be cached and reused, so an earlier tint would otherwise be left stale. - iOS renders the image as a template only when there is a tint, and keeps AlwaysOriginal otherwise. - Windows routes tinted icons through ToIconSource(), which produces a BitmapIconSource/FontIconSource that honors Foreground. ImageIconSource ignores Foreground, so it is still used for the untinted path. IconColor changes are mapped by reloading the source, which is what re-evaluates the tint and guarantees a previous tint is cleared rather than left behind. ISwipeItemMenuItem.IconColor is a default interface member so existing implementers keep compiling, guarded with #if NETSTANDARD2_0 like IRefreshView.IsRefreshEnabled, since netstandard2.0 cannot express one. --- .../net-android/PublicAPI.Unshipped.txt | 3 + .../PublicAPI/net-ios/PublicAPI.Unshipped.txt | 3 + .../net-maccatalyst/PublicAPI.Unshipped.txt | 3 + .../net-tizen/PublicAPI.Unshipped.txt | 3 + .../net-windows/PublicAPI.Unshipped.txt | 3 + .../PublicAPI/net/PublicAPI.Unshipped.txt | 3 + .../netstandard/PublicAPI.Unshipped.txt | 3 + src/Controls/src/Core/SwipeView/SwipeItem.cs | 26 +++++++ .../tests/Core.UnitTests/SwipeViewTests.cs | 71 +++++++++++++++++++ src/Core/src/Core/ISwipeItemMenuItem.cs | 14 ++++ .../SwipeItemMenuItemHandler.Android.cs | 23 +++--- .../SwipeItemMenuItemHandler.Windows.cs | 17 +++++ .../SwipeItemMenuItemHandler.cs | 7 ++ .../SwipeItemMenuItemHandler.iOS.cs | 30 ++------ src/Core/src/Platform/SwipeViewExtensions.cs | 18 +++++ .../net-android/PublicAPI.Unshipped.txt | 2 + .../PublicAPI/net-ios/PublicAPI.Unshipped.txt | 2 + .../net-maccatalyst/PublicAPI.Unshipped.txt | 2 + .../net-tizen/PublicAPI.Unshipped.txt | 2 + .../net-windows/PublicAPI.Unshipped.txt | 4 +- .../src/PublicAPI/net/PublicAPI.Unshipped.txt | 2 + .../netstandard/PublicAPI.Unshipped.txt | 2 + .../netstandard2.0/PublicAPI.Unshipped.txt | 2 + 23 files changed, 206 insertions(+), 39 deletions(-) diff --git a/src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt index 1a9ac9b2f833..f48654745c1f 100644 --- a/src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt @@ -241,3 +241,6 @@ override Microsoft.Maui.Controls.Handlers.Items.MauiRecyclerView.OnVisibilityChanged(Android.Views.View changedView, Android.Views.ViewStates visibility) -> void ~override Microsoft.Maui.Controls.SwipeItems.OnPropertyChanged(string propertyName = null) -> void Microsoft.Maui.Controls.Label.~Label() -> void +~Microsoft.Maui.Controls.SwipeItem.IconColor.get -> Microsoft.Maui.Graphics.Color +~Microsoft.Maui.Controls.SwipeItem.IconColor.set -> void +~static readonly Microsoft.Maui.Controls.SwipeItem.IconColorProperty -> Microsoft.Maui.Controls.BindableProperty diff --git a/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt index 95f16fc091bb..1ef35e66526f 100644 --- a/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt @@ -154,3 +154,6 @@ Microsoft.Maui.Controls.Xaml.Diagnostics.HotReloadSkippedEventArgs.Timestamp.get Microsoft.Maui.Controls.Xaml.Diagnostics.HotReloadSkippedEventArgs.UpdatedTypes.get -> System.Collections.Generic.IReadOnlyList! ~override Microsoft.Maui.Controls.SwipeItems.OnPropertyChanged(string propertyName = null) -> void Microsoft.Maui.Controls.Label.~Label() -> void +~Microsoft.Maui.Controls.SwipeItem.IconColor.get -> Microsoft.Maui.Graphics.Color +~Microsoft.Maui.Controls.SwipeItem.IconColor.set -> void +~static readonly Microsoft.Maui.Controls.SwipeItem.IconColorProperty -> Microsoft.Maui.Controls.BindableProperty diff --git a/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt index 95f16fc091bb..1ef35e66526f 100644 --- a/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt @@ -154,3 +154,6 @@ Microsoft.Maui.Controls.Xaml.Diagnostics.HotReloadSkippedEventArgs.Timestamp.get Microsoft.Maui.Controls.Xaml.Diagnostics.HotReloadSkippedEventArgs.UpdatedTypes.get -> System.Collections.Generic.IReadOnlyList! ~override Microsoft.Maui.Controls.SwipeItems.OnPropertyChanged(string propertyName = null) -> void Microsoft.Maui.Controls.Label.~Label() -> void +~Microsoft.Maui.Controls.SwipeItem.IconColor.get -> Microsoft.Maui.Graphics.Color +~Microsoft.Maui.Controls.SwipeItem.IconColor.set -> void +~static readonly Microsoft.Maui.Controls.SwipeItem.IconColorProperty -> Microsoft.Maui.Controls.BindableProperty diff --git a/src/Controls/src/Core/PublicAPI/net-tizen/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net-tizen/PublicAPI.Unshipped.txt index 1c5cd4c15333..62a3c9fc98b3 100644 --- a/src/Controls/src/Core/PublicAPI/net-tizen/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-tizen/PublicAPI.Unshipped.txt @@ -149,3 +149,6 @@ Microsoft.Maui.Controls.Xaml.Diagnostics.HotReloadSkippedEventArgs.Timestamp.get Microsoft.Maui.Controls.Xaml.Diagnostics.HotReloadSkippedEventArgs.UpdatedTypes.get -> System.Collections.Generic.IReadOnlyList! ~override Microsoft.Maui.Controls.SwipeItems.OnPropertyChanged(string propertyName = null) -> void Microsoft.Maui.Controls.Label.~Label() -> void +~Microsoft.Maui.Controls.SwipeItem.IconColor.get -> Microsoft.Maui.Graphics.Color +~Microsoft.Maui.Controls.SwipeItem.IconColor.set -> void +~static readonly Microsoft.Maui.Controls.SwipeItem.IconColorProperty -> Microsoft.Maui.Controls.BindableProperty diff --git a/src/Controls/src/Core/PublicAPI/net-windows/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net-windows/PublicAPI.Unshipped.txt index d176ba63fa29..aba9c49f370f 100644 --- a/src/Controls/src/Core/PublicAPI/net-windows/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-windows/PublicAPI.Unshipped.txt @@ -197,3 +197,6 @@ Microsoft.Maui.Controls.Xaml.Diagnostics.HotReloadSkippedEventArgs.UpdatedTypes. override Microsoft.Maui.Controls.Handlers.Items.CarouselViewHandler.UpdateEmptyViewVisibility() -> void ~override Microsoft.Maui.Controls.SwipeItems.OnPropertyChanged(string propertyName = null) -> void Microsoft.Maui.Controls.Label.~Label() -> void +~Microsoft.Maui.Controls.SwipeItem.IconColor.get -> Microsoft.Maui.Graphics.Color +~Microsoft.Maui.Controls.SwipeItem.IconColor.set -> void +~static readonly Microsoft.Maui.Controls.SwipeItem.IconColorProperty -> Microsoft.Maui.Controls.BindableProperty diff --git a/src/Controls/src/Core/PublicAPI/net/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net/PublicAPI.Unshipped.txt index be3b56b2ed46..30becea3fdaf 100644 --- a/src/Controls/src/Core/PublicAPI/net/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net/PublicAPI.Unshipped.txt @@ -144,3 +144,6 @@ virtual Microsoft.Maui.Controls.LongPressedEventArgs.GetPosition(Microsoft.Maui. virtual Microsoft.Maui.Controls.LongPressingEventArgs.GetPosition(Microsoft.Maui.Controls.Element? relativeTo) -> Microsoft.Maui.Graphics.Point? ~override Microsoft.Maui.Controls.SwipeItems.OnPropertyChanged(string propertyName = null) -> void Microsoft.Maui.Controls.Label.~Label() -> void +~Microsoft.Maui.Controls.SwipeItem.IconColor.get -> Microsoft.Maui.Graphics.Color +~Microsoft.Maui.Controls.SwipeItem.IconColor.set -> void +~static readonly Microsoft.Maui.Controls.SwipeItem.IconColorProperty -> Microsoft.Maui.Controls.BindableProperty diff --git a/src/Controls/src/Core/PublicAPI/netstandard/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/netstandard/PublicAPI.Unshipped.txt index e7cef5dc3914..2c58c6e87161 100644 --- a/src/Controls/src/Core/PublicAPI/netstandard/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/netstandard/PublicAPI.Unshipped.txt @@ -135,3 +135,6 @@ virtual Microsoft.Maui.Controls.LongPressedEventArgs.GetPosition(Microsoft.Maui. virtual Microsoft.Maui.Controls.LongPressingEventArgs.GetPosition(Microsoft.Maui.Controls.Element? relativeTo) -> Microsoft.Maui.Graphics.Point? ~override Microsoft.Maui.Controls.SwipeItems.OnPropertyChanged(string propertyName = null) -> void Microsoft.Maui.Controls.Label.~Label() -> void +~Microsoft.Maui.Controls.SwipeItem.IconColor.get -> Microsoft.Maui.Graphics.Color +~Microsoft.Maui.Controls.SwipeItem.IconColor.set -> void +~static readonly Microsoft.Maui.Controls.SwipeItem.IconColorProperty -> Microsoft.Maui.Controls.BindableProperty diff --git a/src/Controls/src/Core/SwipeView/SwipeItem.cs b/src/Controls/src/Core/SwipeView/SwipeItem.cs index 8505094c9eb6..0400aac84c9c 100644 --- a/src/Controls/src/Core/SwipeView/SwipeItem.cs +++ b/src/Controls/src/Core/SwipeView/SwipeItem.cs @@ -16,6 +16,9 @@ public partial class SwipeItem : MenuItem, Controls.ISwipeItem, Maui.ISwipeItemM /// Bindable property for . public static readonly BindableProperty IsVisibleProperty = BindableProperty.Create(nameof(IsVisible), typeof(bool), typeof(SwipeItem), true, propertyChanged: OnIsVisibleChanged); + /// Bindable property for . + public static readonly BindableProperty IconColorProperty = BindableProperty.Create(nameof(IconColor), typeof(Color), typeof(SwipeItem), null, propertyChanged: OnIconColorChanged); + /// /// Gets or sets the background color of the swipe item. This is a bindable property. /// @@ -36,10 +39,33 @@ public bool IsVisible public event EventHandler Invoked; + /// + /// Gets or sets the color used to tint . This is a bindable property. + /// + /// + /// When unset, a uses its own and falls + /// back to a color contrasting , while image icons such as PNG and SVG render + /// with their original colors. Setting this property tints every icon type, and it can be bound with + /// to follow the current theme. + /// + public Color IconColor + { + get { return (Color)GetValue(IconColorProperty); } + set { SetValue(IconColorProperty, value); } + } + Paint ISwipeItemMenuItem.Background => new SolidPaint(BackgroundColor); + Color ISwipeItemMenuItem.IconColor => IconColor; + Visibility ISwipeItemMenuItem.Visibility => this.IsVisible ? Visibility.Visible : Visibility.Collapsed; + static void OnIconColorChanged(BindableObject bindable, object oldValue, object newValue) + { + var swipeItem = (SwipeItem)bindable; + swipeItem.Handler?.UpdateValue(nameof(ISwipeItemMenuItem.IconColor)); + } + static void OnIsVisibleChanged(BindableObject bindable, object oldValue, object newValue) { var swipeItem = (SwipeItem)bindable; diff --git a/src/Controls/tests/Core.UnitTests/SwipeViewTests.cs b/src/Controls/tests/Core.UnitTests/SwipeViewTests.cs index 71f3a5fb1b18..5d325bc4b9ba 100644 --- a/src/Controls/tests/Core.UnitTests/SwipeViewTests.cs +++ b/src/Controls/tests/Core.UnitTests/SwipeViewTests.cs @@ -683,6 +683,77 @@ static WeakReference CreateSwipeViewAssignThenReplace(SwipeItems cachedRightItem return new WeakReference(sv); } + [Fact] + public void SwipeItemIconColorDefaultsToNull() + { + Assert.Null(new SwipeItem().IconColor); + } + + [Fact] + public void ImageIconKeepsItsOwnColorsWhenIconColorIsNotSet() + { + var swipeItem = new SwipeItem + { + BackgroundColor = Colors.White, + IconImageSource = "icon.png" + }; + + Assert.Null(((ISwipeItemMenuItem)swipeItem).GetIconTintColor()); + } + + [Fact] + public void ImageIconIsTintedWhenIconColorIsSet() + { + var swipeItem = new SwipeItem + { + BackgroundColor = Colors.White, + IconImageSource = "icon.png", + IconColor = Colors.Red + }; + + Assert.Equal(Colors.Red, ((ISwipeItemMenuItem)swipeItem).GetIconTintColor()); + } + + [Fact] + public void FontIconUsesItsOwnColorWhenIconColorIsNotSet() + { + var swipeItem = new SwipeItem + { + BackgroundColor = Colors.White, + IconImageSource = new FontImageSource { Glyph = "A", Color = Colors.Green } + }; + + Assert.Equal(Colors.Green, ((ISwipeItemMenuItem)swipeItem).GetIconTintColor()); + } + + [Theory] + // A dark background needs a light glyph, a light background needs a dark one. + [InlineData(false, "#000000")] + [InlineData(true, "#FFFFFF")] + public void ColorlessFontIconContrastsWithTheBackground(bool darkBackground, string expected) + { + var swipeItem = new SwipeItem + { + BackgroundColor = darkBackground ? Colors.Black : Colors.White, + IconImageSource = new FontImageSource { Glyph = "A" } + }; + + Assert.Equal(Color.FromArgb(expected), ((ISwipeItemMenuItem)swipeItem).GetIconTintColor()); + } + + [Fact] + public void IconColorOverridesTheFontImageSourceColor() + { + var swipeItem = new SwipeItem + { + BackgroundColor = Colors.White, + IconImageSource = new FontImageSource { Glyph = "A", Color = Colors.Green }, + IconColor = Colors.Red + }; + + Assert.Equal(Colors.Red, ((ISwipeItemMenuItem)swipeItem).GetIconTintColor()); + } + static void ForceFullGC() { for (int i = 0; i < 5; i++) diff --git a/src/Core/src/Core/ISwipeItemMenuItem.cs b/src/Core/src/Core/ISwipeItemMenuItem.cs index 4428d7a6b4a7..76277c08647f 100644 --- a/src/Core/src/Core/ISwipeItemMenuItem.cs +++ b/src/Core/src/Core/ISwipeItemMenuItem.cs @@ -13,5 +13,19 @@ public interface ISwipeItemMenuItem : IMenuElement, ISwipeItem /// Gets a value that determines whether this View should be part of the visual tree or not. /// Visibility Visibility { get; } + + /// + /// Gets the color used to tint the icon specified by . + /// + /// + /// When , a font icon is tinted with its own color (falling back to a + /// color contrasting the item background), and image icons render with their original colors. + /// When set, the color is applied to every icon type. + /// +#if NETSTANDARD2_0 + Color? IconColor { get; } +#else + Color? IconColor => null; +#endif } } diff --git a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Android.cs b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Android.cs index a9e32df9821a..60b808850511 100644 --- a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Android.cs +++ b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Android.cs @@ -184,21 +184,16 @@ public override void SetImageSource(Drawable? platformImage) platformImage.SetBounds(0, 0, iconWidth, iconHeight); } - if (item.Source is IFontImageSource fontImageSource) + var tintColor = item.GetIconTintColor()?.ToPlatform(); + + if (tintColor is not null) + { + platformImage.SetColorFilter(tintColor.Value, FilterMode.SrcAtop); + } + else { - if (fontImageSource.Color is not null) - { - platformImage.SetColorFilter(fontImageSource.Color.ToPlatform(), FilterMode.SrcAtop); - } - else - { - var textColor = item.GetTextColor()?.ToPlatform(); - - if (textColor is not null) - { - platformImage.SetColorFilter(textColor.Value, FilterMode.SrcAtop); - } - } + // Drawables can be cached and reused, so an earlier tint has to be cleared explicitly. + platformImage.ClearColorFilter(); } } diff --git a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Windows.cs b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Windows.cs index cee48aa05a7c..f99789b9810e 100644 --- a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Windows.cs +++ b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Windows.cs @@ -65,6 +65,23 @@ internal static async Task LoadFileIconAsync(ISwipeItemMenuItemHandler handler, return; } + // ImageIconSource renders the image as-is and ignores Foreground, so an explicit tint has to go + // through BitmapIconSource/FontIconSource instead, which honor Foreground (BitmapIconSource + // draws the bitmap as a monochrome mask by default). + var tintColor = item.GetIconTintColor(); + + if (tintColor is not null) + { + var tintedIconSource = item.Source.ToIconSource(handler.MauiContext); + + if (tintedIconSource is not null) + { + tintedIconSource.Foreground = tintColor.ToPlatform(); + swipeItem.IconSource = tintedIconSource; + return; + } + } + var imageSourceServiceProvider = handler.MauiContext.Services.GetRequiredService(); var scale = handler.MauiContext.GetOptionalPlatformWindow()?.GetDisplayDensity() ?? 1.0f; var source = item.Source; diff --git a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.cs b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.cs index ef7d53e3d85f..763b2422c0d9 100644 --- a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.cs +++ b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.cs @@ -26,6 +26,7 @@ public partial class SwipeItemMenuItemHandler : ISwipeItemMenuItemHandler [nameof(ITextStyle.CharacterSpacing)] = MapCharacterSpacing, [nameof(ITextStyle.Font)] = MapFont, [nameof(IMenuElement.Source)] = MapSource, + [nameof(ISwipeItemMenuItem.IconColor)] = MapIconColor, }; public static CommandMapper CommandMapper = @@ -61,6 +62,12 @@ protected SwipeItemMenuItemHandler(IPropertyMapper? mapper, CommandMapper? comma public static void MapSource(ISwipeItemMenuItemHandler handler, ISwipeItemMenuItem image) => MapSourceAsync(handler, image).FireAndForget(handler); + // The tint is resolved while the icon is being applied to the platform view, so reloading the + // source is what re-evaluates it. This also guarantees a previously applied tint is cleared + // rather than left stale when IconColor changes (for example on an app theme switch). + public static void MapIconColor(ISwipeItemMenuItemHandler handler, ISwipeItemMenuItem view) => + MapSource(handler, view); + public static Task MapSourceAsync(ISwipeItemMenuItemHandler handler, ISwipeItemMenuItem image) { #if WINDOWS diff --git a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.iOS.cs b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.iOS.cs index e20a6cceca24..87383b816714 100644 --- a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.iOS.cs +++ b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.iOS.cs @@ -119,31 +119,13 @@ public override void SetImageSource(UIImage? platformImage) try { - // Font glyphs are single-color vectors so template rendering + tint makes sense. - // Regular raster images should use AlwaysOriginal to preserve their own colors. - var fontImageSource = item.Source as IFontImageSource; - var renderingMode = fontImageSource is not null ? UIImageRenderingMode.AlwaysTemplate : UIImageRenderingMode.AlwaysOriginal; - button.SetImage(resizedImage.ImageWithRenderingMode(renderingMode), UIControlState.Normal); + // A tinted icon has to be rendered as a template so the tint actually takes effect. + // Without a tint the image keeps its own colors via AlwaysOriginal. + var tintColor = item.GetIconTintColor(); + var renderingMode = tintColor is not null ? UIImageRenderingMode.AlwaysTemplate : UIImageRenderingMode.AlwaysOriginal; - if (fontImageSource is not null) - { - if (fontImageSource.Color is not null) - { - button.TintColor = fontImageSource.Color.ToPlatform(); - } - else - { - var tintColor = item.GetTextColor(); - if (tintColor is not null) - { - button.TintColor = tintColor.ToPlatform(); - } - } - } - else - { - button.TintColor = null; - } + button.SetImage(resizedImage.ImageWithRenderingMode(renderingMode), UIControlState.Normal); + button.TintColor = tintColor?.ToPlatform(); } catch (Exception) { diff --git a/src/Core/src/Platform/SwipeViewExtensions.cs b/src/Core/src/Platform/SwipeViewExtensions.cs index 536c163bf6b9..77fe0a4f59a5 100644 --- a/src/Core/src/Platform/SwipeViewExtensions.cs +++ b/src/Core/src/Platform/SwipeViewExtensions.cs @@ -19,6 +19,24 @@ public static partial class SwipeViewExtensions return (luminosity < 0.75f ? Colors.White : Colors.Black); } + /// + /// Resolves the color that should tint the swipe item's icon, or + /// when the icon must render with its own colors. + /// + internal static Color? GetIconTintColor(this ISwipeItemMenuItem swipeItemMenuItem) + { + if (swipeItemMenuItem.IconColor is Color iconColor) + return iconColor; + + // Font glyphs are single-color vectors, so tinting them is meaningful: use the explicit + // glyph color when there is one, otherwise fall back to a color contrasting the background. + // Raster and SVG sources carry their own colors and are left untouched. + if (swipeItemMenuItem.Source is IFontImageSource fontImageSource) + return fontImageSource.Color ?? swipeItemMenuItem.GetTextColor(); + + return null; + } + internal static ISwipeItems? GetSwipeItemsByDirection(this ISwipeView swipeView, SwipeDirection? swipeDirection) { ISwipeItems? swipeItems = null; diff --git a/src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt index f9e6fb3286ae..877771d440f8 100644 --- a/src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt @@ -346,3 +346,5 @@ override Microsoft.Maui.Platform.MauiHybridWebViewClient.OnPageStarted(Android.W override Microsoft.Maui.Platform.MauiSwipeRefreshLayout.OnInterceptTouchEvent(Android.Views.MotionEvent? ev) -> bool override Microsoft.Maui.Platform.MauiWebView.Dispose(bool disposing) -> void override Microsoft.Maui.Platform.MauiWebView.OnDetachedFromWindow() -> void +Microsoft.Maui.ISwipeItemMenuItem.IconColor.get -> Microsoft.Maui.Graphics.Color? +static Microsoft.Maui.Handlers.SwipeItemMenuItemHandler.MapIconColor(Microsoft.Maui.Handlers.ISwipeItemMenuItemHandler! handler, Microsoft.Maui.ISwipeItemMenuItem! view) -> void diff --git a/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt index adff3f041e77..b5296f9507e8 100644 --- a/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt @@ -33,3 +33,5 @@ override Microsoft.Maui.Platform.MauiView.AccessibilityActivate() -> bool override Microsoft.Maui.Platform.MauiView.AccessibilityLabel.get -> string? override Microsoft.Maui.Platform.MauiView.AccessibilityLabel.set -> void override Microsoft.Maui.Handlers.EditorHandler.PlatformArrange(Microsoft.Maui.Graphics.Rect rect) -> void +Microsoft.Maui.ISwipeItemMenuItem.IconColor.get -> Microsoft.Maui.Graphics.Color? +static Microsoft.Maui.Handlers.SwipeItemMenuItemHandler.MapIconColor(Microsoft.Maui.Handlers.ISwipeItemMenuItemHandler! handler, Microsoft.Maui.ISwipeItemMenuItem! view) -> void diff --git a/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt index adff3f041e77..b5296f9507e8 100644 --- a/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt @@ -33,3 +33,5 @@ override Microsoft.Maui.Platform.MauiView.AccessibilityActivate() -> bool override Microsoft.Maui.Platform.MauiView.AccessibilityLabel.get -> string? override Microsoft.Maui.Platform.MauiView.AccessibilityLabel.set -> void override Microsoft.Maui.Handlers.EditorHandler.PlatformArrange(Microsoft.Maui.Graphics.Rect rect) -> void +Microsoft.Maui.ISwipeItemMenuItem.IconColor.get -> Microsoft.Maui.Graphics.Color? +static Microsoft.Maui.Handlers.SwipeItemMenuItemHandler.MapIconColor(Microsoft.Maui.Handlers.ISwipeItemMenuItemHandler! handler, Microsoft.Maui.ISwipeItemMenuItem! view) -> void diff --git a/src/Core/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt index 5d4674da0aa6..b1eb89114918 100644 --- a/src/Core/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt @@ -13,3 +13,5 @@ Microsoft.Maui.TabBarPlacement Microsoft.Maui.TabBarPlacement.Bottom = 1 -> Microsoft.Maui.TabBarPlacement Microsoft.Maui.TabBarPlacement.Top = 0 -> Microsoft.Maui.TabBarPlacement static Microsoft.Maui.GridLength.implicit operator Microsoft.Maui.GridLength(string! value) -> Microsoft.Maui.GridLength +Microsoft.Maui.ISwipeItemMenuItem.IconColor.get -> Microsoft.Maui.Graphics.Color? +static Microsoft.Maui.Handlers.SwipeItemMenuItemHandler.MapIconColor(Microsoft.Maui.Handlers.ISwipeItemMenuItemHandler! handler, Microsoft.Maui.ISwipeItemMenuItem! view) -> void diff --git a/src/Core/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt index d5db8617f6e0..f893c24e7ce2 100644 --- a/src/Core/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt @@ -29,4 +29,6 @@ Microsoft.Maui.TabBarPlacement.Bottom = 1 -> Microsoft.Maui.TabBarPlacement Microsoft.Maui.TabBarPlacement.Top = 0 -> Microsoft.Maui.TabBarPlacement static Microsoft.Maui.GridLength.implicit operator Microsoft.Maui.GridLength(string! value) -> Microsoft.Maui.GridLength static Microsoft.Maui.SafeAreaEdges.Container.get -> Microsoft.Maui.SafeAreaEdges -override Microsoft.Maui.Platform.ContentPanel.MeasureOverride(Windows.Foundation.Size availableSize) -> Windows.Foundation.Size \ No newline at end of file +override Microsoft.Maui.Platform.ContentPanel.MeasureOverride(Windows.Foundation.Size availableSize) -> Windows.Foundation.Size +Microsoft.Maui.ISwipeItemMenuItem.IconColor.get -> Microsoft.Maui.Graphics.Color? +static Microsoft.Maui.Handlers.SwipeItemMenuItemHandler.MapIconColor(Microsoft.Maui.Handlers.ISwipeItemMenuItemHandler! handler, Microsoft.Maui.ISwipeItemMenuItem! view) -> void diff --git a/src/Core/src/PublicAPI/net/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net/PublicAPI.Unshipped.txt index faf5d5a701e1..7cbf90d41336 100644 --- a/src/Core/src/PublicAPI/net/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/net/PublicAPI.Unshipped.txt @@ -14,3 +14,5 @@ Microsoft.Maui.TabBarPlacement.Bottom = 1 -> Microsoft.Maui.TabBarPlacement Microsoft.Maui.TabBarPlacement.Top = 0 -> Microsoft.Maui.TabBarPlacement static Microsoft.Maui.GridLength.implicit operator Microsoft.Maui.GridLength(string! value) -> Microsoft.Maui.GridLength static Microsoft.Maui.SafeAreaEdges.Container.get -> Microsoft.Maui.SafeAreaEdges +Microsoft.Maui.ISwipeItemMenuItem.IconColor.get -> Microsoft.Maui.Graphics.Color? +static Microsoft.Maui.Handlers.SwipeItemMenuItemHandler.MapIconColor(Microsoft.Maui.Handlers.ISwipeItemMenuItemHandler! handler, Microsoft.Maui.ISwipeItemMenuItem! view) -> void diff --git a/src/Core/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt index cb1a85056343..b1d428b07889 100644 --- a/src/Core/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt @@ -13,3 +13,5 @@ Microsoft.Maui.ITab.Title.get -> string! Microsoft.Maui.TabBarPlacement Microsoft.Maui.TabBarPlacement.Bottom = 1 -> Microsoft.Maui.TabBarPlacement Microsoft.Maui.TabBarPlacement.Top = 0 -> Microsoft.Maui.TabBarPlacement +Microsoft.Maui.ISwipeItemMenuItem.IconColor.get -> Microsoft.Maui.Graphics.Color? +static Microsoft.Maui.Handlers.SwipeItemMenuItemHandler.MapIconColor(Microsoft.Maui.Handlers.ISwipeItemMenuItemHandler! handler, Microsoft.Maui.ISwipeItemMenuItem! view) -> void diff --git a/src/Core/src/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index cb1a85056343..b1d428b07889 100644 --- a/src/Core/src/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -13,3 +13,5 @@ Microsoft.Maui.ITab.Title.get -> string! Microsoft.Maui.TabBarPlacement Microsoft.Maui.TabBarPlacement.Bottom = 1 -> Microsoft.Maui.TabBarPlacement Microsoft.Maui.TabBarPlacement.Top = 0 -> Microsoft.Maui.TabBarPlacement +Microsoft.Maui.ISwipeItemMenuItem.IconColor.get -> Microsoft.Maui.Graphics.Color? +static Microsoft.Maui.Handlers.SwipeItemMenuItemHandler.MapIconColor(Microsoft.Maui.Handlers.ISwipeItemMenuItemHandler! handler, Microsoft.Maui.ISwipeItemMenuItem! view) -> void From eec21721b5a281134f7f949e881bea2b11a07ce2 Mon Sep 17 00:00:00 2001 From: praveenkumarkarunanithi Date: Thu, 30 Jul 2026 10:08:02 +0530 Subject: [PATCH 02/22] fix and test code update --- .../net-android/PublicAPI.Unshipped.txt | 3 + .../PublicAPI/net-ios/PublicAPI.Unshipped.txt | 3 + .../net-maccatalyst/PublicAPI.Unshipped.txt | 3 + .../net-tizen/PublicAPI.Unshipped.txt | 3 + .../net-windows/PublicAPI.Unshipped.txt | 3 + .../PublicAPI/net/PublicAPI.Unshipped.txt | 3 + .../netstandard/PublicAPI.Unshipped.txt | 3 + src/Controls/src/Core/SwipeView/SwipeItem.cs | 31 +++++++- .../tests/Core.UnitTests/SwipeViewTests.cs | 72 +++++++++++++++++++ src/Core/src/Core/ISwipeItemMenuItem.cs | 17 ++++- .../SwipeItemMenuItemHandler.Android.cs | 14 +++- .../SwipeItemMenuItemHandler.Windows.cs | 19 +++++ .../SwipeItemMenuItemHandler.cs | 4 +- src/Core/src/Platform/SwipeViewExtensions.cs | 4 ++ .../net-android/PublicAPI.Unshipped.txt | 1 + .../PublicAPI/net-ios/PublicAPI.Unshipped.txt | 1 + .../net-maccatalyst/PublicAPI.Unshipped.txt | 1 + .../net-tizen/PublicAPI.Unshipped.txt | 1 + .../net-windows/PublicAPI.Unshipped.txt | 1 + .../src/PublicAPI/net/PublicAPI.Unshipped.txt | 1 + .../netstandard/PublicAPI.Unshipped.txt | 1 + .../netstandard2.0/PublicAPI.Unshipped.txt | 1 + 22 files changed, 185 insertions(+), 5 deletions(-) diff --git a/src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt index f48654745c1f..0f2ae1e43f8b 100644 --- a/src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt @@ -244,3 +244,6 @@ Microsoft.Maui.Controls.Label.~Label() -> void ~Microsoft.Maui.Controls.SwipeItem.IconColor.get -> Microsoft.Maui.Graphics.Color ~Microsoft.Maui.Controls.SwipeItem.IconColor.set -> void ~static readonly Microsoft.Maui.Controls.SwipeItem.IconColorProperty -> Microsoft.Maui.Controls.BindableProperty +~Microsoft.Maui.Controls.SwipeItem.TextColor.get -> Microsoft.Maui.Graphics.Color +~Microsoft.Maui.Controls.SwipeItem.TextColor.set -> void +~static readonly Microsoft.Maui.Controls.SwipeItem.TextColorProperty -> Microsoft.Maui.Controls.BindableProperty diff --git a/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt index 1ef35e66526f..ed6a26622f5c 100644 --- a/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt @@ -157,3 +157,6 @@ Microsoft.Maui.Controls.Label.~Label() -> void ~Microsoft.Maui.Controls.SwipeItem.IconColor.get -> Microsoft.Maui.Graphics.Color ~Microsoft.Maui.Controls.SwipeItem.IconColor.set -> void ~static readonly Microsoft.Maui.Controls.SwipeItem.IconColorProperty -> Microsoft.Maui.Controls.BindableProperty +~Microsoft.Maui.Controls.SwipeItem.TextColor.get -> Microsoft.Maui.Graphics.Color +~Microsoft.Maui.Controls.SwipeItem.TextColor.set -> void +~static readonly Microsoft.Maui.Controls.SwipeItem.TextColorProperty -> Microsoft.Maui.Controls.BindableProperty diff --git a/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt index 1ef35e66526f..ed6a26622f5c 100644 --- a/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt @@ -157,3 +157,6 @@ Microsoft.Maui.Controls.Label.~Label() -> void ~Microsoft.Maui.Controls.SwipeItem.IconColor.get -> Microsoft.Maui.Graphics.Color ~Microsoft.Maui.Controls.SwipeItem.IconColor.set -> void ~static readonly Microsoft.Maui.Controls.SwipeItem.IconColorProperty -> Microsoft.Maui.Controls.BindableProperty +~Microsoft.Maui.Controls.SwipeItem.TextColor.get -> Microsoft.Maui.Graphics.Color +~Microsoft.Maui.Controls.SwipeItem.TextColor.set -> void +~static readonly Microsoft.Maui.Controls.SwipeItem.TextColorProperty -> Microsoft.Maui.Controls.BindableProperty diff --git a/src/Controls/src/Core/PublicAPI/net-tizen/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net-tizen/PublicAPI.Unshipped.txt index 62a3c9fc98b3..f2203ba3a550 100644 --- a/src/Controls/src/Core/PublicAPI/net-tizen/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-tizen/PublicAPI.Unshipped.txt @@ -152,3 +152,6 @@ Microsoft.Maui.Controls.Label.~Label() -> void ~Microsoft.Maui.Controls.SwipeItem.IconColor.get -> Microsoft.Maui.Graphics.Color ~Microsoft.Maui.Controls.SwipeItem.IconColor.set -> void ~static readonly Microsoft.Maui.Controls.SwipeItem.IconColorProperty -> Microsoft.Maui.Controls.BindableProperty +~Microsoft.Maui.Controls.SwipeItem.TextColor.get -> Microsoft.Maui.Graphics.Color +~Microsoft.Maui.Controls.SwipeItem.TextColor.set -> void +~static readonly Microsoft.Maui.Controls.SwipeItem.TextColorProperty -> Microsoft.Maui.Controls.BindableProperty diff --git a/src/Controls/src/Core/PublicAPI/net-windows/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net-windows/PublicAPI.Unshipped.txt index aba9c49f370f..d72b0e1119e4 100644 --- a/src/Controls/src/Core/PublicAPI/net-windows/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-windows/PublicAPI.Unshipped.txt @@ -200,3 +200,6 @@ Microsoft.Maui.Controls.Label.~Label() -> void ~Microsoft.Maui.Controls.SwipeItem.IconColor.get -> Microsoft.Maui.Graphics.Color ~Microsoft.Maui.Controls.SwipeItem.IconColor.set -> void ~static readonly Microsoft.Maui.Controls.SwipeItem.IconColorProperty -> Microsoft.Maui.Controls.BindableProperty +~Microsoft.Maui.Controls.SwipeItem.TextColor.get -> Microsoft.Maui.Graphics.Color +~Microsoft.Maui.Controls.SwipeItem.TextColor.set -> void +~static readonly Microsoft.Maui.Controls.SwipeItem.TextColorProperty -> Microsoft.Maui.Controls.BindableProperty diff --git a/src/Controls/src/Core/PublicAPI/net/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net/PublicAPI.Unshipped.txt index 30becea3fdaf..86a9dda5647d 100644 --- a/src/Controls/src/Core/PublicAPI/net/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net/PublicAPI.Unshipped.txt @@ -147,3 +147,6 @@ Microsoft.Maui.Controls.Label.~Label() -> void ~Microsoft.Maui.Controls.SwipeItem.IconColor.get -> Microsoft.Maui.Graphics.Color ~Microsoft.Maui.Controls.SwipeItem.IconColor.set -> void ~static readonly Microsoft.Maui.Controls.SwipeItem.IconColorProperty -> Microsoft.Maui.Controls.BindableProperty +~Microsoft.Maui.Controls.SwipeItem.TextColor.get -> Microsoft.Maui.Graphics.Color +~Microsoft.Maui.Controls.SwipeItem.TextColor.set -> void +~static readonly Microsoft.Maui.Controls.SwipeItem.TextColorProperty -> Microsoft.Maui.Controls.BindableProperty diff --git a/src/Controls/src/Core/PublicAPI/netstandard/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/netstandard/PublicAPI.Unshipped.txt index 2c58c6e87161..f06e348cbf5c 100644 --- a/src/Controls/src/Core/PublicAPI/netstandard/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/netstandard/PublicAPI.Unshipped.txt @@ -138,3 +138,6 @@ Microsoft.Maui.Controls.Label.~Label() -> void ~Microsoft.Maui.Controls.SwipeItem.IconColor.get -> Microsoft.Maui.Graphics.Color ~Microsoft.Maui.Controls.SwipeItem.IconColor.set -> void ~static readonly Microsoft.Maui.Controls.SwipeItem.IconColorProperty -> Microsoft.Maui.Controls.BindableProperty +~Microsoft.Maui.Controls.SwipeItem.TextColor.get -> Microsoft.Maui.Graphics.Color +~Microsoft.Maui.Controls.SwipeItem.TextColor.set -> void +~static readonly Microsoft.Maui.Controls.SwipeItem.TextColorProperty -> Microsoft.Maui.Controls.BindableProperty diff --git a/src/Controls/src/Core/SwipeView/SwipeItem.cs b/src/Controls/src/Core/SwipeView/SwipeItem.cs index 0400aac84c9c..72229baf59ae 100644 --- a/src/Controls/src/Core/SwipeView/SwipeItem.cs +++ b/src/Controls/src/Core/SwipeView/SwipeItem.cs @@ -19,6 +19,9 @@ public partial class SwipeItem : MenuItem, Controls.ISwipeItem, Maui.ISwipeItemM /// Bindable property for . public static readonly BindableProperty IconColorProperty = BindableProperty.Create(nameof(IconColor), typeof(Color), typeof(SwipeItem), null, propertyChanged: OnIconColorChanged); + /// Bindable property for . + public static readonly BindableProperty TextColorProperty = BindableProperty.Create(nameof(TextColor), typeof(Color), typeof(SwipeItem), null, propertyChanged: OnTextColorChanged); + /// /// Gets or sets the background color of the swipe item. This is a bindable property. /// @@ -45,8 +48,10 @@ public bool IsVisible /// /// When unset, a uses its own and falls /// back to a color contrasting , while image icons such as PNG and SVG render - /// with their original colors. Setting this property tints every icon type, and it can be bound with - /// to follow the current theme. + /// with their original colors. When set, this property tints font, file-based, and URI-based image icons on + /// Android, iOS, MacCatalyst, and Windows, and can be bound with to follow the + /// current theme. Stream-based image icons on Windows and all icons on Tizen currently render with their + /// original colors regardless of this property. /// public Color IconColor { @@ -54,10 +59,26 @@ public Color IconColor set { SetValue(IconColorProperty, value); } } + /// + /// Gets or sets the color used for the swipe item's label text. This is a bindable property. + /// + /// + /// When unset, the label falls back to a color contrasting . + /// When set, that color is used as the label color, and it can be bound with + /// to follow the current theme. + /// + public Color TextColor + { + get { return (Color)GetValue(TextColorProperty); } + set { SetValue(TextColorProperty, value); } + } + Paint ISwipeItemMenuItem.Background => new SolidPaint(BackgroundColor); Color ISwipeItemMenuItem.IconColor => IconColor; + Color ISwipeItemMenuItem.TextColor => TextColor; + Visibility ISwipeItemMenuItem.Visibility => this.IsVisible ? Visibility.Visible : Visibility.Collapsed; static void OnIconColorChanged(BindableObject bindable, object oldValue, object newValue) @@ -66,6 +87,12 @@ static void OnIconColorChanged(BindableObject bindable, object oldValue, object swipeItem.Handler?.UpdateValue(nameof(ISwipeItemMenuItem.IconColor)); } + static void OnTextColorChanged(BindableObject bindable, object oldValue, object newValue) + { + var swipeItem = (SwipeItem)bindable; + swipeItem.Handler?.UpdateValue(nameof(ISwipeItemMenuItem.TextColor)); + } + static void OnIsVisibleChanged(BindableObject bindable, object oldValue, object newValue) { var swipeItem = (SwipeItem)bindable; diff --git a/src/Controls/tests/Core.UnitTests/SwipeViewTests.cs b/src/Controls/tests/Core.UnitTests/SwipeViewTests.cs index 5d325bc4b9ba..ed9925f4347b 100644 --- a/src/Controls/tests/Core.UnitTests/SwipeViewTests.cs +++ b/src/Controls/tests/Core.UnitTests/SwipeViewTests.cs @@ -754,6 +754,78 @@ public void IconColorOverridesTheFontImageSourceColor() Assert.Equal(Colors.Red, ((ISwipeItemMenuItem)swipeItem).GetIconTintColor()); } + [Fact] + public void SwipeItemTextColorDefaultsToNull() + { + Assert.Null(new SwipeItem().TextColor); + } + + [Fact] + public void TextColorFallsBackToNullWhenBackgroundIsUnset() + { + var swipeItem = new SwipeItem + { + Text = "Delete" + }; + + Assert.Null(((ISwipeItemMenuItem)swipeItem).GetTextColor()); + } + + [Fact] + public void ExplicitTextColorIsRespected() + { + var swipeItem = new SwipeItem + { + BackgroundColor = Colors.White, + Text = "Delete", + TextColor = Colors.Red + }; + + Assert.Equal(Colors.Red, ((ISwipeItemMenuItem)swipeItem).GetTextColor()); + } + + [Fact] + public void TextColorIsNullWhenFontImageSourceHasItsOwnColor() + { + var swipeItem = new SwipeItem + { + BackgroundColor = Colors.White, + Text = "Delete", + IconImageSource = new FontImageSource { Glyph = "A", Color = Colors.Green } + }; + + Assert.Null(((ISwipeItemMenuItem)swipeItem).GetTextColor()); + } + + [Theory] + // A dark background needs light text, a light background needs dark text. + [InlineData(false, "#000000")] + [InlineData(true, "#FFFFFF")] + public void TextColorContrastsWithTheBackground(bool darkBackground, string expected) + { + var swipeItem = new SwipeItem + { + BackgroundColor = darkBackground ? Colors.Black : Colors.White, + Text = "Delete" + }; + + Assert.Equal(Color.FromArgb(expected), ((ISwipeItemMenuItem)swipeItem).GetTextColor()); + } + + [Fact] + public void ExplicitTextColorOverridesFontImageSourceColor() + { + var swipeItem = new SwipeItem + { + BackgroundColor = Colors.White, + Text = "Delete", + IconImageSource = new FontImageSource { Glyph = "A", Color = Colors.Green }, + TextColor = Colors.Red + }; + + Assert.Equal(Colors.Red, ((ISwipeItemMenuItem)swipeItem).GetTextColor()); + } + static void ForceFullGC() { for (int i = 0; i < 5; i++) diff --git a/src/Core/src/Core/ISwipeItemMenuItem.cs b/src/Core/src/Core/ISwipeItemMenuItem.cs index 76277c08647f..026d4ba4a4f9 100644 --- a/src/Core/src/Core/ISwipeItemMenuItem.cs +++ b/src/Core/src/Core/ISwipeItemMenuItem.cs @@ -20,12 +20,27 @@ public interface ISwipeItemMenuItem : IMenuElement, ISwipeItem /// /// When , a font icon is tinted with its own color (falling back to a /// color contrasting the item background), and image icons render with their original colors. - /// When set, the color is applied to every icon type. + /// When set, the color is applied to font, file-based, and URI-based image icons on Android, + /// iOS, MacCatalyst, and Windows. Stream-based image icons on Windows and all icons on Tizen + /// currently render with their original colors regardless of this property. /// #if NETSTANDARD2_0 Color? IconColor { get; } #else Color? IconColor => null; #endif + + /// + /// Gets the color used for the item's label text. + /// + /// + /// When , the label falls back to a color contrasting the item background. + /// When set, that color is used as the label color. + /// +#if NETSTANDARD2_0 + new Color? TextColor { get; } +#else + new Color? TextColor => null; +#endif } } diff --git a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Android.cs b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Android.cs index 60b808850511..3d1bb455f3c0 100644 --- a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Android.cs +++ b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Android.cs @@ -4,6 +4,7 @@ using Android.Graphics.Drawables; using Android.Util; using Android.Widget; +using Microsoft.Maui.Graphics; using AButton = AndroidX.AppCompat.Widget.AppCompatButton; using ATextAlignment = Android.Views.TextAlignment; using AView = Android.Views.View; @@ -31,7 +32,18 @@ protected override void DisconnectHandler(AView platformView) public static void MapTextColor(ISwipeItemMenuItemHandler handler, ITextStyle view) { - (handler.PlatformView as TextView)?.UpdateTextColor(view); + if (handler.PlatformView is not TextView textView) + return; + + // The mapper is PropertyMapper so view is always an + // ISwipeItemMenuItem at runtime; route through GetTextColor() so an explicit + // ISwipeItemMenuItem.TextColor wins over the luminosity-contrast fallback + // (base ITextStyle.TextColor is always null on MenuItem via explicit interface impl). + Color? resolved = view is ISwipeItemMenuItem swipeItem ? swipeItem.GetTextColor() : view!.TextColor; + var textColor = resolved?.ToPlatform(); + + if (textColor != null) + textView.SetTextColor(textColor.Value); } public static void MapCharacterSpacing(ISwipeItemMenuItemHandler handler, ITextStyle view) diff --git a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Windows.cs b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Windows.cs index f99789b9810e..3614319c66db 100644 --- a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Windows.cs +++ b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Windows.cs @@ -52,6 +52,10 @@ void OnSwipeItemInvoked(WSwipeItem sender, Microsoft.UI.Xaml.Controls.SwipeItemI VirtualView.OnInvoked(); } + // Per-handler monotonic counter; captured before an async load and re-checked on completion so + // a stale untinted load cannot overwrite a newer tinted icon (or vice versa) on THIS SwipeItem. + int _iconLoadGeneration; + internal static async Task LoadFileIconAsync(ISwipeItemMenuItemHandler handler, ISwipeItemMenuItem item) { if (handler.PlatformView is not WSwipeItem swipeItem || handler.MauiContext is null) @@ -59,6 +63,13 @@ internal static async Task LoadFileIconAsync(ISwipeItemMenuItemHandler handler, return; } + // Use the concrete handler for per-instance state; the Windows mapper is always paired + // with the concrete SwipeItemMenuItemHandler, so this cast is safe. + var concreteHandler = handler as SwipeItemMenuItemHandler; + int generation = concreteHandler is not null + ? System.Threading.Interlocked.Increment(ref concreteHandler._iconLoadGeneration) + : 0; + if (item.Source is null) { swipeItem.IconSource = null; @@ -90,6 +101,14 @@ internal static async Task LoadFileIconAsync(ISwipeItemMenuItemHandler handler, var service = imageSourceServiceProvider.GetRequiredImageSourceService(source); // Do not use ConfigureAwait(false): WinUI DependencyProperty writes require the UI thread. var result = await service.GetImageSourceAsync(source, scale); + + // Only apply the result if no newer load has started on THIS handler while this one was + // in flight. Checking item.Source == source alone is not enough: the source can be + // identical while IconColor changed, so a stale untinted result would overwrite a tinted one. + if (concreteHandler is not null && + generation != System.Threading.Volatile.Read(ref concreteHandler._iconLoadGeneration)) + return; + if (item.Source == source) { swipeItem.IconSource = result?.Value is WImageSource platformImage ? new ImageIconSource { ImageSource = platformImage } : null; diff --git a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.cs b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.cs index 763b2422c0d9..394bd65d22d2 100644 --- a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.cs +++ b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.cs @@ -65,8 +65,10 @@ public static void MapSource(ISwipeItemMenuItemHandler handler, ISwipeItemMenuIt // The tint is resolved while the icon is being applied to the platform view, so reloading the // source is what re-evaluates it. This also guarantees a previously applied tint is cleared // rather than left stale when IconColor changes (for example on an app theme switch). + // Route through UpdateValue so user mapper customizations for Source (AppendToMapping / + // PrependToMapping) are preserved instead of bypassed by a direct MapSource call. public static void MapIconColor(ISwipeItemMenuItemHandler handler, ISwipeItemMenuItem view) => - MapSource(handler, view); + handler.UpdateValue(nameof(IMenuElement.Source)); public static Task MapSourceAsync(ISwipeItemMenuItemHandler handler, ISwipeItemMenuItem image) { diff --git a/src/Core/src/Platform/SwipeViewExtensions.cs b/src/Core/src/Platform/SwipeViewExtensions.cs index 77fe0a4f59a5..534250d5e988 100644 --- a/src/Core/src/Platform/SwipeViewExtensions.cs +++ b/src/Core/src/Platform/SwipeViewExtensions.cs @@ -9,6 +9,10 @@ public static partial class SwipeViewExtensions public static Color? GetTextColor(this ISwipeItemMenuItem swipeItemMenuItem) { + // Explicit TextColor always wins. + if (swipeItemMenuItem.TextColor is Color explicitTextColor) + return explicitTextColor; + Color? backgroundColor = swipeItemMenuItem.Background?.ToColor(); if (backgroundColor == null || (swipeItemMenuItem.Source is IFontImageSource fontImageSource && fontImageSource.Color != null)) diff --git a/src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt index 877771d440f8..447eb402383f 100644 --- a/src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt @@ -347,4 +347,5 @@ override Microsoft.Maui.Platform.MauiSwipeRefreshLayout.OnInterceptTouchEvent(An override Microsoft.Maui.Platform.MauiWebView.Dispose(bool disposing) -> void override Microsoft.Maui.Platform.MauiWebView.OnDetachedFromWindow() -> void Microsoft.Maui.ISwipeItemMenuItem.IconColor.get -> Microsoft.Maui.Graphics.Color? +Microsoft.Maui.ISwipeItemMenuItem.TextColor.get -> Microsoft.Maui.Graphics.Color? static Microsoft.Maui.Handlers.SwipeItemMenuItemHandler.MapIconColor(Microsoft.Maui.Handlers.ISwipeItemMenuItemHandler! handler, Microsoft.Maui.ISwipeItemMenuItem! view) -> void diff --git a/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt index b5296f9507e8..c7ab7e7e4af4 100644 --- a/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt @@ -34,4 +34,5 @@ override Microsoft.Maui.Platform.MauiView.AccessibilityLabel.get -> string? override Microsoft.Maui.Platform.MauiView.AccessibilityLabel.set -> void override Microsoft.Maui.Handlers.EditorHandler.PlatformArrange(Microsoft.Maui.Graphics.Rect rect) -> void Microsoft.Maui.ISwipeItemMenuItem.IconColor.get -> Microsoft.Maui.Graphics.Color? +Microsoft.Maui.ISwipeItemMenuItem.TextColor.get -> Microsoft.Maui.Graphics.Color? static Microsoft.Maui.Handlers.SwipeItemMenuItemHandler.MapIconColor(Microsoft.Maui.Handlers.ISwipeItemMenuItemHandler! handler, Microsoft.Maui.ISwipeItemMenuItem! view) -> void diff --git a/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt index b5296f9507e8..c7ab7e7e4af4 100644 --- a/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt @@ -34,4 +34,5 @@ override Microsoft.Maui.Platform.MauiView.AccessibilityLabel.get -> string? override Microsoft.Maui.Platform.MauiView.AccessibilityLabel.set -> void override Microsoft.Maui.Handlers.EditorHandler.PlatformArrange(Microsoft.Maui.Graphics.Rect rect) -> void Microsoft.Maui.ISwipeItemMenuItem.IconColor.get -> Microsoft.Maui.Graphics.Color? +Microsoft.Maui.ISwipeItemMenuItem.TextColor.get -> Microsoft.Maui.Graphics.Color? static Microsoft.Maui.Handlers.SwipeItemMenuItemHandler.MapIconColor(Microsoft.Maui.Handlers.ISwipeItemMenuItemHandler! handler, Microsoft.Maui.ISwipeItemMenuItem! view) -> void diff --git a/src/Core/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt index b1eb89114918..4107fe240e5a 100644 --- a/src/Core/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt @@ -14,4 +14,5 @@ Microsoft.Maui.TabBarPlacement.Bottom = 1 -> Microsoft.Maui.TabBarPlacement Microsoft.Maui.TabBarPlacement.Top = 0 -> Microsoft.Maui.TabBarPlacement static Microsoft.Maui.GridLength.implicit operator Microsoft.Maui.GridLength(string! value) -> Microsoft.Maui.GridLength Microsoft.Maui.ISwipeItemMenuItem.IconColor.get -> Microsoft.Maui.Graphics.Color? +Microsoft.Maui.ISwipeItemMenuItem.TextColor.get -> Microsoft.Maui.Graphics.Color? static Microsoft.Maui.Handlers.SwipeItemMenuItemHandler.MapIconColor(Microsoft.Maui.Handlers.ISwipeItemMenuItemHandler! handler, Microsoft.Maui.ISwipeItemMenuItem! view) -> void diff --git a/src/Core/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt index f893c24e7ce2..7b8cfdcf4365 100644 --- a/src/Core/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt @@ -31,4 +31,5 @@ static Microsoft.Maui.GridLength.implicit operator Microsoft.Maui.GridLength(str static Microsoft.Maui.SafeAreaEdges.Container.get -> Microsoft.Maui.SafeAreaEdges override Microsoft.Maui.Platform.ContentPanel.MeasureOverride(Windows.Foundation.Size availableSize) -> Windows.Foundation.Size Microsoft.Maui.ISwipeItemMenuItem.IconColor.get -> Microsoft.Maui.Graphics.Color? +Microsoft.Maui.ISwipeItemMenuItem.TextColor.get -> Microsoft.Maui.Graphics.Color? static Microsoft.Maui.Handlers.SwipeItemMenuItemHandler.MapIconColor(Microsoft.Maui.Handlers.ISwipeItemMenuItemHandler! handler, Microsoft.Maui.ISwipeItemMenuItem! view) -> void diff --git a/src/Core/src/PublicAPI/net/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net/PublicAPI.Unshipped.txt index 7cbf90d41336..4ccdd0c792a9 100644 --- a/src/Core/src/PublicAPI/net/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/net/PublicAPI.Unshipped.txt @@ -15,4 +15,5 @@ Microsoft.Maui.TabBarPlacement.Top = 0 -> Microsoft.Maui.TabBarPlacement static Microsoft.Maui.GridLength.implicit operator Microsoft.Maui.GridLength(string! value) -> Microsoft.Maui.GridLength static Microsoft.Maui.SafeAreaEdges.Container.get -> Microsoft.Maui.SafeAreaEdges Microsoft.Maui.ISwipeItemMenuItem.IconColor.get -> Microsoft.Maui.Graphics.Color? +Microsoft.Maui.ISwipeItemMenuItem.TextColor.get -> Microsoft.Maui.Graphics.Color? static Microsoft.Maui.Handlers.SwipeItemMenuItemHandler.MapIconColor(Microsoft.Maui.Handlers.ISwipeItemMenuItemHandler! handler, Microsoft.Maui.ISwipeItemMenuItem! view) -> void diff --git a/src/Core/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt index b1d428b07889..610ff9851735 100644 --- a/src/Core/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt @@ -14,4 +14,5 @@ Microsoft.Maui.TabBarPlacement Microsoft.Maui.TabBarPlacement.Bottom = 1 -> Microsoft.Maui.TabBarPlacement Microsoft.Maui.TabBarPlacement.Top = 0 -> Microsoft.Maui.TabBarPlacement Microsoft.Maui.ISwipeItemMenuItem.IconColor.get -> Microsoft.Maui.Graphics.Color? +Microsoft.Maui.ISwipeItemMenuItem.TextColor.get -> Microsoft.Maui.Graphics.Color? static Microsoft.Maui.Handlers.SwipeItemMenuItemHandler.MapIconColor(Microsoft.Maui.Handlers.ISwipeItemMenuItemHandler! handler, Microsoft.Maui.ISwipeItemMenuItem! view) -> void diff --git a/src/Core/src/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index b1d428b07889..610ff9851735 100644 --- a/src/Core/src/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -14,4 +14,5 @@ Microsoft.Maui.TabBarPlacement Microsoft.Maui.TabBarPlacement.Bottom = 1 -> Microsoft.Maui.TabBarPlacement Microsoft.Maui.TabBarPlacement.Top = 0 -> Microsoft.Maui.TabBarPlacement Microsoft.Maui.ISwipeItemMenuItem.IconColor.get -> Microsoft.Maui.Graphics.Color? +Microsoft.Maui.ISwipeItemMenuItem.TextColor.get -> Microsoft.Maui.Graphics.Color? static Microsoft.Maui.Handlers.SwipeItemMenuItemHandler.MapIconColor(Microsoft.Maui.Handlers.ISwipeItemMenuItemHandler! handler, Microsoft.Maui.ISwipeItemMenuItem! view) -> void From 1f3ba33b9f864edc2e3c6f0495ac111a2d7c9dfd Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:42:05 +0200 Subject: [PATCH 03/22] Remove duplicate PublicAPI.Unshipped.txt entries on iOS/MacCatalyst MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The net11.0 merge duplicated a block of ~14 PublicAPI entries (ITab/TabBarPlacement/ValidateCommand/AccessibilityLabel/etc.) in the net-ios and net-maccatalyst PublicAPI.Unshipped.txt files — the same entries already existed earlier in each file. Remove the duplicate block; the new SwipeItem IconColor/TextColor/MapIconColor API entries are retained. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .../src/PublicAPI/net-ios/PublicAPI.Unshipped.txt | 14 -------------- .../net-maccatalyst/PublicAPI.Unshipped.txt | 14 -------------- 2 files changed, 28 deletions(-) diff --git a/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt index 521f221b6a4b..050ec5802fff 100644 --- a/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt @@ -41,20 +41,6 @@ static Microsoft.Maui.Handlers.EntryHandler.MapBackground(Microsoft.Maui.Handler static Microsoft.Maui.Handlers.RadioButtonHandler.MapBackground(Microsoft.Maui.Handlers.IRadioButtonHandler! handler, Microsoft.Maui.IRadioButton! radioButton) -> void static Microsoft.Maui.Handlers.WindowHandler.MapStatusBarTheme(Microsoft.Maui.Handlers.IWindowHandler! handler, Microsoft.Maui.IWindow! window) -> void static Microsoft.Maui.SafeAreaEdges.Container.get -> Microsoft.Maui.SafeAreaEdges -Microsoft.Maui.ITab -Microsoft.Maui.ITab.Icon.get -> Microsoft.Maui.IImageSource? -Microsoft.Maui.ITab.IsEnabled.get -> bool -Microsoft.Maui.ITab.Title.get -> string! -Microsoft.Maui.TabBarPlacement -Microsoft.Maui.TabBarPlacement.Bottom = 1 -> Microsoft.Maui.TabBarPlacement -Microsoft.Maui.TabBarPlacement.Top = 0 -> Microsoft.Maui.TabBarPlacement -override Microsoft.Maui.MauiUIApplicationDelegate.ValidateCommand(UIKit.UICommand! command) -> void -override Microsoft.Maui.Platform.MauiTextField.LayoutSubviews() -> void -override Microsoft.Maui.Platform.NoCaretField.LayoutSubviews() -> void -override Microsoft.Maui.Platform.MauiView.AccessibilityActivate() -> bool -override Microsoft.Maui.Platform.MauiView.AccessibilityLabel.get -> string? -override Microsoft.Maui.Platform.MauiView.AccessibilityLabel.set -> void -override Microsoft.Maui.Handlers.EditorHandler.PlatformArrange(Microsoft.Maui.Graphics.Rect rect) -> void Microsoft.Maui.ISwipeItemMenuItem.IconColor.get -> Microsoft.Maui.Graphics.Color? Microsoft.Maui.ISwipeItemMenuItem.TextColor.get -> Microsoft.Maui.Graphics.Color? static Microsoft.Maui.Handlers.SwipeItemMenuItemHandler.MapIconColor(Microsoft.Maui.Handlers.ISwipeItemMenuItemHandler! handler, Microsoft.Maui.ISwipeItemMenuItem! view) -> void diff --git a/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt index 09070785400f..3b011645e602 100644 --- a/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt @@ -40,20 +40,6 @@ static Microsoft.Maui.Handlers.EntryHandler.MapBackground(Microsoft.Maui.Handler static Microsoft.Maui.Handlers.RadioButtonHandler.MapBackground(Microsoft.Maui.Handlers.IRadioButtonHandler! handler, Microsoft.Maui.IRadioButton! radioButton) -> void static Microsoft.Maui.Handlers.WindowHandler.MapStatusBarTheme(Microsoft.Maui.Handlers.IWindowHandler! handler, Microsoft.Maui.IWindow! window) -> void static Microsoft.Maui.SafeAreaEdges.Container.get -> Microsoft.Maui.SafeAreaEdges -Microsoft.Maui.ITab -Microsoft.Maui.ITab.Icon.get -> Microsoft.Maui.IImageSource? -Microsoft.Maui.ITab.IsEnabled.get -> bool -Microsoft.Maui.ITab.Title.get -> string! -Microsoft.Maui.TabBarPlacement -Microsoft.Maui.TabBarPlacement.Bottom = 1 -> Microsoft.Maui.TabBarPlacement -Microsoft.Maui.TabBarPlacement.Top = 0 -> Microsoft.Maui.TabBarPlacement -override Microsoft.Maui.MauiUIApplicationDelegate.ValidateCommand(UIKit.UICommand! command) -> void -override Microsoft.Maui.Platform.MauiTextField.LayoutSubviews() -> void -override Microsoft.Maui.Platform.NoCaretField.LayoutSubviews() -> void -override Microsoft.Maui.Platform.MauiView.AccessibilityActivate() -> bool -override Microsoft.Maui.Platform.MauiView.AccessibilityLabel.get -> string? -override Microsoft.Maui.Platform.MauiView.AccessibilityLabel.set -> void -override Microsoft.Maui.Handlers.EditorHandler.PlatformArrange(Microsoft.Maui.Graphics.Rect rect) -> void Microsoft.Maui.ISwipeItemMenuItem.IconColor.get -> Microsoft.Maui.Graphics.Color? Microsoft.Maui.ISwipeItemMenuItem.TextColor.get -> Microsoft.Maui.Graphics.Color? static Microsoft.Maui.Handlers.SwipeItemMenuItemHandler.MapIconColor(Microsoft.Maui.Handlers.ISwipeItemMenuItemHandler! handler, Microsoft.Maui.ISwipeItemMenuItem! view) -> void From f6328adc43600d49443d3a9f1394412417bb3558 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:52:23 +0200 Subject: [PATCH 04/22] Keep SwipeItem icon tint and text color in sync on runtime changes Address two runtime-staleness edge cases flagged in review on the SwipeItem IconColor/TextColor work: - SwipeItem.OnTextColorChanged now also refreshes the icon. TextColor drives the icon tint for a colorless font icon (GetIconTintColor falls back to GetTextColor), so changing TextColor at runtime (e.g. via AppThemeBinding) previously updated the label but left the icon tint stale. This mirrors OnIconColorChanged, which already remaps the icon. - Android MapTextColor/MapBackground now route through a new internal TextViewExtensions.UpdateTextColor(Color?) overload that restores the cached themed default when the resolved color is null, instead of leaving a previously applied color stale. The public UpdateTextColor(ITextStyle) delegates to it, so behavior for other controls is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- src/Controls/src/Core/SwipeView/SwipeItem.cs | 4 ++++ .../SwipeItemMenuItemHandler.Android.cs | 13 ++++++------- src/Core/src/Platform/Android/TextViewExtensions.cs | 11 ++++++++--- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/Controls/src/Core/SwipeView/SwipeItem.cs b/src/Controls/src/Core/SwipeView/SwipeItem.cs index 72229baf59ae..c47c380959be 100644 --- a/src/Controls/src/Core/SwipeView/SwipeItem.cs +++ b/src/Controls/src/Core/SwipeView/SwipeItem.cs @@ -91,6 +91,10 @@ static void OnTextColorChanged(BindableObject bindable, object oldValue, object { var swipeItem = (SwipeItem)bindable; swipeItem.Handler?.UpdateValue(nameof(ISwipeItemMenuItem.TextColor)); + + // TextColor also drives the icon tint for a colorless font icon (GetIconTintColor falls + // back to GetTextColor), so refresh the icon too or its tint would remain stale. + swipeItem.Handler?.UpdateValue(nameof(ISwipeItemMenuItem.IconColor)); } static void OnIsVisibleChanged(BindableObject bindable, object oldValue, object newValue) diff --git a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Android.cs b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Android.cs index 3d1bb455f3c0..8b2348f23fb7 100644 --- a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Android.cs +++ b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Android.cs @@ -40,10 +40,10 @@ public static void MapTextColor(ISwipeItemMenuItemHandler handler, ITextStyle vi // ISwipeItemMenuItem.TextColor wins over the luminosity-contrast fallback // (base ITextStyle.TextColor is always null on MenuItem via explicit interface impl). Color? resolved = view is ISwipeItemMenuItem swipeItem ? swipeItem.GetTextColor() : view!.TextColor; - var textColor = resolved?.ToPlatform(); - if (textColor != null) - textView.SetTextColor(textColor.Value); + // UpdateTextColor restores the cached themed default when resolved is null so a + // previously applied color is not left stale (for example when Background is removed). + textView.UpdateTextColor(resolved); } public static void MapCharacterSpacing(ISwipeItemMenuItemHandler handler, ITextStyle view) @@ -71,12 +71,11 @@ public static void MapBackground(ISwipeItemMenuItemHandler handler, ISwipeItemMe { handler.PlatformView.UpdateBackground(handler.VirtualView.Background); - var textColor = handler.VirtualView.GetTextColor()?.ToPlatform(); - if (handler.PlatformView is TextView textView) { - if (textColor != null) - textView.SetTextColor(textColor.Value); + // Route through UpdateTextColor so the contrast fallback is applied and, when it + // resolves to null, the TextView is reset to its cached themed default. + textView.UpdateTextColor(handler.VirtualView.GetTextColor()); textView.TextAlignment = ATextAlignment.Center; } diff --git a/src/Core/src/Platform/Android/TextViewExtensions.cs b/src/Core/src/Platform/Android/TextViewExtensions.cs index 5d79bae2bf69..5af803c212c7 100644 --- a/src/Core/src/Platform/Android/TextViewExtensions.cs +++ b/src/Core/src/Platform/Android/TextViewExtensions.cs @@ -56,10 +56,15 @@ void SetTextHtml() SetTextHtml(); } - public static void UpdateTextColor(this TextView textView, ITextStyle textStyle) + public static void UpdateTextColor(this TextView textView, ITextStyle textStyle) => + textView.UpdateTextColor(textStyle.TextColor); + + // Applies an already-resolved text color, restoring the cached themed default when the color + // is null. Callers that resolve the color themselves (for example the SwipeItem handler, whose + // ITextStyle.TextColor is always null) route through here so a later null still resets the + // TextView instead of leaving a stale color. + internal static void UpdateTextColor(this TextView textView, Microsoft.Maui.Graphics.Color? textColor) { - var textColor = textStyle.TextColor; - // Cache the original themed TextColors the first time this control is updated. if (textView.TextColors is ColorStateList currentColors) { From ee053d53486df96227f1d672f2c415a8d9ec1b83 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:14:21 +0200 Subject: [PATCH 05/22] Refine SwipeItem icon-refresh trigger and correct IconColor tinting docs Follow-up to the runtime-sync change, addressing review feedback: - OnTextColorChanged now refreshes the icon only when TextColor can actually affect the tint: IconColor unset and IconImageSource is a FontImageSource without its own Color (the exact case where GetIconTintColor falls back to GetTextColor). This avoids a needless image reload for image sources or an explicitly tinted icon. - Correct the IconColor XML docs (SwipeItem and ISwipeItemMenuItem): the tint is applied to the resolved platform image on Android, iOS, and MacCatalyst, so it also covers stream-based image sources there. The stream-based limitation is Windows-only (ImageIconSource ignores Foreground); Tizen renders all icons with their original colors. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- src/Controls/src/Core/SwipeView/SwipeItem.cs | 16 ++++++++++------ src/Core/src/Core/ISwipeItemMenuItem.cs | 7 ++++--- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/Controls/src/Core/SwipeView/SwipeItem.cs b/src/Controls/src/Core/SwipeView/SwipeItem.cs index c47c380959be..9f32c00559a6 100644 --- a/src/Controls/src/Core/SwipeView/SwipeItem.cs +++ b/src/Controls/src/Core/SwipeView/SwipeItem.cs @@ -48,9 +48,10 @@ public bool IsVisible /// /// When unset, a uses its own and falls /// back to a color contrasting , while image icons such as PNG and SVG render - /// with their original colors. When set, this property tints font, file-based, and URI-based image icons on - /// Android, iOS, MacCatalyst, and Windows, and can be bound with to follow the - /// current theme. Stream-based image icons on Windows and all icons on Tizen currently render with their + /// with their original colors. When set, this property tints font icons and image icons on Android, iOS, and + /// MacCatalyst (including stream-based sources, since the resolved platform image is tinted), and font, + /// file-based, and URI-based icons on Windows, and can be bound with to follow + /// the current theme. Stream-based image icons on Windows and all icons on Tizen currently render with their /// original colors regardless of this property. /// public Color IconColor @@ -92,9 +93,12 @@ static void OnTextColorChanged(BindableObject bindable, object oldValue, object var swipeItem = (SwipeItem)bindable; swipeItem.Handler?.UpdateValue(nameof(ISwipeItemMenuItem.TextColor)); - // TextColor also drives the icon tint for a colorless font icon (GetIconTintColor falls - // back to GetTextColor), so refresh the icon too or its tint would remain stale. - swipeItem.Handler?.UpdateValue(nameof(ISwipeItemMenuItem.IconColor)); + // TextColor only drives the icon tint for an untinted font icon: GetIconTintColor falls + // back to GetTextColor only when IconColor is unset and the source is a FontImageSource + // without its own Color. Refresh the icon just in that case so its tint isn't left stale, + // avoiding a needless image reload for image sources or an explicitly tinted icon. + if (swipeItem.IconColor is null && swipeItem.IconImageSource is FontImageSource { Color: null }) + swipeItem.Handler?.UpdateValue(nameof(ISwipeItemMenuItem.IconColor)); } static void OnIsVisibleChanged(BindableObject bindable, object oldValue, object newValue) diff --git a/src/Core/src/Core/ISwipeItemMenuItem.cs b/src/Core/src/Core/ISwipeItemMenuItem.cs index 026d4ba4a4f9..fba5240fb2dd 100644 --- a/src/Core/src/Core/ISwipeItemMenuItem.cs +++ b/src/Core/src/Core/ISwipeItemMenuItem.cs @@ -20,9 +20,10 @@ public interface ISwipeItemMenuItem : IMenuElement, ISwipeItem /// /// When , a font icon is tinted with its own color (falling back to a /// color contrasting the item background), and image icons render with their original colors. - /// When set, the color is applied to font, file-based, and URI-based image icons on Android, - /// iOS, MacCatalyst, and Windows. Stream-based image icons on Windows and all icons on Tizen - /// currently render with their original colors regardless of this property. + /// When set, the color tints font icons and image icons on Android, iOS, and MacCatalyst + /// (including stream-based sources, since the resolved platform image is tinted), and font, + /// file-based, and URI-based icons on Windows. Stream-based image icons on Windows and all + /// icons on Tizen currently render with their original colors regardless of this property. /// #if NETSTANDARD2_0 Color? IconColor { get; } From 3279b35aa442cfab0f4e5a0537ea69a3f548ef1c Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Sun, 2 Aug 2026 23:33:05 +0200 Subject: [PATCH 06/22] Address SwipeItem review: font-icon invalidation + doc precedence From PureWeen's review of the SwipeItem IconColor/TextColor work: - OnTextColorChanged: match any IFontImageSource (not only the concrete FontImageSource) when deciding whether TextColor affects the icon tint, so a custom IFontImageSource with no Color isn't left with a stale glyph tint. This mirrors GetIconTintColor, which resolves on IFontImageSource. - Document the full colorless-font-icon tint precedence (FontImageSource.Color -> TextColor -> background contrast) on both SwipeItem.IconColor and ISwipeItemMenuItem.IconColor. - Document that the SwipeItem.TextColor contrast fallback is not unconditional: GetTextColor returns null (platform default) when the icon is a FontImageSource with its own Color, or when no BackgroundColor is set. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- src/Controls/src/Core/SwipeView/SwipeItem.cs | 26 +++++++++++--------- src/Core/src/Core/ISwipeItemMenuItem.cs | 13 +++++----- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/src/Controls/src/Core/SwipeView/SwipeItem.cs b/src/Controls/src/Core/SwipeView/SwipeItem.cs index 9f32c00559a6..debf539d37ae 100644 --- a/src/Controls/src/Core/SwipeView/SwipeItem.cs +++ b/src/Controls/src/Core/SwipeView/SwipeItem.cs @@ -46,13 +46,13 @@ public bool IsVisible /// Gets or sets the color used to tint . This is a bindable property. /// /// - /// When unset, a uses its own and falls - /// back to a color contrasting , while image icons such as PNG and SVG render - /// with their original colors. When set, this property tints font icons and image icons on Android, iOS, and - /// MacCatalyst (including stream-based sources, since the resolved platform image is tinted), and font, - /// file-based, and URI-based icons on Windows, and can be bound with to follow - /// the current theme. Stream-based image icons on Windows and all icons on Tizen currently render with their - /// original colors regardless of this property. + /// When unset, a font icon is tinted by its own , then by + /// , then by a color contrasting ; image icons such as + /// PNG and SVG render with their original colors. When set, this property tints font icons and image icons on + /// Android, iOS, and MacCatalyst (including stream-based sources, since the resolved platform image is + /// tinted), and font, file-based, and URI-based icons on Windows, and can be bound with + /// to follow the current theme. Stream-based image icons on Windows and all + /// icons on Tizen currently render with their original colors regardless of this property. /// public Color IconColor { @@ -64,9 +64,11 @@ public Color IconColor /// Gets or sets the color used for the swipe item's label text. This is a bindable property. /// /// - /// When unset, the label falls back to a color contrasting . - /// When set, that color is used as the label color, and it can be bound with - /// to follow the current theme. + /// When unset, the label falls back to a color contrasting , except when the + /// item's icon is a that already specifies its own + /// , or when no is set; in those cases the + /// label keeps the platform default. When set, that color is used as the label color, and it can be bound + /// with to follow the current theme. /// public Color TextColor { @@ -94,10 +96,10 @@ static void OnTextColorChanged(BindableObject bindable, object oldValue, object swipeItem.Handler?.UpdateValue(nameof(ISwipeItemMenuItem.TextColor)); // TextColor only drives the icon tint for an untinted font icon: GetIconTintColor falls - // back to GetTextColor only when IconColor is unset and the source is a FontImageSource + // back to GetTextColor only when IconColor is unset and the source is an IFontImageSource // without its own Color. Refresh the icon just in that case so its tint isn't left stale, // avoiding a needless image reload for image sources or an explicitly tinted icon. - if (swipeItem.IconColor is null && swipeItem.IconImageSource is FontImageSource { Color: null }) + if (swipeItem.IconColor is null && swipeItem.IconImageSource is IFontImageSource { Color: null }) swipeItem.Handler?.UpdateValue(nameof(ISwipeItemMenuItem.IconColor)); } diff --git a/src/Core/src/Core/ISwipeItemMenuItem.cs b/src/Core/src/Core/ISwipeItemMenuItem.cs index fba5240fb2dd..e257f3cc8f35 100644 --- a/src/Core/src/Core/ISwipeItemMenuItem.cs +++ b/src/Core/src/Core/ISwipeItemMenuItem.cs @@ -18,12 +18,13 @@ public interface ISwipeItemMenuItem : IMenuElement, ISwipeItem /// Gets the color used to tint the icon specified by . /// /// - /// When , a font icon is tinted with its own color (falling back to a - /// color contrasting the item background), and image icons render with their original colors. - /// When set, the color tints font icons and image icons on Android, iOS, and MacCatalyst - /// (including stream-based sources, since the resolved platform image is tinted), and font, - /// file-based, and URI-based icons on Windows. Stream-based image icons on Windows and all - /// icons on Tizen currently render with their original colors regardless of this property. + /// When , a font icon is tinted by its own color, then by the item's + /// , then by a color contrasting the item background; image + /// icons render with their original colors. When set, the color tints font icons and image icons + /// on Android, iOS, and MacCatalyst (including stream-based sources, since the resolved platform + /// image is tinted), and font, file-based, and URI-based icons on Windows. Stream-based image + /// icons on Windows and all icons on Tizen currently render with their original colors regardless + /// of this property. /// #if NETSTANDARD2_0 Color? IconColor { get; } From 796e1d8211a06c1762b13fd32bef5322f7899e9e Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 3 Aug 2026 00:54:23 +0200 Subject: [PATCH 07/22] Use mapper view parameter instead of handler.VirtualView in SwipeItem Android handler MapBackground resolved Background and the derived text color via handler.VirtualView, which can be null during disconnect/teardown; use the mapper's view parameter instead (guaranteed non-null while mapping and consistent with the iOS/Windows handlers). Also drop the now-redundant null-forgiving operator on view.TextColor in MapTextColor. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .../SwipeItemMenuItem/SwipeItemMenuItemHandler.Android.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Android.cs b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Android.cs index 8b2348f23fb7..b0920ebe5245 100644 --- a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Android.cs +++ b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Android.cs @@ -39,7 +39,7 @@ public static void MapTextColor(ISwipeItemMenuItemHandler handler, ITextStyle vi // ISwipeItemMenuItem at runtime; route through GetTextColor() so an explicit // ISwipeItemMenuItem.TextColor wins over the luminosity-contrast fallback // (base ITextStyle.TextColor is always null on MenuItem via explicit interface impl). - Color? resolved = view is ISwipeItemMenuItem swipeItem ? swipeItem.GetTextColor() : view!.TextColor; + Color? resolved = view is ISwipeItemMenuItem swipeItem ? swipeItem.GetTextColor() : view.TextColor; // UpdateTextColor restores the cached themed default when resolved is null so a // previously applied color is not left stale (for example when Background is removed). @@ -69,13 +69,13 @@ public static void MapText(ISwipeItemMenuItemHandler handler, ISwipeItemMenuItem public static void MapBackground(ISwipeItemMenuItemHandler handler, ISwipeItemMenuItem view) { - handler.PlatformView.UpdateBackground(handler.VirtualView.Background); + handler.PlatformView.UpdateBackground(view.Background); if (handler.PlatformView is TextView textView) { // Route through UpdateTextColor so the contrast fallback is applied and, when it // resolves to null, the TextView is reset to its cached themed default. - textView.UpdateTextColor(handler.VirtualView.GetTextColor()); + textView.UpdateTextColor(view.GetTextColor()); textView.TextAlignment = ATextAlignment.Center; } From ce20aba60c84729e10597b06d56ab79f9f3b7759 Mon Sep 17 00:00:00 2001 From: Vally Fixture Date: Mon, 3 Aug 2026 18:52:47 +0200 Subject: [PATCH 08/22] Avoid double SwipeItem mapper updates and fix IconColor doc references Address PureWeen's round-2 review of the SwipeItem IconColor/TextColor work: - Element.OnBindablePropertySet already fires the same-key handler mapper update when a bindable property changes, so the explicit Handler.UpdateValue calls for the matching key ran MapIconColor/MapTextColor twice (two source reloads) per runtime change. Drop the IconColor propertyChanged callback entirely and remove the same-key TextColor call, keeping only the cross-key IconColor refresh in OnTextColorChanged (needed because TextColor drives the tint of an untinted font icon). - Correct the ISwipeItemMenuItem.IconColor remark to reference this interface's TextColor (the member the resolver actually reads) instead of the always-null ITextStyle.TextColor, and mirror the TextColor contrast-fallback exceptions (no background / colored font icon) onto the Core interface remark. - Add handler-spy unit tests proving a runtime TextColor change refreshes the icon only for a colorless IFontImageSource (not for an explicit IconColor, a colored font source, or a non-font source), and that setting IconColor or TextColor invokes its mapper exactly once (no duplicate same-key update). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- src/Controls/src/Core/SwipeView/SwipeItem.cs | 17 ++-- .../tests/Core.UnitTests/SwipeViewTests.cs | 99 +++++++++++++++++++ src/Core/src/Core/ISwipeItemMenuItem.cs | 18 ++-- 3 files changed, 115 insertions(+), 19 deletions(-) diff --git a/src/Controls/src/Core/SwipeView/SwipeItem.cs b/src/Controls/src/Core/SwipeView/SwipeItem.cs index debf539d37ae..546290960b53 100644 --- a/src/Controls/src/Core/SwipeView/SwipeItem.cs +++ b/src/Controls/src/Core/SwipeView/SwipeItem.cs @@ -17,7 +17,7 @@ public partial class SwipeItem : MenuItem, Controls.ISwipeItem, Maui.ISwipeItemM public static readonly BindableProperty IsVisibleProperty = BindableProperty.Create(nameof(IsVisible), typeof(bool), typeof(SwipeItem), true, propertyChanged: OnIsVisibleChanged); /// Bindable property for . - public static readonly BindableProperty IconColorProperty = BindableProperty.Create(nameof(IconColor), typeof(Color), typeof(SwipeItem), null, propertyChanged: OnIconColorChanged); + public static readonly BindableProperty IconColorProperty = BindableProperty.Create(nameof(IconColor), typeof(Color), typeof(SwipeItem), null); /// Bindable property for . public static readonly BindableProperty TextColorProperty = BindableProperty.Create(nameof(TextColor), typeof(Color), typeof(SwipeItem), null, propertyChanged: OnTextColorChanged); @@ -84,20 +84,15 @@ public Color TextColor Visibility ISwipeItemMenuItem.Visibility => this.IsVisible ? Visibility.Visible : Visibility.Collapsed; - static void OnIconColorChanged(BindableObject bindable, object oldValue, object newValue) - { - var swipeItem = (SwipeItem)bindable; - swipeItem.Handler?.UpdateValue(nameof(ISwipeItemMenuItem.IconColor)); - } - static void OnTextColorChanged(BindableObject bindable, object oldValue, object newValue) { var swipeItem = (SwipeItem)bindable; - swipeItem.Handler?.UpdateValue(nameof(ISwipeItemMenuItem.TextColor)); - // TextColor only drives the icon tint for an untinted font icon: GetIconTintColor falls - // back to GetTextColor only when IconColor is unset and the source is an IFontImageSource - // without its own Color. Refresh the icon just in that case so its tint isn't left stale, + // The same-key TextColor mapper update is already fired automatically by + // Element.OnBindablePropertySet, so only the cross-key IconColor refresh is done here: + // TextColor drives the icon tint for an untinted font icon (GetIconTintColor falls back + // to GetTextColor only when IconColor is unset and the source is an IFontImageSource + // without its own Color). Refresh the icon just in that case so its tint isn't left stale, // avoiding a needless image reload for image sources or an explicitly tinted icon. if (swipeItem.IconColor is null && swipeItem.IconImageSource is IFontImageSource { Color: null }) swipeItem.Handler?.UpdateValue(nameof(ISwipeItemMenuItem.IconColor)); diff --git a/src/Controls/tests/Core.UnitTests/SwipeViewTests.cs b/src/Controls/tests/Core.UnitTests/SwipeViewTests.cs index ed9925f4347b..48d3bbe5ce16 100644 --- a/src/Controls/tests/Core.UnitTests/SwipeViewTests.cs +++ b/src/Controls/tests/Core.UnitTests/SwipeViewTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using Microsoft.Maui.Graphics; using Microsoft.Maui.Platform; +using NSubstitute; using Xunit; using Xunit.Sdk; @@ -826,6 +827,104 @@ public void ExplicitTextColorOverridesFontImageSourceColor() Assert.Equal(Colors.Red, ((ISwipeItemMenuItem)swipeItem).GetTextColor()); } + // A colorless font icon derives its tint from TextColor, so a runtime TextColor change must + // also refresh the icon. Element.OnBindablePropertySet already fires the same-key mapper + // update automatically, so these tests also guard against a duplicate same-key invocation. + [Fact] + public void ChangingTextColorRefreshesIconForColorlessFontIcon() + { + var swipeItem = new SwipeItem + { + IconImageSource = new FontImageSource { Glyph = "A" } + }; + var handler = Substitute.For(); + swipeItem.Handler = handler; + handler.ClearReceivedCalls(); + + swipeItem.TextColor = Colors.Red; + + handler.Received(1).UpdateValue(nameof(ITextStyle.TextColor)); + handler.Received(1).UpdateValue(nameof(ISwipeItemMenuItem.IconColor)); + } + + [Fact] + public void ChangingTextColorDoesNotRefreshIconWhenIconColorIsExplicit() + { + var swipeItem = new SwipeItem + { + IconColor = Colors.Blue, + IconImageSource = new FontImageSource { Glyph = "A" } + }; + var handler = Substitute.For(); + swipeItem.Handler = handler; + handler.ClearReceivedCalls(); + + swipeItem.TextColor = Colors.Red; + + handler.Received(1).UpdateValue(nameof(ITextStyle.TextColor)); + handler.DidNotReceive().UpdateValue(nameof(ISwipeItemMenuItem.IconColor)); + } + + [Fact] + public void ChangingTextColorDoesNotRefreshIconWhenFontIconHasItsOwnColor() + { + var swipeItem = new SwipeItem + { + IconImageSource = new FontImageSource { Glyph = "A", Color = Colors.Green } + }; + var handler = Substitute.For(); + swipeItem.Handler = handler; + handler.ClearReceivedCalls(); + + swipeItem.TextColor = Colors.Red; + + handler.DidNotReceive().UpdateValue(nameof(ISwipeItemMenuItem.IconColor)); + } + + [Fact] + public void ChangingTextColorDoesNotRefreshIconForNonFontIcon() + { + var swipeItem = new SwipeItem + { + IconImageSource = "icon.png" + }; + var handler = Substitute.For(); + swipeItem.Handler = handler; + handler.ClearReceivedCalls(); + + swipeItem.TextColor = Colors.Red; + + handler.DidNotReceive().UpdateValue(nameof(ISwipeItemMenuItem.IconColor)); + } + + // Element.OnBindablePropertySet fires the same-key mapper update automatically, so the + // property callbacks must not invoke it again (which would reload the icon twice). + [Fact] + public void SettingIconColorInvokesIconMapperOnce() + { + var swipeItem = new SwipeItem(); + var handler = Substitute.For(); + swipeItem.Handler = handler; + handler.ClearReceivedCalls(); + + swipeItem.IconColor = Colors.Red; + + handler.Received(1).UpdateValue(nameof(ISwipeItemMenuItem.IconColor)); + } + + [Fact] + public void SettingTextColorInvokesTextColorMapperOnce() + { + var swipeItem = new SwipeItem(); + var handler = Substitute.For(); + swipeItem.Handler = handler; + handler.ClearReceivedCalls(); + + swipeItem.TextColor = Colors.Red; + + handler.Received(1).UpdateValue(nameof(ITextStyle.TextColor)); + } + static void ForceFullGC() { for (int i = 0; i < 5; i++) diff --git a/src/Core/src/Core/ISwipeItemMenuItem.cs b/src/Core/src/Core/ISwipeItemMenuItem.cs index e257f3cc8f35..d079e6bbd51a 100644 --- a/src/Core/src/Core/ISwipeItemMenuItem.cs +++ b/src/Core/src/Core/ISwipeItemMenuItem.cs @@ -19,12 +19,12 @@ public interface ISwipeItemMenuItem : IMenuElement, ISwipeItem /// /// /// When , a font icon is tinted by its own color, then by the item's - /// , then by a color contrasting the item background; image - /// icons render with their original colors. When set, the color tints font icons and image icons - /// on Android, iOS, and MacCatalyst (including stream-based sources, since the resolved platform - /// image is tinted), and font, file-based, and URI-based icons on Windows. Stream-based image - /// icons on Windows and all icons on Tizen currently render with their original colors regardless - /// of this property. + /// , then by a color contrasting the item background; + /// image icons render with their original colors. When set, the color tints font icons and image + /// icons on Android, iOS, and MacCatalyst (including stream-based sources, since the resolved + /// platform image is tinted), and font, file-based, and URI-based icons on Windows. Stream-based + /// image icons on Windows and all icons on Tizen currently render with their original colors + /// regardless of this property. /// #if NETSTANDARD2_0 Color? IconColor { get; } @@ -36,8 +36,10 @@ public interface ISwipeItemMenuItem : IMenuElement, ISwipeItem /// Gets the color used for the item's label text. /// /// - /// When , the label falls back to a color contrasting the item background. - /// When set, that color is used as the label color. + /// When , the label falls back to a color contrasting the item background, + /// except when no background is set or the item's icon is a font icon that already specifies its + /// own color; in those cases the label keeps the platform default. When set, that color is used + /// as the label color. /// #if NETSTANDARD2_0 new Color? TextColor { get; } From dd3471362e150efea6eaf8f782cbcbe8e05a2bfe Mon Sep 17 00:00:00 2001 From: kubaflo <34349119+kubaflo@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:30:58 +0200 Subject: [PATCH 09/22] Add discriminating test for IFontImageSource icon-tint guard Adds a positive test that uses a custom ImageSource implementing IFontImageSource (not the concrete FontImageSource) with a null Color, so the cross-key IconColor refresh is verified against the interface guard rather than the concrete type. Guards against a regression where the check narrows back to `is FontImageSource`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .../tests/Core.UnitTests/SwipeViewTests.cs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/Controls/tests/Core.UnitTests/SwipeViewTests.cs b/src/Controls/tests/Core.UnitTests/SwipeViewTests.cs index 48d3bbe5ce16..5337b13dacc2 100644 --- a/src/Controls/tests/Core.UnitTests/SwipeViewTests.cs +++ b/src/Controls/tests/Core.UnitTests/SwipeViewTests.cs @@ -847,6 +847,25 @@ public void ChangingTextColorRefreshesIconForColorlessFontIcon() handler.Received(1).UpdateValue(nameof(ISwipeItemMenuItem.IconColor)); } + // Discriminating test for the IFontImageSource (interface, not concrete FontImageSource) guard: + // a custom ImageSource implementing IFontImageSource with a null Color must still trigger the + // cross-key icon refresh. This fails if the guard regresses to the concrete-type check. + [Fact] + public void ChangingTextColorRefreshesIconForCustomColorlessFontImageSource() + { + var swipeItem = new SwipeItem + { + IconImageSource = new CustomFontImageSource() + }; + var handler = Substitute.For(); + swipeItem.Handler = handler; + handler.ClearReceivedCalls(); + + swipeItem.TextColor = Colors.Red; + + handler.Received(1).UpdateValue(nameof(ISwipeItemMenuItem.IconColor)); + } + [Fact] public void ChangingTextColorDoesNotRefreshIconWhenIconColorIsExplicit() { @@ -934,5 +953,14 @@ static void ForceFullGC() GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, blocking: true, compacting: true); } } + + // A custom ImageSource that implements IFontImageSource without being the concrete + // FontImageSource, used to verify the icon-tint guard keys off the interface, not the type. + sealed class CustomFontImageSource : ImageSource, IFontImageSource + { + Color IFontImageSource.Color => null; + Font IFontImageSource.Font => Font.Default; + string IFontImageSource.Glyph => "A"; + } } } From 6f81414162ec2a160c28e81e2989b44debd9caa2 Mon Sep 17 00:00:00 2001 From: kubaflo <34349119+kubaflo@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:01:13 +0200 Subject: [PATCH 10/22] Mutate Android swipe-item drawable before tinting File/resource image services can return drawables backed by a shared Android ConstantState, so applying or clearing the color filter directly could bleed the tint between SwipeItems that use the same resource. Mutate the drawable first (as SliderExtensions/SearchViewExtensions already do) and attach the uniquely-stateful result. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .../SwipeItemMenuItem/SwipeItemMenuItemHandler.Android.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Android.cs b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Android.cs index b0920ebe5245..0eca47b839ba 100644 --- a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Android.cs +++ b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Android.cs @@ -197,6 +197,11 @@ public override void SetImageSource(Drawable? platformImage) var tintColor = item.GetIconTintColor()?.ToPlatform(); + // File/resource image services can return drawables backed by a shared ConstantState, + // so mutate before changing the color filter to avoid bleeding the tint between + // SwipeItems that use the same resource. Attach the now-uniquely-stateful drawable. + platformImage = platformImage.Mutate(); + if (tintColor is not null) { platformImage.SetColorFilter(tintColor.Value, FilterMode.SrcAtop); From 50d8927d7ff47f5cfaec55cd1dfd02731a60031a Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Fri, 7 Aug 2026 00:47:14 +0200 Subject: [PATCH 11/22] Fix SwipeItem color handler regressions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- src/Controls/src/Core/SwipeView/SwipeItem.cs | 29 ++- .../tests/Core.UnitTests/SwipeViewTests.cs | 167 ++++++++++++++++-- src/Core/src/Core/ISwipeItemMenuItem.cs | 39 ++-- .../SwipeItemMenuItemHandler.Android.cs | 16 +- .../SwipeItemMenuItemHandler.Tizen.cs | 17 +- .../SwipeItemMenuItemHandler.Windows.cs | 108 +++++++---- .../SwipeItemMenuItemHandler.cs | 56 +++++- .../SwipeItemMenuItemHandler.iOS.cs | 11 +- src/Core/src/Platform/SwipeViewExtensions.cs | 2 +- .../Platform/Windows/SwipeViewExtensions.cs | 8 +- .../net-android/PublicAPI.Unshipped.txt | 4 +- .../PublicAPI/net-ios/PublicAPI.Unshipped.txt | 4 +- .../net-maccatalyst/PublicAPI.Unshipped.txt | 4 +- .../net-tizen/PublicAPI.Unshipped.txt | 4 +- .../net-windows/PublicAPI.Unshipped.txt | 4 +- .../src/PublicAPI/net/PublicAPI.Unshipped.txt | 4 +- .../netstandard/PublicAPI.Unshipped.txt | 4 +- .../netstandard2.0/PublicAPI.Unshipped.txt | 4 +- .../SwipeItemMenuItemHandlerTests.Android.cs | 52 ++++++ .../SwipeItemMenuItemHandlerTests.Windows.cs | 66 +++++++ .../SwipeItemMenuItemHandlerTests.iOS.cs | 63 +++++++ .../Stubs/SwipeItemMenuItemStub.cs | 41 +++++ 22 files changed, 577 insertions(+), 130 deletions(-) create mode 100644 src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Android.cs create mode 100644 src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Windows.cs create mode 100644 src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.iOS.cs create mode 100644 src/Core/tests/DeviceTests/Stubs/SwipeItemMenuItemStub.cs diff --git a/src/Controls/src/Core/SwipeView/SwipeItem.cs b/src/Controls/src/Core/SwipeView/SwipeItem.cs index 546290960b53..51d2fe2e7713 100644 --- a/src/Controls/src/Core/SwipeView/SwipeItem.cs +++ b/src/Controls/src/Core/SwipeView/SwipeItem.cs @@ -8,10 +8,10 @@ namespace Microsoft.Maui.Controls /// /// Represents a menu item displayed in a when the view is swiped. /// - public partial class SwipeItem : MenuItem, Controls.ISwipeItem, Maui.ISwipeItemMenuItem + public partial class SwipeItem : MenuItem, Controls.ISwipeItem, Maui.ISwipeItemMenuItem, Maui.ISwipeItemMenuItemIconColor { /// Bindable property for . - public static readonly BindableProperty BackgroundColorProperty = BindableProperty.Create(nameof(BackgroundColor), typeof(Color), typeof(SwipeItem), null); + public static readonly BindableProperty BackgroundColorProperty = BindableProperty.Create(nameof(BackgroundColor), typeof(Color), typeof(SwipeItem), null, propertyChanged: OnBackgroundColorChanged); /// Bindable property for . public static readonly BindableProperty IsVisibleProperty = BindableProperty.Create(nameof(IsVisible), typeof(bool), typeof(SwipeItem), true, propertyChanged: OnIsVisibleChanged); @@ -20,7 +20,7 @@ public partial class SwipeItem : MenuItem, Controls.ISwipeItem, Maui.ISwipeItemM public static readonly BindableProperty IconColorProperty = BindableProperty.Create(nameof(IconColor), typeof(Color), typeof(SwipeItem), null); /// Bindable property for . - public static readonly BindableProperty TextColorProperty = BindableProperty.Create(nameof(TextColor), typeof(Color), typeof(SwipeItem), null, propertyChanged: OnTextColorChanged); + public static readonly BindableProperty TextColorProperty = BindableProperty.Create(nameof(TextColor), typeof(Color), typeof(SwipeItem), null); /// /// Gets or sets the background color of the swipe item. This is a bindable property. @@ -50,9 +50,10 @@ public bool IsVisible /// , then by a color contrasting ; image icons such as /// PNG and SVG render with their original colors. When set, this property tints font icons and image icons on /// Android, iOS, and MacCatalyst (including stream-based sources, since the resolved platform image is - /// tinted), and font, file-based, and URI-based icons on Windows, and can be bound with - /// to follow the current theme. Stream-based image icons on Windows and all - /// icons on Tizen currently render with their original colors regardless of this property. + /// tinted), and font, packaged file, and URI-based icons on Windows, and can be bound with + /// to follow the current theme. On Windows, packaged file and URI icons use + /// the color as a monochrome mask; rooted and stream-based image icons render with their original colors. + /// All icons on Tizen currently render with their original colors regardless of this property. /// public Color IconColor { @@ -78,24 +79,16 @@ public Color TextColor Paint ISwipeItemMenuItem.Background => new SolidPaint(BackgroundColor); - Color ISwipeItemMenuItem.IconColor => IconColor; + Color ISwipeItemMenuItemIconColor.IconColor => IconColor; - Color ISwipeItemMenuItem.TextColor => TextColor; + Color ITextStyle.TextColor => TextColor; Visibility ISwipeItemMenuItem.Visibility => this.IsVisible ? Visibility.Visible : Visibility.Collapsed; - static void OnTextColorChanged(BindableObject bindable, object oldValue, object newValue) + static void OnBackgroundColorChanged(BindableObject bindable, object oldValue, object newValue) { var swipeItem = (SwipeItem)bindable; - - // The same-key TextColor mapper update is already fired automatically by - // Element.OnBindablePropertySet, so only the cross-key IconColor refresh is done here: - // TextColor drives the icon tint for an untinted font icon (GetIconTintColor falls back - // to GetTextColor only when IconColor is unset and the source is an IFontImageSource - // without its own Color). Refresh the icon just in that case so its tint isn't left stale, - // avoiding a needless image reload for image sources or an explicitly tinted icon. - if (swipeItem.IconColor is null && swipeItem.IconImageSource is IFontImageSource { Color: null }) - swipeItem.Handler?.UpdateValue(nameof(ISwipeItemMenuItem.IconColor)); + swipeItem.Handler?.UpdateValue(nameof(ISwipeItemMenuItem.Background)); } static void OnIsVisibleChanged(BindableObject bindable, object oldValue, object newValue) diff --git a/src/Controls/tests/Core.UnitTests/SwipeViewTests.cs b/src/Controls/tests/Core.UnitTests/SwipeViewTests.cs index 5337b13dacc2..3322345114a3 100644 --- a/src/Controls/tests/Core.UnitTests/SwipeViewTests.cs +++ b/src/Controls/tests/Core.UnitTests/SwipeViewTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using Microsoft.Maui.Graphics; +using Microsoft.Maui.Handlers; using Microsoft.Maui.Platform; using NSubstitute; using Xunit; @@ -783,6 +784,7 @@ public void ExplicitTextColorIsRespected() }; Assert.Equal(Colors.Red, ((ISwipeItemMenuItem)swipeItem).GetTextColor()); + Assert.Equal(Colors.Red, ((ITextStyle)swipeItem).TextColor); } [Fact] @@ -827,9 +829,8 @@ public void ExplicitTextColorOverridesFontImageSourceColor() Assert.Equal(Colors.Red, ((ISwipeItemMenuItem)swipeItem).GetTextColor()); } - // A colorless font icon derives its tint from TextColor, so a runtime TextColor change must - // also refresh the icon. Element.OnBindablePropertySet already fires the same-key mapper - // update automatically, so these tests also guard against a duplicate same-key invocation. + // A colorless font icon derives its tint from TextColor, so the TextColor mapper must + // also refresh the source after initial mapping has completed. [Fact] public void ChangingTextColorRefreshesIconForColorlessFontIcon() { @@ -837,14 +838,16 @@ public void ChangingTextColorRefreshesIconForColorlessFontIcon() { IconImageSource = new FontImageSource { Glyph = "A" } }; - var handler = Substitute.For(); + var handler = Substitute.For(); + handler.VirtualView.Returns((ISwipeItemMenuItem)swipeItem); swipeItem.Handler = handler; handler.ClearReceivedCalls(); swipeItem.TextColor = Colors.Red; + SwipeItemMenuItemHandler.UpdateTextColorIconDependency(handler, (ISwipeItemMenuItem)swipeItem); handler.Received(1).UpdateValue(nameof(ITextStyle.TextColor)); - handler.Received(1).UpdateValue(nameof(ISwipeItemMenuItem.IconColor)); + handler.Received(1).UpdateValue(nameof(IMenuElement.Source)); } // Discriminating test for the IFontImageSource (interface, not concrete FontImageSource) guard: @@ -857,13 +860,15 @@ public void ChangingTextColorRefreshesIconForCustomColorlessFontImageSource() { IconImageSource = new CustomFontImageSource() }; - var handler = Substitute.For(); + var handler = Substitute.For(); + handler.VirtualView.Returns((ISwipeItemMenuItem)swipeItem); swipeItem.Handler = handler; handler.ClearReceivedCalls(); swipeItem.TextColor = Colors.Red; + SwipeItemMenuItemHandler.UpdateTextColorIconDependency(handler, (ISwipeItemMenuItem)swipeItem); - handler.Received(1).UpdateValue(nameof(ISwipeItemMenuItem.IconColor)); + handler.Received(1).UpdateValue(nameof(IMenuElement.Source)); } [Fact] @@ -874,14 +879,16 @@ public void ChangingTextColorDoesNotRefreshIconWhenIconColorIsExplicit() IconColor = Colors.Blue, IconImageSource = new FontImageSource { Glyph = "A" } }; - var handler = Substitute.For(); + var handler = Substitute.For(); + handler.VirtualView.Returns((ISwipeItemMenuItem)swipeItem); swipeItem.Handler = handler; handler.ClearReceivedCalls(); swipeItem.TextColor = Colors.Red; + SwipeItemMenuItemHandler.UpdateTextColorIconDependency(handler, (ISwipeItemMenuItem)swipeItem); handler.Received(1).UpdateValue(nameof(ITextStyle.TextColor)); - handler.DidNotReceive().UpdateValue(nameof(ISwipeItemMenuItem.IconColor)); + handler.DidNotReceive().UpdateValue(nameof(IMenuElement.Source)); } [Fact] @@ -891,13 +898,15 @@ public void ChangingTextColorDoesNotRefreshIconWhenFontIconHasItsOwnColor() { IconImageSource = new FontImageSource { Glyph = "A", Color = Colors.Green } }; - var handler = Substitute.For(); + var handler = Substitute.For(); + handler.VirtualView.Returns((ISwipeItemMenuItem)swipeItem); swipeItem.Handler = handler; handler.ClearReceivedCalls(); swipeItem.TextColor = Colors.Red; + SwipeItemMenuItemHandler.UpdateTextColorIconDependency(handler, (ISwipeItemMenuItem)swipeItem); - handler.DidNotReceive().UpdateValue(nameof(ISwipeItemMenuItem.IconColor)); + handler.DidNotReceive().UpdateValue(nameof(IMenuElement.Source)); } [Fact] @@ -907,13 +916,15 @@ public void ChangingTextColorDoesNotRefreshIconForNonFontIcon() { IconImageSource = "icon.png" }; - var handler = Substitute.For(); + var handler = Substitute.For(); + handler.VirtualView.Returns((ISwipeItemMenuItem)swipeItem); swipeItem.Handler = handler; handler.ClearReceivedCalls(); swipeItem.TextColor = Colors.Red; + SwipeItemMenuItemHandler.UpdateTextColorIconDependency(handler, (ISwipeItemMenuItem)swipeItem); - handler.DidNotReceive().UpdateValue(nameof(ISwipeItemMenuItem.IconColor)); + handler.DidNotReceive().UpdateValue(nameof(IMenuElement.Source)); } // Element.OnBindablePropertySet fires the same-key mapper update automatically, so the @@ -928,7 +939,90 @@ public void SettingIconColorInvokesIconMapperOnce() swipeItem.IconColor = Colors.Red; - handler.Received(1).UpdateValue(nameof(ISwipeItemMenuItem.IconColor)); + handler.Received(1).UpdateValue(nameof(ISwipeItemMenuItemIconColor.IconColor)); + } + + [Fact] + public void SettingBackgroundColorInvokesBackgroundMapperOnce() + { + var swipeItem = new SwipeItem(); + var handler = Substitute.For(); + swipeItem.Handler = handler; + handler.ClearReceivedCalls(); + + swipeItem.BackgroundColor = Colors.Red; + + handler.Received(1).UpdateValue(nameof(ISwipeItemMenuItem.Background)); + } + + [Fact] + public void BackgroundMapperRefreshesTextColorDependency() + { + var handler = Substitute.For(); + + SwipeItemMenuItemHandler.UpdateBackgroundColorDependencies(handler); + + handler.Received(1).UpdateValue(nameof(ITextStyle.TextColor)); + handler.DidNotReceive().UpdateValue(nameof(IMenuElement.Source)); + } + + [Fact] + public void InitialIconColorMappingDoesNotReloadSource() + { + var swipeItem = new SwipeItem { IconImageSource = "icon.png" }; + var handler = new TestSwipeItemMenuItemHandler( + (ISwipeItemMenuItem)swipeItem, + ElementHandlerState.MappingProperties); + + SwipeItemMenuItemHandler.MapIconColor(handler, (ISwipeItemMenuItem)swipeItem); + Assert.Empty(handler.UpdatedProperties); + + handler.State = ElementHandlerState.Connected; + SwipeItemMenuItemHandler.MapIconColor(handler, (ISwipeItemMenuItem)swipeItem); + Assert.Equal(new[] { nameof(IMenuElement.Source) }, handler.UpdatedProperties); + } + + [Fact] + public void InitialTextColorMappingDoesNotReloadSource() + { + var swipeItem = new SwipeItem + { + IconImageSource = new FontImageSource { Glyph = "A" } + }; + var handler = new TestSwipeItemMenuItemHandler( + (ISwipeItemMenuItem)swipeItem, + ElementHandlerState.MappingProperties); + + SwipeItemMenuItemHandler.UpdateTextColorIconDependency(handler, (ISwipeItemMenuItem)swipeItem); + Assert.Empty(handler.UpdatedProperties); + + handler.State = ElementHandlerState.Connected; + SwipeItemMenuItemHandler.UpdateTextColorIconDependency(handler, (ISwipeItemMenuItem)swipeItem); + Assert.Equal(new[] { nameof(IMenuElement.Source) }, handler.UpdatedProperties); + } + + [Fact] + public void NewerIconLoadInvalidatesOlderLoadForCustomHandler() + { + var swipeItem = new SwipeItem { IconImageSource = "icon.png" }; + var platformView = new object(); + var handler = Substitute.For(); + handler.VirtualView.Returns((ISwipeItemMenuItem)swipeItem); + handler.PlatformView.Returns(platformView); + + var firstGeneration = SwipeItemMenuItemHandler.BeginIconLoad(handler); + var secondGeneration = SwipeItemMenuItemHandler.BeginIconLoad(handler); + + Assert.False(SwipeItemMenuItemHandler.IsIconLoadCurrent( + handler, + (ISwipeItemMenuItem)swipeItem, + platformView, + firstGeneration)); + Assert.True(SwipeItemMenuItemHandler.IsIconLoadCurrent( + handler, + (ISwipeItemMenuItem)swipeItem, + platformView, + secondGeneration)); } [Fact] @@ -962,5 +1056,50 @@ sealed class CustomFontImageSource : ImageSource, IFontImageSource Font IFontImageSource.Font => Font.Default; string IFontImageSource.Glyph => "A"; } + + sealed class TestSwipeItemMenuItemHandler : ISwipeItemMenuItemHandler, IElementHandlerStateExhibitor + { + public TestSwipeItemMenuItemHandler(ISwipeItemMenuItem virtualView, ElementHandlerState state) + { + VirtualView = virtualView; + State = state; + } + + public IMauiContext MauiContext => null; + + public object PlatformView { get; } = new(); + + public ImageSourcePartLoader SourceLoader => null; + + public ElementHandlerState State { get; set; } + + public List UpdatedProperties { get; } = new(); + + public ISwipeItemMenuItem VirtualView { get; private set; } + + IElement IElementHandler.VirtualView => VirtualView; + + public void DisconnectHandler() + { + } + + public void Invoke(string command, object args = null) + { + } + + public void SetMauiContext(IMauiContext mauiContext) + { + } + + public void SetVirtualView(IElement view) + { + VirtualView = (ISwipeItemMenuItem)view; + } + + public void UpdateValue(string property) + { + UpdatedProperties.Add(property); + } + } } } diff --git a/src/Core/src/Core/ISwipeItemMenuItem.cs b/src/Core/src/Core/ISwipeItemMenuItem.cs index d079e6bbd51a..7988fa173439 100644 --- a/src/Core/src/Core/ISwipeItemMenuItem.cs +++ b/src/Core/src/Core/ISwipeItemMenuItem.cs @@ -1,4 +1,5 @@ -using Microsoft.Maui.Graphics; +using System.ComponentModel; +using Microsoft.Maui.Graphics; namespace Microsoft.Maui { @@ -14,37 +15,27 @@ public interface ISwipeItemMenuItem : IMenuElement, ISwipeItem /// Visibility Visibility { get; } + } + + /// + /// Provides an optional icon tint for an . + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public interface ISwipeItemMenuItemIconColor + { /// /// Gets the color used to tint the icon specified by . /// /// /// When , a font icon is tinted by its own color, then by the item's - /// , then by a color contrasting the item background; + /// , then by a color contrasting the item background; /// image icons render with their original colors. When set, the color tints font icons and image /// icons on Android, iOS, and MacCatalyst (including stream-based sources, since the resolved - /// platform image is tinted), and font, file-based, and URI-based icons on Windows. Stream-based - /// image icons on Windows and all icons on Tizen currently render with their original colors - /// regardless of this property. + /// platform image is tinted), and font, packaged file, and URI icons on Windows. On Windows, + /// packaged file and URI icons use the color as a monochrome mask; rooted and stream-based image + /// icons render with their original colors. All icons on Tizen currently render with their + /// original colors regardless of this property. /// -#if NETSTANDARD2_0 Color? IconColor { get; } -#else - Color? IconColor => null; -#endif - - /// - /// Gets the color used for the item's label text. - /// - /// - /// When , the label falls back to a color contrasting the item background, - /// except when no background is set or the item's icon is a font icon that already specifies its - /// own color; in those cases the label keeps the platform default. When set, that color is used - /// as the label color. - /// -#if NETSTANDARD2_0 - new Color? TextColor { get; } -#else - new Color? TextColor => null; -#endif } } diff --git a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Android.cs b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Android.cs index 0eca47b839ba..4079cf619ed7 100644 --- a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Android.cs +++ b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Android.cs @@ -36,14 +36,16 @@ public static void MapTextColor(ISwipeItemMenuItemHandler handler, ITextStyle vi return; // The mapper is PropertyMapper so view is always an - // ISwipeItemMenuItem at runtime; route through GetTextColor() so an explicit - // ISwipeItemMenuItem.TextColor wins over the luminosity-contrast fallback - // (base ITextStyle.TextColor is always null on MenuItem via explicit interface impl). + // ISwipeItemMenuItem at runtime; route through GetTextColor() to include the + // luminosity-contrast fallback. Color? resolved = view is ISwipeItemMenuItem swipeItem ? swipeItem.GetTextColor() : view.TextColor; // UpdateTextColor restores the cached themed default when resolved is null so a // previously applied color is not left stale (for example when Background is removed). textView.UpdateTextColor(resolved); + + if (view is ISwipeItemMenuItem swipeItemMenuItem) + UpdateTextColorIconDependency(handler, swipeItemMenuItem); } public static void MapCharacterSpacing(ISwipeItemMenuItemHandler handler, ITextStyle view) @@ -72,13 +74,9 @@ public static void MapBackground(ISwipeItemMenuItemHandler handler, ISwipeItemMe handler.PlatformView.UpdateBackground(view.Background); if (handler.PlatformView is TextView textView) - { - // Route through UpdateTextColor so the contrast fallback is applied and, when it - // resolves to null, the TextView is reset to its cached themed default. - textView.UpdateTextColor(view.GetTextColor()); - textView.TextAlignment = ATextAlignment.Center; - } + + UpdateBackgroundColorDependencies(handler); } public static void MapVisibility(ISwipeItemMenuItemHandler handler, ISwipeItemMenuItem view) diff --git a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Tizen.cs b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Tizen.cs index 5e7156491f05..551374013180 100644 --- a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Tizen.cs +++ b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Tizen.cs @@ -14,7 +14,16 @@ public partial class SwipeItemMenuItemHandler : ElementHandler + public static void MapTextColor(ISwipeItemMenuItemHandler handler, ISwipeItemMenuItem view) + { handler.PlatformView.UpdateTextColor(view); + UpdateTextColorIconDependency(handler, view); + } public static void MapCharacterSpacing(ISwipeItemMenuItemHandler handler, ITextStyle view) { } @@ -30,8 +35,11 @@ public static void MapText(ISwipeItemMenuItemHandler handler, ISwipeItemMenuItem handler.PlatformView.Text = view.Text; } - public static void MapBackground(ISwipeItemMenuItemHandler handler, ISwipeItemMenuItem view) => + public static void MapBackground(ISwipeItemMenuItemHandler handler, ISwipeItemMenuItem view) + { handler.PlatformView.UpdateBackground(view.Background); + UpdateBackgroundColorDependencies(handler); + } public static void MapVisibility(ISwipeItemMenuItemHandler handler, ISwipeItemMenuItem view) { } @@ -52,10 +60,6 @@ void OnSwipeItemInvoked(WSwipeItem sender, Microsoft.UI.Xaml.Controls.SwipeItemI VirtualView.OnInvoked(); } - // Per-handler monotonic counter; captured before an async load and re-checked on completion so - // a stale untinted load cannot overwrite a newer tinted icon (or vice versa) on THIS SwipeItem. - int _iconLoadGeneration; - internal static async Task LoadFileIconAsync(ISwipeItemMenuItemHandler handler, ISwipeItemMenuItem item) { if (handler.PlatformView is not WSwipeItem swipeItem || handler.MauiContext is null) @@ -63,14 +67,10 @@ internal static async Task LoadFileIconAsync(ISwipeItemMenuItemHandler handler, return; } - // Use the concrete handler for per-instance state; the Windows mapper is always paired - // with the concrete SwipeItemMenuItemHandler, so this cast is safe. - var concreteHandler = handler as SwipeItemMenuItemHandler; - int generation = concreteHandler is not null - ? System.Threading.Interlocked.Increment(ref concreteHandler._iconLoadGeneration) - : 0; + int generation = BeginIconLoad(handler); - if (item.Source is null) + var source = item.Source; + if (source is null) { swipeItem.IconSource = null; return; @@ -79,37 +79,47 @@ internal static async Task LoadFileIconAsync(ISwipeItemMenuItemHandler handler, // ImageIconSource renders the image as-is and ignores Foreground, so an explicit tint has to go // through BitmapIconSource/FontIconSource instead, which honor Foreground (BitmapIconSource // draws the bitmap as a monochrome mask by default). - var tintColor = item.GetIconTintColor(); + var tintColor = (item as ISwipeItemMenuItemIconColor)?.IconColor; - if (tintColor is not null) + try { - var tintedIconSource = item.Source.ToIconSource(handler.MauiContext); + if (tintColor is not null) + { + var tintedIconSource = CreateTintedIconSource(source, handler.MauiContext); + + if (tintedIconSource is not null) + { + if (IsIconLoadCurrent(handler, item, swipeItem, generation) && + ReferenceEquals(item.Source, source)) + { + tintedIconSource.Foreground = tintColor.ToPlatform(); + swipeItem.IconSource = tintedIconSource; + } + + return; + } + } - if (tintedIconSource is not null) + var imageSourceServiceProvider = handler.MauiContext.Services.GetRequiredService(); + var scale = handler.MauiContext.GetOptionalPlatformWindow()?.GetDisplayDensity() ?? 1.0f; + IImageSource loadSource = source; + if (source is IFontImageSource { Color: null } fontImageSource && + item.GetTextColor() is Color fallbackColor) { - tintedIconSource.Foreground = tintColor.ToPlatform(); - swipeItem.IconSource = tintedIconSource; - return; + loadSource = new TintedFontImageSource(fontImageSource, fallbackColor); } - } - var imageSourceServiceProvider = handler.MauiContext.Services.GetRequiredService(); - var scale = handler.MauiContext.GetOptionalPlatformWindow()?.GetDisplayDensity() ?? 1.0f; - var source = item.Source; - try - { - var service = imageSourceServiceProvider.GetRequiredImageSourceService(source); + var service = imageSourceServiceProvider.GetRequiredImageSourceService(loadSource); // Do not use ConfigureAwait(false): WinUI DependencyProperty writes require the UI thread. - var result = await service.GetImageSourceAsync(source, scale); + var result = await service.GetImageSourceAsync(loadSource, scale); // Only apply the result if no newer load has started on THIS handler while this one was // in flight. Checking item.Source == source alone is not enough: the source can be // identical while IconColor changed, so a stale untinted result would overwrite a tinted one. - if (concreteHandler is not null && - generation != System.Threading.Volatile.Read(ref concreteHandler._iconLoadGeneration)) + if (!IsIconLoadCurrent(handler, item, swipeItem, generation)) return; - if (item.Source == source) + if (ReferenceEquals(item.Source, source)) { swipeItem.IconSource = result?.Value is WImageSource platformImage ? new ImageIconSource { ImageSource = platformImage } : null; } @@ -120,6 +130,42 @@ internal static async Task LoadFileIconAsync(ISwipeItemMenuItemHandler handler, } } + internal static IconSource? CreateTintedIconSource(IImageSource source, IMauiContext mauiContext) + { + if (source is IFileImageSource fileImageSource) + { + var filename = fileImageSource.File; + if (string.IsNullOrEmpty(filename) || Path.IsPathRooted(filename)) + return null; + + return new BitmapIconSource + { + UriSource = new Uri("ms-appx:///" + Path.GetFileName(filename)) + }; + } + + return source.ToIconSource(mauiContext); + } + + sealed class TintedFontImageSource : IFontImageSource + { + readonly IFontImageSource _source; + + public TintedFontImageSource(IFontImageSource source, Color color) + { + _source = source; + Color = color; + } + + public bool IsEmpty => _source.IsEmpty; + + public Color Color { get; } + + public Font Font => _source.Font; + + public string Glyph => _source.Glyph; + } + partial class SwipeItemMenuItemImageSourcePartSetter { public override void SetImageSource(ImageSource? platformImage) diff --git a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.cs b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.cs index 394bd65d22d2..e8f0d740aaf0 100644 --- a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.cs +++ b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.cs @@ -10,12 +10,15 @@ using PlatformView = System.Object; #endif +using System.Runtime.CompilerServices; using System.Threading.Tasks; namespace Microsoft.Maui.Handlers { public partial class SwipeItemMenuItemHandler : ISwipeItemMenuItemHandler { + static readonly ConditionalWeakTable s_handlerStates = new(); + public static IPropertyMapper Mapper = new PropertyMapper(ViewHandler.ElementMapper) { @@ -26,7 +29,7 @@ public partial class SwipeItemMenuItemHandler : ISwipeItemMenuItemHandler [nameof(ITextStyle.CharacterSpacing)] = MapCharacterSpacing, [nameof(ITextStyle.Font)] = MapFont, [nameof(IMenuElement.Source)] = MapSource, - [nameof(ISwipeItemMenuItem.IconColor)] = MapIconColor, + [nameof(ISwipeItemMenuItemIconColor.IconColor)] = MapIconColor, }; public static CommandMapper CommandMapper = @@ -59,16 +62,58 @@ protected SwipeItemMenuItemHandler(IPropertyMapper? mapper, CommandMapper? comma public virtual ImageSourcePartLoader SourceLoader => _imageSourcePartLoader ??= new ImageSourcePartLoader(new SwipeItemMenuItemImageSourcePartSetter(this)); - public static void MapSource(ISwipeItemMenuItemHandler handler, ISwipeItemMenuItem image) => + public static void MapSource(ISwipeItemMenuItemHandler handler, ISwipeItemMenuItem image) + { MapSourceAsync(handler, image).FireAndForget(handler); + } // The tint is resolved while the icon is being applied to the platform view, so reloading the // source is what re-evaluates it. This also guarantees a previously applied tint is cleared // rather than left stale when IconColor changes (for example on an app theme switch). // Route through UpdateValue so user mapper customizations for Source (AppendToMapping / // PrependToMapping) are preserved instead of bypassed by a direct MapSource call. - public static void MapIconColor(ISwipeItemMenuItemHandler handler, ISwipeItemMenuItem view) => + public static void MapIconColor(ISwipeItemMenuItemHandler handler, ISwipeItemMenuItem view) + { + if (handler.IsMappingProperties()) + return; + handler.UpdateValue(nameof(IMenuElement.Source)); + } + + internal static void UpdateBackgroundColorDependencies(ISwipeItemMenuItemHandler handler) + { + if (handler.IsMappingProperties()) + return; + + handler.UpdateValue(nameof(ITextStyle.TextColor)); + } + + internal static void UpdateTextColorIconDependency( + ISwipeItemMenuItemHandler handler, + ISwipeItemMenuItem view) + { + if (!handler.IsMappingProperties() && + view is not ISwipeItemMenuItemIconColor { IconColor: not null } && + view.Source is IFontImageSource { Color: null }) + { + handler.UpdateValue(nameof(IMenuElement.Source)); + } + } + + internal static HandlerState GetHandlerState(ISwipeItemMenuItemHandler handler) => + s_handlerStates.GetValue(handler, static _ => new HandlerState()); + + internal static int BeginIconLoad(ISwipeItemMenuItemHandler handler) => + System.Threading.Interlocked.Increment(ref GetHandlerState(handler).IconLoadGeneration); + + internal static bool IsIconLoadCurrent( + ISwipeItemMenuItemHandler handler, + ISwipeItemMenuItem item, + object platformView, + int generation) => + generation == System.Threading.Volatile.Read(ref GetHandlerState(handler).IconLoadGeneration) && + ReferenceEquals(handler.VirtualView, item) && + ReferenceEquals(handler.PlatformView, platformView); public static Task MapSourceAsync(ISwipeItemMenuItemHandler handler, ISwipeItemMenuItem image) { @@ -89,5 +134,10 @@ public SwipeItemMenuItemImageSourcePartSetter(ISwipeItemMenuItemHandler handler) { } } + + internal sealed class HandlerState + { + public int IconLoadGeneration; + } } } diff --git a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.iOS.cs b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.iOS.cs index 87383b816714..08879ccbe887 100644 --- a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.iOS.cs +++ b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.iOS.cs @@ -56,9 +56,8 @@ protected override void DisconnectHandler(UIButton platformView) public static void MapTextColor(ISwipeItemMenuItemHandler handler, ISwipeItemMenuItem view) { var color = view.GetTextColor(); - - if (color != null) - handler.PlatformView.SetTitleColor(color.ToPlatform(), UIControlState.Normal); + handler.PlatformView.SetTitleColor(color?.ToPlatform(), UIControlState.Normal); + UpdateTextColorIconDependency(handler, view); } public static void MapCharacterSpacing(ISwipeItemMenuItemHandler handler, ITextStyle view) @@ -82,6 +81,7 @@ public static void MapText(ISwipeItemMenuItemHandler handler, ISwipeItemMenuItem public static void MapBackground(ISwipeItemMenuItemHandler handler, ISwipeItemMenuItem view) { handler.PlatformView.UpdateBackground(view.Background); + UpdateBackgroundColorDependencies(handler); } public static void MapVisibility(ISwipeItemMenuItemHandler handler, ISwipeItemMenuItem view) @@ -122,7 +122,10 @@ public override void SetImageSource(UIImage? platformImage) // A tinted icon has to be rendered as a template so the tint actually takes effect. // Without a tint the image keeps its own colors via AlwaysOriginal. var tintColor = item.GetIconTintColor(); - var renderingMode = tintColor is not null ? UIImageRenderingMode.AlwaysTemplate : UIImageRenderingMode.AlwaysOriginal; + var renderingMode = + item.Source is IFontImageSource || tintColor is not null + ? UIImageRenderingMode.AlwaysTemplate + : UIImageRenderingMode.AlwaysOriginal; button.SetImage(resizedImage.ImageWithRenderingMode(renderingMode), UIControlState.Normal); button.TintColor = tintColor?.ToPlatform(); diff --git a/src/Core/src/Platform/SwipeViewExtensions.cs b/src/Core/src/Platform/SwipeViewExtensions.cs index 534250d5e988..373e14718e3b 100644 --- a/src/Core/src/Platform/SwipeViewExtensions.cs +++ b/src/Core/src/Platform/SwipeViewExtensions.cs @@ -29,7 +29,7 @@ public static partial class SwipeViewExtensions /// internal static Color? GetIconTintColor(this ISwipeItemMenuItem swipeItemMenuItem) { - if (swipeItemMenuItem.IconColor is Color iconColor) + if (swipeItemMenuItem is ISwipeItemMenuItemIconColor { IconColor: Color iconColor }) return iconColor; // Font glyphs are single-color vectors, so tinting them is meaningful: use the explicit diff --git a/src/Core/src/Platform/Windows/SwipeViewExtensions.cs b/src/Core/src/Platform/Windows/SwipeViewExtensions.cs index c2aa2e097f66..6aec96116922 100644 --- a/src/Core/src/Platform/Windows/SwipeViewExtensions.cs +++ b/src/Core/src/Platform/Windows/SwipeViewExtensions.cs @@ -35,10 +35,10 @@ public static WSwipeMode ToPlatform(this SwipeMode swipeMode) public static void UpdateTextColor(this WSwipeItem platformControl, ISwipeItemMenuItem view) { - var textColor = view.TextColor ?? view.GetTextColor(); - - if (textColor != null) - platformControl.Foreground = textColor.ToPlatform(); + var textColor = view.GetTextColor(); + platformControl.UpdateProperty( + WSwipeItem.ForegroundProperty, + textColor?.ToPlatform()); } public static WSwipeBehaviorOnInvoked ToPlatform(this SwipeBehaviorOnInvoked swipeBehaviorOnInvoked) diff --git a/src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt index 534b0ed43dac..c64cbfe7b553 100644 --- a/src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt @@ -354,8 +354,8 @@ override Microsoft.Maui.Platform.MauiHybridWebViewClient.OnPageStarted(Android.W override Microsoft.Maui.Platform.MauiSwipeRefreshLayout.OnInterceptTouchEvent(Android.Views.MotionEvent? ev) -> bool override Microsoft.Maui.Platform.MauiWebView.Dispose(bool disposing) -> void override Microsoft.Maui.Platform.MauiWebView.OnDetachedFromWindow() -> void -Microsoft.Maui.ISwipeItemMenuItem.IconColor.get -> Microsoft.Maui.Graphics.Color? -Microsoft.Maui.ISwipeItemMenuItem.TextColor.get -> Microsoft.Maui.Graphics.Color? +Microsoft.Maui.ISwipeItemMenuItemIconColor +Microsoft.Maui.ISwipeItemMenuItemIconColor.IconColor.get -> Microsoft.Maui.Graphics.Color? static Microsoft.Maui.Handlers.SwipeItemMenuItemHandler.MapIconColor(Microsoft.Maui.Handlers.ISwipeItemMenuItemHandler! handler, Microsoft.Maui.ISwipeItemMenuItem! view) -> void static Microsoft.Maui.Platform.TimePickerExtensions.UpdateTextAlignment(this Microsoft.Maui.Platform.MauiTimePicker! mauiTimePicker, Microsoft.Maui.ITimePicker! timePicker) -> void static Microsoft.Maui.Handlers.TimePickerHandler.MapFlowDirection(Microsoft.Maui.Handlers.ITimePickerHandler! handler, Microsoft.Maui.ITimePicker! timePicker) -> void diff --git a/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt index 64619e52f85a..496634b43079 100644 --- a/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt @@ -41,8 +41,8 @@ static Microsoft.Maui.Handlers.EntryHandler.MapBackground(Microsoft.Maui.Handler static Microsoft.Maui.Handlers.RadioButtonHandler.MapBackground(Microsoft.Maui.Handlers.IRadioButtonHandler! handler, Microsoft.Maui.IRadioButton! radioButton) -> void static Microsoft.Maui.Handlers.WindowHandler.MapStatusBarTheme(Microsoft.Maui.Handlers.IWindowHandler! handler, Microsoft.Maui.IWindow! window) -> void static Microsoft.Maui.SafeAreaEdges.Container.get -> Microsoft.Maui.SafeAreaEdges -Microsoft.Maui.ISwipeItemMenuItem.IconColor.get -> Microsoft.Maui.Graphics.Color? -Microsoft.Maui.ISwipeItemMenuItem.TextColor.get -> Microsoft.Maui.Graphics.Color? +Microsoft.Maui.ISwipeItemMenuItemIconColor +Microsoft.Maui.ISwipeItemMenuItemIconColor.IconColor.get -> Microsoft.Maui.Graphics.Color? static Microsoft.Maui.Handlers.SwipeItemMenuItemHandler.MapIconColor(Microsoft.Maui.Handlers.ISwipeItemMenuItemHandler! handler, Microsoft.Maui.ISwipeItemMenuItem! view) -> void Microsoft.Maui.Animations.PlatformTicker.Dispose() -> void virtual Microsoft.Maui.Animations.PlatformTicker.Dispose(bool disposing) -> void diff --git a/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt index 74ec6ea74bdd..6d2110cde879 100644 --- a/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt @@ -40,8 +40,8 @@ static Microsoft.Maui.Handlers.EntryHandler.MapBackground(Microsoft.Maui.Handler static Microsoft.Maui.Handlers.RadioButtonHandler.MapBackground(Microsoft.Maui.Handlers.IRadioButtonHandler! handler, Microsoft.Maui.IRadioButton! radioButton) -> void static Microsoft.Maui.Handlers.WindowHandler.MapStatusBarTheme(Microsoft.Maui.Handlers.IWindowHandler! handler, Microsoft.Maui.IWindow! window) -> void static Microsoft.Maui.SafeAreaEdges.Container.get -> Microsoft.Maui.SafeAreaEdges -Microsoft.Maui.ISwipeItemMenuItem.IconColor.get -> Microsoft.Maui.Graphics.Color? -Microsoft.Maui.ISwipeItemMenuItem.TextColor.get -> Microsoft.Maui.Graphics.Color? +Microsoft.Maui.ISwipeItemMenuItemIconColor +Microsoft.Maui.ISwipeItemMenuItemIconColor.IconColor.get -> Microsoft.Maui.Graphics.Color? static Microsoft.Maui.Handlers.SwipeItemMenuItemHandler.MapIconColor(Microsoft.Maui.Handlers.ISwipeItemMenuItemHandler! handler, Microsoft.Maui.ISwipeItemMenuItem! view) -> void Microsoft.Maui.Animations.PlatformTicker.Dispose() -> void virtual Microsoft.Maui.Animations.PlatformTicker.Dispose(bool disposing) -> void diff --git a/src/Core/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt index 756717f7baa2..db21e60cc162 100644 --- a/src/Core/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt @@ -18,6 +18,6 @@ Microsoft.Maui.TabBarPlacement Microsoft.Maui.TabBarPlacement.Bottom = 1 -> Microsoft.Maui.TabBarPlacement Microsoft.Maui.TabBarPlacement.Top = 0 -> Microsoft.Maui.TabBarPlacement static Microsoft.Maui.GridLength.implicit operator Microsoft.Maui.GridLength(string! value) -> Microsoft.Maui.GridLength -Microsoft.Maui.ISwipeItemMenuItem.IconColor.get -> Microsoft.Maui.Graphics.Color? -Microsoft.Maui.ISwipeItemMenuItem.TextColor.get -> Microsoft.Maui.Graphics.Color? +Microsoft.Maui.ISwipeItemMenuItemIconColor +Microsoft.Maui.ISwipeItemMenuItemIconColor.IconColor.get -> Microsoft.Maui.Graphics.Color? static Microsoft.Maui.Handlers.SwipeItemMenuItemHandler.MapIconColor(Microsoft.Maui.Handlers.ISwipeItemMenuItemHandler! handler, Microsoft.Maui.ISwipeItemMenuItem! view) -> void diff --git a/src/Core/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt index 1d36c00a53a5..504747768e70 100644 --- a/src/Core/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt @@ -37,8 +37,8 @@ virtual Microsoft.Maui.Animations.PlatformTicker.Dispose(bool disposing) -> void static Microsoft.Maui.GridLength.implicit operator Microsoft.Maui.GridLength(string! value) -> Microsoft.Maui.GridLength static Microsoft.Maui.SafeAreaEdges.Container.get -> Microsoft.Maui.SafeAreaEdges override Microsoft.Maui.Platform.ContentPanel.MeasureOverride(Windows.Foundation.Size availableSize) -> Windows.Foundation.Size -Microsoft.Maui.ISwipeItemMenuItem.IconColor.get -> Microsoft.Maui.Graphics.Color? -Microsoft.Maui.ISwipeItemMenuItem.TextColor.get -> Microsoft.Maui.Graphics.Color? +Microsoft.Maui.ISwipeItemMenuItemIconColor +Microsoft.Maui.ISwipeItemMenuItemIconColor.IconColor.get -> Microsoft.Maui.Graphics.Color? static Microsoft.Maui.Handlers.SwipeItemMenuItemHandler.MapIconColor(Microsoft.Maui.Handlers.ISwipeItemMenuItemHandler! handler, Microsoft.Maui.ISwipeItemMenuItem! view) -> void Microsoft.Maui.Platform.MauiPasswordTextBoxAutomationPeer Microsoft.Maui.Platform.MauiPasswordTextBoxAutomationPeer.MauiPasswordTextBoxAutomationPeer(Microsoft.Maui.Platform.MauiPasswordTextBox! owner) -> void diff --git a/src/Core/src/PublicAPI/net/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net/PublicAPI.Unshipped.txt index eff1673b4b6e..3f5b10ac4e24 100644 --- a/src/Core/src/PublicAPI/net/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/net/PublicAPI.Unshipped.txt @@ -19,8 +19,8 @@ Microsoft.Maui.TabBarPlacement.Bottom = 1 -> Microsoft.Maui.TabBarPlacement Microsoft.Maui.TabBarPlacement.Top = 0 -> Microsoft.Maui.TabBarPlacement static Microsoft.Maui.GridLength.implicit operator Microsoft.Maui.GridLength(string! value) -> Microsoft.Maui.GridLength static Microsoft.Maui.SafeAreaEdges.Container.get -> Microsoft.Maui.SafeAreaEdges -Microsoft.Maui.ISwipeItemMenuItem.IconColor.get -> Microsoft.Maui.Graphics.Color? -Microsoft.Maui.ISwipeItemMenuItem.TextColor.get -> Microsoft.Maui.Graphics.Color? +Microsoft.Maui.ISwipeItemMenuItemIconColor +Microsoft.Maui.ISwipeItemMenuItemIconColor.IconColor.get -> Microsoft.Maui.Graphics.Color? static Microsoft.Maui.Handlers.SwipeItemMenuItemHandler.MapIconColor(Microsoft.Maui.Handlers.ISwipeItemMenuItemHandler! handler, Microsoft.Maui.ISwipeItemMenuItem! view) -> void static Microsoft.Maui.Handlers.ShapeViewHandler.MapFlowDirection(Microsoft.Maui.Handlers.IShapeViewHandler! handler, Microsoft.Maui.IShapeView! shapeView) -> void static Microsoft.Maui.Handlers.SearchBarHandler.MapCursorPosition(Microsoft.Maui.Handlers.ISearchBarHandler! handler, Microsoft.Maui.ISearchBar! searchBar) -> void diff --git a/src/Core/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt index bf7fdafc7145..2eab747a6e79 100644 --- a/src/Core/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt @@ -17,8 +17,8 @@ Microsoft.Maui.ITab.Title.get -> string! Microsoft.Maui.TabBarPlacement Microsoft.Maui.TabBarPlacement.Bottom = 1 -> Microsoft.Maui.TabBarPlacement Microsoft.Maui.TabBarPlacement.Top = 0 -> Microsoft.Maui.TabBarPlacement -Microsoft.Maui.ISwipeItemMenuItem.IconColor.get -> Microsoft.Maui.Graphics.Color? -Microsoft.Maui.ISwipeItemMenuItem.TextColor.get -> Microsoft.Maui.Graphics.Color? +Microsoft.Maui.ISwipeItemMenuItemIconColor +Microsoft.Maui.ISwipeItemMenuItemIconColor.IconColor.get -> Microsoft.Maui.Graphics.Color? static Microsoft.Maui.Handlers.SwipeItemMenuItemHandler.MapIconColor(Microsoft.Maui.Handlers.ISwipeItemMenuItemHandler! handler, Microsoft.Maui.ISwipeItemMenuItem! view) -> void static Microsoft.Maui.Handlers.ShapeViewHandler.MapFlowDirection(Microsoft.Maui.Handlers.IShapeViewHandler! handler, Microsoft.Maui.IShapeView! shapeView) -> void static Microsoft.Maui.Handlers.SearchBarHandler.MapCursorPosition(Microsoft.Maui.Handlers.ISearchBarHandler! handler, Microsoft.Maui.ISearchBar! searchBar) -> void diff --git a/src/Core/src/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index bf7fdafc7145..2eab747a6e79 100644 --- a/src/Core/src/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -17,8 +17,8 @@ Microsoft.Maui.ITab.Title.get -> string! Microsoft.Maui.TabBarPlacement Microsoft.Maui.TabBarPlacement.Bottom = 1 -> Microsoft.Maui.TabBarPlacement Microsoft.Maui.TabBarPlacement.Top = 0 -> Microsoft.Maui.TabBarPlacement -Microsoft.Maui.ISwipeItemMenuItem.IconColor.get -> Microsoft.Maui.Graphics.Color? -Microsoft.Maui.ISwipeItemMenuItem.TextColor.get -> Microsoft.Maui.Graphics.Color? +Microsoft.Maui.ISwipeItemMenuItemIconColor +Microsoft.Maui.ISwipeItemMenuItemIconColor.IconColor.get -> Microsoft.Maui.Graphics.Color? static Microsoft.Maui.Handlers.SwipeItemMenuItemHandler.MapIconColor(Microsoft.Maui.Handlers.ISwipeItemMenuItemHandler! handler, Microsoft.Maui.ISwipeItemMenuItem! view) -> void static Microsoft.Maui.Handlers.ShapeViewHandler.MapFlowDirection(Microsoft.Maui.Handlers.IShapeViewHandler! handler, Microsoft.Maui.IShapeView! shapeView) -> void static Microsoft.Maui.Handlers.SearchBarHandler.MapCursorPosition(Microsoft.Maui.Handlers.ISearchBarHandler! handler, Microsoft.Maui.ISearchBar! searchBar) -> void diff --git a/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Android.cs b/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Android.cs new file mode 100644 index 000000000000..3349a41f486b --- /dev/null +++ b/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Android.cs @@ -0,0 +1,52 @@ +using System.Threading.Tasks; +using Android.Widget; +using Microsoft.Maui.DeviceTests.Stubs; +using Microsoft.Maui.Handlers; +using Xunit; +using static Microsoft.Maui.DeviceTests.AssertHelpers; + +namespace Microsoft.Maui.DeviceTests +{ + [Category(TestCategory.SwipeView)] + public class SwipeItemMenuItemHandlerTests : CoreHandlerTestBase + { + [Fact] + public async Task IconTintCanBeClearedWithoutMutatingSharedDrawable() + { + await InvokeOnMainThreadAsync(async () => + { + var tintedItem = new SwipeItemMenuItemStub + { + IconColor = Colors.Blue, + Source = new FileImageSourceStub("red.png") + }; + var tintedHandler = CreateHandler(tintedItem); + var tintedButton = Assert.IsAssignableFrom(tintedHandler.PlatformView); + + await AssertEventually(() => GetTopDrawable(tintedButton)?.ColorFilter is not null); + + var untintedItem = new SwipeItemMenuItemStub + { + Source = new FileImageSourceStub("red.png") + }; + var untintedHandler = CreateHandler(untintedItem); + var untintedButton = Assert.IsAssignableFrom(untintedHandler.PlatformView); + + await AssertEventually(() => GetTopDrawable(untintedButton) is not null); + Assert.Null(GetTopDrawable(untintedButton).ColorFilter); + + tintedItem.IconColor = null; + tintedHandler.UpdateValue(nameof(ISwipeItemMenuItemIconColor.IconColor)); + + await AssertEventually(() => GetTopDrawable(tintedButton) is not null && + GetTopDrawable(tintedButton).ColorFilter is null); + }); + } + + static global::Android.Graphics.Drawables.Drawable GetTopDrawable(TextView textView) + { + var drawables = textView.GetCompoundDrawables(); + return drawables.Length > 1 ? drawables[1] : null; + } + } +} diff --git a/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Windows.cs b/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Windows.cs new file mode 100644 index 000000000000..b75b7f0efd9e --- /dev/null +++ b/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Windows.cs @@ -0,0 +1,66 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Maui.DeviceTests.Stubs; +using Microsoft.Maui.Handlers; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Xunit; + +namespace Microsoft.Maui.DeviceTests +{ + [Category(TestCategory.SwipeView)] + public class SwipeItemMenuItemHandlerTests : CoreHandlerTestBase + { + [Fact] + public Task TextColorCanBeCleared() + { + return InvokeOnMainThreadAsync(() => + { + var item = new SwipeItemMenuItemStub + { + Text = "Delete", + TextColor = Colors.Red + }; + var handler = CreateHandler(item); + + Assert.NotSame( + DependencyProperty.UnsetValue, + handler.PlatformView.ReadLocalValue(SwipeItem.ForegroundProperty)); + + item.TextColor = null; + handler.UpdateValue(nameof(ITextStyle.TextColor)); + + Assert.Same( + DependencyProperty.UnsetValue, + handler.PlatformView.ReadLocalValue(SwipeItem.ForegroundProperty)); + }); + } + + [Fact] + public Task TintedPackagedFileUsesFlattenedMauiAssetName() + { + return InvokeOnMainThreadAsync(() => + { + var icon = Assert.IsType( + SwipeItemMenuItemHandler.CreateTintedIconSource( + new FileImageSourceStub("Resources/Images/delete.png"), + MauiContext)); + + Assert.Equal(new Uri("ms-appx:///delete.png"), icon.UriSource); + }); + } + + [Fact] + public Task TintedRootedFileFallsBackToUntintedLoader() + { + return InvokeOnMainThreadAsync(() => + { + var icon = SwipeItemMenuItemHandler.CreateTintedIconSource( + new FileImageSourceStub(@"C:\images\delete.png"), + MauiContext); + + Assert.Null(icon); + }); + } + } +} diff --git a/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.iOS.cs b/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.iOS.cs new file mode 100644 index 000000000000..3828c4acd32c --- /dev/null +++ b/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.iOS.cs @@ -0,0 +1,63 @@ +using System.Threading.Tasks; +using CoreGraphics; +using Microsoft.Maui.DeviceTests.Stubs; +using Microsoft.Maui.Handlers; +using UIKit; +using Xunit; +using static Microsoft.Maui.DeviceTests.AssertHelpers; + +namespace Microsoft.Maui.DeviceTests +{ + [Category(TestCategory.SwipeView)] + public class SwipeItemMenuItemHandlerTests : CoreHandlerTestBase + { + [Fact] + public Task TextColorCanBeCleared() + { + return InvokeOnMainThreadAsync(() => + { + var item = new SwipeItemMenuItemStub + { + Text = "Delete", + TextColor = Colors.Red + }; + var handler = CreateHandler(item); + + Assert.Equal(Colors.Red, handler.PlatformView.CurrentTitleColor.ToColor()); + + item.TextColor = null; + handler.UpdateValue(nameof(ITextStyle.TextColor)); + + Assert.NotEqual(Colors.Red, handler.PlatformView.CurrentTitleColor.ToColor()); + }); + } + + [Fact] + public async Task ColorlessFontIconUsesTemplateRenderingAndTracksTextColor() + { + await InvokeOnMainThreadAsync(async () => + { + var item = new SwipeItemMenuItemStub(); + var handler = CreateHandler(item); + handler.PlatformView.Frame = new CGRect(0, 0, 100, 100); + + item.Source = new FontImageSourceStub + { + Glyph = "X", + Font = Font.Default.WithSize(30) + }; + + await SwipeItemMenuItemHandler.MapSourceAsync(handler, item); + + var image = handler.PlatformView.ImageForState(UIControlState.Normal); + Assert.NotNull(image); + Assert.Equal(UIImageRenderingMode.AlwaysTemplate, image.RenderingMode); + + item.TextColor = Colors.Red; + handler.UpdateValue(nameof(ITextStyle.TextColor)); + + await AssertEventually(() => handler.PlatformView.TintColor?.ToColor() == Colors.Red); + }); + } + } +} diff --git a/src/Core/tests/DeviceTests/Stubs/SwipeItemMenuItemStub.cs b/src/Core/tests/DeviceTests/Stubs/SwipeItemMenuItemStub.cs new file mode 100644 index 000000000000..6d4ea1a94339 --- /dev/null +++ b/src/Core/tests/DeviceTests/Stubs/SwipeItemMenuItemStub.cs @@ -0,0 +1,41 @@ +using Microsoft.Maui.Graphics; + +namespace Microsoft.Maui.DeviceTests.Stubs +{ + public class SwipeItemMenuItemStub : ElementStub, ISwipeItemMenuItem, ISwipeItemMenuItemIconColor + { + public string AutomationId { get; set; } + + public Paint Background { get; set; } + + public double CharacterSpacing { get; set; } + + public Font Font { get; set; } = Font.Default; + + public Color IconColor { get; set; } + + public bool IsAnimationPlaying { get; set; } + + public bool IsEnabled { get; set; } = true; + + public IImageSource Source { get; set; } + + public string Text { get; set; } + + public Color TextColor { get; set; } + + public Visibility Visibility { get; set; } = Visibility.Visible; + + public void Clicked() + { + } + + public void OnInvoked() + { + } + + public void UpdateIsLoading(bool isLoading) + { + } + } +} From 39cb7171bd3d19f5a5e8dbbe322bf0df9335b495 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Fri, 7 Aug 2026 00:50:18 +0200 Subject: [PATCH 12/22] Use mapper view for Tizen SwipeItem background Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .../SwipeItemMenuItem/SwipeItemMenuItemHandler.Tizen.cs | 2 +- src/Core/src/Platform/Android/TextViewExtensions.cs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Tizen.cs b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Tizen.cs index 551374013180..ddcd7e276e3b 100644 --- a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Tizen.cs +++ b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Tizen.cs @@ -42,7 +42,7 @@ public static void MapBackground(ISwipeItemMenuItemHandler handler, ISwipeItemMe if (handler.PlatformView == null) return; - handler.PlatformView.UpdateBackground(handler.VirtualView.Background); + handler.PlatformView.UpdateBackground(view.Background); UpdateBackgroundColorDependencies(handler); } diff --git a/src/Core/src/Platform/Android/TextViewExtensions.cs b/src/Core/src/Platform/Android/TextViewExtensions.cs index 5af803c212c7..a49382f2429e 100644 --- a/src/Core/src/Platform/Android/TextViewExtensions.cs +++ b/src/Core/src/Platform/Android/TextViewExtensions.cs @@ -60,9 +60,9 @@ public static void UpdateTextColor(this TextView textView, ITextStyle textStyle) textView.UpdateTextColor(textStyle.TextColor); // Applies an already-resolved text color, restoring the cached themed default when the color - // is null. Callers that resolve the color themselves (for example the SwipeItem handler, whose - // ITextStyle.TextColor is always null) route through here so a later null still resets the - // TextView instead of leaving a stale color. + // is null. Callers that resolve the color themselves (for example the SwipeItem handler, which + // also applies a background-contrast fallback) route through here so a later null still resets + // the TextView instead of leaving a stale color. internal static void UpdateTextColor(this TextView textView, Microsoft.Maui.Graphics.Color? textColor) { // Cache the original themed TextColors the first time this control is updated. From a0d4ac2be5cb716014dfe3a4c85585274c38fe34 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Fri, 7 Aug 2026 02:14:05 +0200 Subject: [PATCH 13/22] Preserve custom SwipeItem image loading when tinted Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .../SwipeItemMenuItemHandler.Windows.cs | 24 ++++- .../SwipeItemMenuItemHandlerTests.Windows.cs | 100 +++++++++++++++++- .../SwipeItemMenuItemHandlerTests.iOS.cs | 29 +++++ 3 files changed, 148 insertions(+), 5 deletions(-) diff --git a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Windows.cs b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Windows.cs index c97089adad87..fb5998f521ea 100644 --- a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Windows.cs +++ b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Windows.cs @@ -83,9 +83,12 @@ internal static async Task LoadFileIconAsync(ISwipeItemMenuItemHandler handler, try { + var imageSourceServiceProvider = handler.MauiContext.Services.GetRequiredService(); + if (tintColor is not null) { - var tintedIconSource = CreateTintedIconSource(source, handler.MauiContext); + var sourceService = imageSourceServiceProvider.GetRequiredImageSourceService(source); + var tintedIconSource = CreateTintedIconSource(source, sourceService, handler.MauiContext); if (tintedIconSource is not null) { @@ -100,7 +103,6 @@ internal static async Task LoadFileIconAsync(ISwipeItemMenuItemHandler handler, } } - var imageSourceServiceProvider = handler.MauiContext.Services.GetRequiredService(); var scale = handler.MauiContext.GetOptionalPlatformWindow()?.GetDisplayDensity() ?? 1.0f; IImageSource loadSource = source; if (source is IFontImageSource { Color: null } fontImageSource && @@ -130,10 +132,20 @@ internal static async Task LoadFileIconAsync(ISwipeItemMenuItemHandler handler, } } - internal static IconSource? CreateTintedIconSource(IImageSource source, IMauiContext mauiContext) + internal static IconSource? CreateTintedIconSource( + IImageSource source, + IImageSourceService imageSourceService, + IMauiContext mauiContext) { if (source is IFileImageSource fileImageSource) { + // The built-in Windows file service resolves relative paths from the flattened app + // package, matching BitmapIconSource. A custom service may resolve the same relative + // path from anywhere, so keep its service-based result rather than replacing it with + // an invalid ms-appx URI merely because a tint was requested. + if (imageSourceService.GetType() != typeof(FileImageSourceService)) + return null; + var filename = fileImageSource.File; if (string.IsNullOrEmpty(filename) || Path.IsPathRooted(filename)) return null; @@ -144,6 +156,12 @@ internal static async Task LoadFileIconAsync(ISwipeItemMenuItemHandler handler, }; } + if (source is IUriImageSource && + imageSourceService.GetType() != typeof(UriImageSourceService)) + { + return null; + } + return source.ToIconSource(mauiContext); } diff --git a/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Windows.cs b/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Windows.cs index b75b7f0efd9e..78fb010e42bb 100644 --- a/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Windows.cs +++ b/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Windows.cs @@ -1,10 +1,16 @@ using System; +using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Maui.DeviceTests.Stubs; using Microsoft.Maui.Handlers; +using Microsoft.Maui.Hosting; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media.Imaging; using Xunit; +using WImageSource = Microsoft.UI.Xaml.Media.ImageSource; namespace Microsoft.Maui.DeviceTests { @@ -41,9 +47,14 @@ public Task TintedPackagedFileUsesFlattenedMauiAssetName() { return InvokeOnMainThreadAsync(() => { + var source = new FileImageSourceStub("Resources/Images/delete.png"); + var service = MauiContext.Services + .GetRequiredService() + .GetRequiredImageSourceService(source); var icon = Assert.IsType( SwipeItemMenuItemHandler.CreateTintedIconSource( - new FileImageSourceStub("Resources/Images/delete.png"), + source, + service, MauiContext)); Assert.Equal(new Uri("ms-appx:///delete.png"), icon.UriSource); @@ -55,12 +66,97 @@ public Task TintedRootedFileFallsBackToUntintedLoader() { return InvokeOnMainThreadAsync(() => { + var source = new FileImageSourceStub(@"C:\images\delete.png"); + var service = MauiContext.Services + .GetRequiredService() + .GetRequiredImageSourceService(source); var icon = SwipeItemMenuItemHandler.CreateTintedIconSource( - new FileImageSourceStub(@"C:\images\delete.png"), + source, + service, MauiContext); Assert.Null(icon); }); } + + [Fact] + public async Task CustomFileImageServiceKeepsIconWhenTintIsRequested() + { + var imageService = new DelayedFileImageSourceService(); + EnsureHandlerCreated(builder => builder.ConfigureImageSources( + services => services.AddService(_ => imageService))); + + await InvokeOnMainThreadAsync(async () => + { + var item = new SwipeItemMenuItemStub + { + IconColor = Colors.Red + }; + var handler = CreateHandler(item); + item.Source = new FileImageSourceStub("custom-relative-path.png"); + + var load = SwipeItemMenuItemHandler.MapSourceAsync(handler, item); + var request = imageService.Requests.Dequeue(); + var image = new BitmapImage(); + request.SetResult(new ImageSourceServiceResult(image)); + + await load; + + var icon = Assert.IsType(handler.PlatformView.IconSource); + Assert.Same(image, icon.ImageSource); + }); + } + + [Fact] + public async Task StaleSameSourceLoadCannotOverwriteNewerTintChange() + { + var imageService = new DelayedFileImageSourceService(); + EnsureHandlerCreated(builder => builder.ConfigureImageSources( + services => services.AddService(_ => imageService))); + + await InvokeOnMainThreadAsync(async () => + { + var source = new FileImageSourceStub("custom-relative-path.png"); + var item = new SwipeItemMenuItemStub(); + var handler = CreateHandler(item); + item.Source = source; + + var staleLoad = SwipeItemMenuItemHandler.MapSourceAsync(handler, item); + var initialRequest = imageService.Requests.Dequeue(); + + item.IconColor = Colors.Blue; + var newerLoad = SwipeItemMenuItemHandler.MapSourceAsync(handler, item); + var newerRequest = imageService.Requests.Dequeue(); + var newerImage = new BitmapImage(); + newerRequest.SetResult(new ImageSourceServiceResult(newerImage)); + await newerLoad; + + var currentIcon = Assert.IsType(handler.PlatformView.IconSource); + Assert.Same(newerImage, currentIcon.ImageSource); + + var staleImage = new BitmapImage(); + initialRequest.SetResult(new ImageSourceServiceResult(staleImage)); + await staleLoad; + + currentIcon = Assert.IsType(handler.PlatformView.IconSource); + Assert.Same(newerImage, currentIcon.ImageSource); + }); + } + + sealed class DelayedFileImageSourceService : IImageSourceService + { + public Queue?>> Requests { get; } = new(); + + public Task?> GetImageSourceAsync( + IImageSource imageSource, + float scale = 1, + CancellationToken cancellationToken = default) + { + var completion = new TaskCompletionSource?>( + TaskCreationOptions.RunContinuationsAsynchronously); + Requests.Enqueue(completion); + return completion.Task; + } + } } } diff --git a/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.iOS.cs b/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.iOS.cs index 3828c4acd32c..be4cdc1d7314 100644 --- a/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.iOS.cs +++ b/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.iOS.cs @@ -59,5 +59,34 @@ await InvokeOnMainThreadAsync(async () => await AssertEventually(() => handler.PlatformView.TintColor?.ToColor() == Colors.Red); }); } + + [Fact] + public async Task IconTintCanBeChangedAndCleared() + { + await InvokeOnMainThreadAsync(async () => + { + var item = new SwipeItemMenuItemStub + { + IconColor = Colors.Blue + }; + var handler = CreateHandler(item); + handler.PlatformView.Frame = new CGRect(0, 0, 100, 100); + item.Source = new FileImageSourceStub("red.png"); + + await SwipeItemMenuItemHandler.MapSourceAsync(handler, item); + await AssertEventually(() => + handler.PlatformView.ImageForState(UIControlState.Normal)?.RenderingMode == UIImageRenderingMode.AlwaysTemplate && + handler.PlatformView.TintColor?.ToColor() == Colors.Blue); + + item.IconColor = Colors.Red; + handler.UpdateValue(nameof(ISwipeItemMenuItemIconColor.IconColor)); + await AssertEventually(() => handler.PlatformView.TintColor?.ToColor() == Colors.Red); + + item.IconColor = null; + handler.UpdateValue(nameof(ISwipeItemMenuItemIconColor.IconColor)); + await AssertEventually(() => + handler.PlatformView.ImageForState(UIControlState.Normal)?.RenderingMode == UIImageRenderingMode.AlwaysOriginal); + }); + } } } From 5a142ef35ade207f94593cccae679414b2a652ec Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Fri, 7 Aug 2026 04:58:47 +0200 Subject: [PATCH 14/22] Address SwipeItem image service review feedback Keep Windows URI and font icons on registered image services, avoid Android/iOS reloads for color-only changes, and scope Windows load generations to the platform implementation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- src/Controls/src/Core/SwipeView/SwipeItem.cs | 6 +- .../tests/Core.UnitTests/SwipeViewTests.cs | 24 ---- src/Core/src/Core/ISwipeItemMenuItem.cs | 4 +- .../SwipeItemMenuItemHandler.Android.cs | 27 +++- .../SwipeItemMenuItemHandler.Tizen.cs | 9 ++ .../SwipeItemMenuItemHandler.Windows.cs | 119 +++++++++++++----- .../SwipeItemMenuItemHandler.cs | 41 +++--- .../SwipeItemMenuItemHandler.iOS.cs | 15 +++ .../SwipeItemMenuItemHandlerTests.Android.cs | 79 ++++++++++++ .../SwipeItemMenuItemHandlerTests.Windows.cs | 112 ++++++++++++++++- .../SwipeItemMenuItemHandlerTests.iOS.cs | 45 +++++++ 11 files changed, 389 insertions(+), 92 deletions(-) diff --git a/src/Controls/src/Core/SwipeView/SwipeItem.cs b/src/Controls/src/Core/SwipeView/SwipeItem.cs index 51d2fe2e7713..de056090b568 100644 --- a/src/Controls/src/Core/SwipeView/SwipeItem.cs +++ b/src/Controls/src/Core/SwipeView/SwipeItem.cs @@ -50,9 +50,9 @@ public bool IsVisible /// , then by a color contrasting ; image icons such as /// PNG and SVG render with their original colors. When set, this property tints font icons and image icons on /// Android, iOS, and MacCatalyst (including stream-based sources, since the resolved platform image is - /// tinted), and font, packaged file, and URI-based icons on Windows, and can be bound with - /// to follow the current theme. On Windows, packaged file and URI icons use - /// the color as a monochrome mask; rooted and stream-based image icons render with their original colors. + /// tinted), and font and packaged file icons on Windows, and can be bound with + /// to follow the current theme. On Windows, packaged file icons use + /// the color as a monochrome mask; URI, rooted, and stream-based image icons render with their original colors. /// All icons on Tizen currently render with their original colors regardless of this property. /// public Color IconColor diff --git a/src/Controls/tests/Core.UnitTests/SwipeViewTests.cs b/src/Controls/tests/Core.UnitTests/SwipeViewTests.cs index 3322345114a3..466b868d908a 100644 --- a/src/Controls/tests/Core.UnitTests/SwipeViewTests.cs +++ b/src/Controls/tests/Core.UnitTests/SwipeViewTests.cs @@ -1001,30 +1001,6 @@ public void InitialTextColorMappingDoesNotReloadSource() Assert.Equal(new[] { nameof(IMenuElement.Source) }, handler.UpdatedProperties); } - [Fact] - public void NewerIconLoadInvalidatesOlderLoadForCustomHandler() - { - var swipeItem = new SwipeItem { IconImageSource = "icon.png" }; - var platformView = new object(); - var handler = Substitute.For(); - handler.VirtualView.Returns((ISwipeItemMenuItem)swipeItem); - handler.PlatformView.Returns(platformView); - - var firstGeneration = SwipeItemMenuItemHandler.BeginIconLoad(handler); - var secondGeneration = SwipeItemMenuItemHandler.BeginIconLoad(handler); - - Assert.False(SwipeItemMenuItemHandler.IsIconLoadCurrent( - handler, - (ISwipeItemMenuItem)swipeItem, - platformView, - firstGeneration)); - Assert.True(SwipeItemMenuItemHandler.IsIconLoadCurrent( - handler, - (ISwipeItemMenuItem)swipeItem, - platformView, - secondGeneration)); - } - [Fact] public void SettingTextColorInvokesTextColorMapperOnce() { diff --git a/src/Core/src/Core/ISwipeItemMenuItem.cs b/src/Core/src/Core/ISwipeItemMenuItem.cs index 7988fa173439..2bdeea7c2437 100644 --- a/src/Core/src/Core/ISwipeItemMenuItem.cs +++ b/src/Core/src/Core/ISwipeItemMenuItem.cs @@ -31,8 +31,8 @@ public interface ISwipeItemMenuItemIconColor /// , then by a color contrasting the item background; /// image icons render with their original colors. When set, the color tints font icons and image /// icons on Android, iOS, and MacCatalyst (including stream-based sources, since the resolved - /// platform image is tinted), and font, packaged file, and URI icons on Windows. On Windows, - /// packaged file and URI icons use the color as a monochrome mask; rooted and stream-based image + /// platform image is tinted), and font and packaged file icons on Windows. On Windows, + /// packaged file icons use the color as a monochrome mask; URI, rooted, and stream-based image /// icons render with their original colors. All icons on Tizen currently render with their /// original colors regardless of this property. /// diff --git a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Android.cs b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Android.cs index 4079cf619ed7..9eac238d6459 100644 --- a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Android.cs +++ b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Android.cs @@ -167,6 +167,22 @@ void UpdateSize() PlatformView.SetPadding(0, buttonPadding, 0, buttonPadding); } + static partial void UpdateIconColorPlatform( + ISwipeItemMenuItemHandler handler, + ISwipeItemMenuItem view, + ref bool handled) + { + if (handler.PlatformView is not TextView button || + handler.SourceLoader is not ImageSourcePartLoader loader) + { + return; + } + + var drawables = button.GetCompoundDrawables(); + loader.Setter.SetImageSource(drawables.Length > 1 ? drawables[1] : null); + handled = true; + } + partial class SwipeItemMenuItemImageSourcePartSetter { public override void SetImageSource(Drawable? platformImage) @@ -177,6 +193,12 @@ public override void SetImageSource(Drawable? platformImage) if (platformImage is not null) { var iconSize = GetIconSize(Handler); + + // File/resource image services can return drawables backed by a shared ConstantState. + // Mutate first because custom drawables may return a distinct instance whose bounds + // still need to be initialized before it is attached. + platformImage = platformImage.Mutate(); + int drawableWidth = platformImage.IntrinsicWidth; int drawableHeight = platformImage.IntrinsicHeight; @@ -195,11 +217,6 @@ public override void SetImageSource(Drawable? platformImage) var tintColor = item.GetIconTintColor()?.ToPlatform(); - // File/resource image services can return drawables backed by a shared ConstantState, - // so mutate before changing the color filter to avoid bleeding the tint between - // SwipeItems that use the same resource. Attach the now-uniquely-stateful drawable. - platformImage = platformImage.Mutate(); - if (tintColor is not null) { platformImage.SetColorFilter(tintColor.Value, FilterMode.SrcAtop); diff --git a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Tizen.cs b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Tizen.cs index ddcd7e276e3b..9167cc45737b 100644 --- a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Tizen.cs +++ b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Tizen.cs @@ -61,6 +61,15 @@ public static void MapVisibility(ISwipeItemMenuItemHandler handler, ISwipeItemMe swipeView?.UpdateIsVisibleSwipeItem(view); } + static partial void UpdateIconColorPlatform( + ISwipeItemMenuItemHandler handler, + ISwipeItemMenuItem view, + ref bool handled) + { + // IconColor is not supported on Tizen, so a color-only change requires no image reload. + handled = true; + } + partial class SwipeItemMenuItemImageSourcePartSetter { public override void SetImageSource(MauiImageSource? platformImage) diff --git a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Windows.cs b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Windows.cs index fb5998f521ea..f8f97714760c 100644 --- a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Windows.cs +++ b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Windows.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Runtime.CompilerServices; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -15,6 +16,9 @@ namespace Microsoft.Maui.Handlers { public partial class SwipeItemMenuItemHandler : ElementHandler { + static readonly ConditionalWeakTable s_externalHandlerStates = new(); + int _iconLoadGeneration; + protected override WSwipeItem CreatePlatformElement() { return new WSwipeItem(); @@ -76,9 +80,9 @@ internal static async Task LoadFileIconAsync(ISwipeItemMenuItemHandler handler, return; } - // ImageIconSource renders the image as-is and ignores Foreground, so an explicit tint has to go - // through BitmapIconSource/FontIconSource instead, which honor Foreground (BitmapIconSource - // draws the bitmap as a monochrome mask by default). + // ImageIconSource renders the image as-is and ignores Foreground. Built-in packaged + // files can use BitmapIconSource's monochrome mask; font icons are rendered by their + // registered service with the resolved tint. Other source services keep their own colors. var tintColor = (item as ISwipeItemMenuItemIconColor)?.IconColor; try @@ -88,7 +92,7 @@ internal static async Task LoadFileIconAsync(ISwipeItemMenuItemHandler handler, if (tintColor is not null) { var sourceService = imageSourceServiceProvider.GetRequiredImageSourceService(source); - var tintedIconSource = CreateTintedIconSource(source, sourceService, handler.MauiContext); + var tintedIconSource = CreateTintedIconSource(source, sourceService); if (tintedIconSource is not null) { @@ -105,10 +109,13 @@ internal static async Task LoadFileIconAsync(ISwipeItemMenuItemHandler handler, var scale = handler.MauiContext.GetOptionalPlatformWindow()?.GetDisplayDensity() ?? 1.0f; IImageSource loadSource = source; - if (source is IFontImageSource { Color: null } fontImageSource && - item.GetTextColor() is Color fallbackColor) + if (source is IFontImageSource fontImageSource) { - loadSource = new TintedFontImageSource(fontImageSource, fallbackColor); + var resolvedFontColor = tintColor ?? + (fontImageSource.Color is null ? item.GetTextColor() : null); + + if (resolvedFontColor is Color fontColor) + loadSource = new TintedFontImageSource(fontImageSource, fontColor); } var service = imageSourceServiceProvider.GetRequiredImageSourceService(loadSource); @@ -134,35 +141,89 @@ internal static async Task LoadFileIconAsync(ISwipeItemMenuItemHandler handler, internal static IconSource? CreateTintedIconSource( IImageSource source, - IImageSourceService imageSourceService, - IMauiContext mauiContext) + IImageSourceService imageSourceService) { - if (source is IFileImageSource fileImageSource) + if (!CanCreateTintedIconSource(source, imageSourceService) || + source is not IFileImageSource fileImageSource) { - // The built-in Windows file service resolves relative paths from the flattened app - // package, matching BitmapIconSource. A custom service may resolve the same relative - // path from anywhere, so keep its service-based result rather than replacing it with - // an invalid ms-appx URI merely because a tint was requested. - if (imageSourceService.GetType() != typeof(FileImageSourceService)) - return null; - - var filename = fileImageSource.File; - if (string.IsNullOrEmpty(filename) || Path.IsPathRooted(filename)) - return null; - - return new BitmapIconSource - { - UriSource = new Uri("ms-appx:///" + Path.GetFileName(filename)) - }; + return null; } - if (source is IUriImageSource && - imageSourceService.GetType() != typeof(UriImageSourceService)) + return new BitmapIconSource { - return null; + UriSource = new Uri("ms-appx:///" + Path.GetFileName(fileImageSource.File)) + }; + } + + internal static bool CanCreateTintedIconSource( + IImageSource source, + IImageSourceService imageSourceService) => + source is IFileImageSource { File: string filename } && + imageSourceService.GetType() == typeof(FileImageSourceService) && + !string.IsNullOrEmpty(filename) && + !Path.IsPathRooted(filename); + + static partial void UpdateIconColorPlatform( + ISwipeItemMenuItemHandler handler, + ISwipeItemMenuItem view, + ref bool handled) + { + var source = view.Source; + if (source is null) + { + handled = true; + return; } - return source.ToIconSource(mauiContext); + if (source is IFontImageSource) + return; + + if (source is not IFileImageSource || handler.MauiContext is null) + { + handled = true; + return; + } + + try + { + var provider = handler.MauiContext.Services.GetRequiredService(); + var service = provider.GetRequiredImageSourceService(source); + handled = !CanCreateTintedIconSource(source, service); + } + catch (InvalidOperationException) + { + // Let the normal source update preserve existing error logging. + } + } + + internal static int BeginIconLoad(ISwipeItemMenuItemHandler handler) + { + if (handler is SwipeItemMenuItemHandler platformHandler) + return System.Threading.Interlocked.Increment(ref platformHandler._iconLoadGeneration); + + var state = s_externalHandlerStates.GetValue(handler, static _ => new HandlerState()); + return System.Threading.Interlocked.Increment(ref state.IconLoadGeneration); + } + + internal static bool IsIconLoadCurrent( + ISwipeItemMenuItemHandler handler, + ISwipeItemMenuItem item, + object platformView, + int generation) + { + int currentGeneration = handler is SwipeItemMenuItemHandler platformHandler + ? System.Threading.Volatile.Read(ref platformHandler._iconLoadGeneration) + : System.Threading.Volatile.Read( + ref s_externalHandlerStates.GetValue(handler, static _ => new HandlerState()).IconLoadGeneration); + + return generation == currentGeneration && + ReferenceEquals(handler.VirtualView, item) && + ReferenceEquals(handler.PlatformView, platformView); + } + + sealed class HandlerState + { + public int IconLoadGeneration; } sealed class TintedFontImageSource : IFontImageSource diff --git a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.cs b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.cs index e8f0d740aaf0..1a71b94cfc74 100644 --- a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.cs +++ b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.cs @@ -10,15 +10,12 @@ using PlatformView = System.Object; #endif -using System.Runtime.CompilerServices; using System.Threading.Tasks; namespace Microsoft.Maui.Handlers { public partial class SwipeItemMenuItemHandler : ISwipeItemMenuItemHandler { - static readonly ConditionalWeakTable s_handlerStates = new(); - public static IPropertyMapper Mapper = new PropertyMapper(ViewHandler.ElementMapper) { @@ -67,17 +64,12 @@ public static void MapSource(ISwipeItemMenuItemHandler handler, ISwipeItemMenuIt MapSourceAsync(handler, image).FireAndForget(handler); } - // The tint is resolved while the icon is being applied to the platform view, so reloading the - // source is what re-evaluates it. This also guarantees a previously applied tint is cleared - // rather than left stale when IconColor changes (for example on an app theme switch). - // Route through UpdateValue so user mapper customizations for Source (AppendToMapping / - // PrependToMapping) are preserved instead of bypassed by a direct MapSource call. public static void MapIconColor(ISwipeItemMenuItemHandler handler, ISwipeItemMenuItem view) { if (handler.IsMappingProperties()) return; - handler.UpdateValue(nameof(IMenuElement.Source)); + UpdateIconColor(handler, view); } internal static void UpdateBackgroundColorDependencies(ISwipeItemMenuItemHandler handler) @@ -96,24 +88,27 @@ internal static void UpdateTextColorIconDependency( view is not ISwipeItemMenuItemIconColor { IconColor: not null } && view.Source is IFontImageSource { Color: null }) { - handler.UpdateValue(nameof(IMenuElement.Source)); + UpdateIconColor(handler, view); } } - internal static HandlerState GetHandlerState(ISwipeItemMenuItemHandler handler) => - s_handlerStates.GetValue(handler, static _ => new HandlerState()); + static void UpdateIconColor( + ISwipeItemMenuItemHandler handler, + ISwipeItemMenuItem view) + { + bool handled = false; + UpdateIconColorPlatform(handler, view, ref handled); - internal static int BeginIconLoad(ISwipeItemMenuItemHandler handler) => - System.Threading.Interlocked.Increment(ref GetHandlerState(handler).IconLoadGeneration); + // Platforms that cannot safely reapply the attached native image must reload the + // source so the platform image type or rendered font color can be recomputed. + if (!handled) + handler.UpdateValue(nameof(IMenuElement.Source)); + } - internal static bool IsIconLoadCurrent( + static partial void UpdateIconColorPlatform( ISwipeItemMenuItemHandler handler, - ISwipeItemMenuItem item, - object platformView, - int generation) => - generation == System.Threading.Volatile.Read(ref GetHandlerState(handler).IconLoadGeneration) && - ReferenceEquals(handler.VirtualView, item) && - ReferenceEquals(handler.PlatformView, platformView); + ISwipeItemMenuItem view, + ref bool handled); public static Task MapSourceAsync(ISwipeItemMenuItemHandler handler, ISwipeItemMenuItem image) { @@ -135,9 +130,5 @@ public SwipeItemMenuItemImageSourcePartSetter(ISwipeItemMenuItemHandler handler) } } - internal sealed class HandlerState - { - public int IconLoadGeneration; - } } } diff --git a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.iOS.cs b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.iOS.cs index 08879ccbe887..bef52bb8b2c1 100644 --- a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.iOS.cs +++ b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.iOS.cs @@ -95,6 +95,21 @@ public static void MapVisibility(ISwipeItemMenuItemHandler handler, ISwipeItemMe swipeView?.UpdateIsVisibleSwipeItem(view); } + static partial void UpdateIconColorPlatform( + ISwipeItemMenuItemHandler handler, + ISwipeItemMenuItem view, + ref bool handled) + { + if (handler.PlatformView is not UIButton button || + handler.SourceLoader is not ImageSourcePartLoader loader) + { + return; + } + + loader.Setter.SetImageSource(button.ImageForState(UIControlState.Normal)); + handled = true; + } + partial class SwipeItemMenuItemImageSourcePartSetter { public override void SetImageSource(UIImage? platformImage) diff --git a/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Android.cs b/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Android.cs index 3349a41f486b..378dc0d9da28 100644 --- a/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Android.cs +++ b/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Android.cs @@ -1,7 +1,11 @@ +using System.Threading; using System.Threading.Tasks; +using Android.Graphics.Drawables; using Android.Widget; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Maui.DeviceTests.Stubs; using Microsoft.Maui.Handlers; +using Microsoft.Maui.Hosting; using Xunit; using static Microsoft.Maui.DeviceTests.AssertHelpers; @@ -43,10 +47,85 @@ await AssertEventually(() => GetTopDrawable(tintedButton) is not null && }); } + [Fact] + public async Task IconColorChangeReusesMutatedDrawable() + { + var imageService = new MutatingFileImageSourceService(); + EnsureHandlerCreated(builder => builder.ConfigureImageSources( + services => services.AddService(_ => imageService))); + + await InvokeOnMainThreadAsync(async () => + { + var item = new SwipeItemMenuItemStub(); + var handler = CreateHandler(item); + item.Source = new FileImageSourceStub("custom.png"); + + await SwipeItemMenuItemHandler.MapSourceAsync(handler, item); + + var drawable = GetTopDrawable(Assert.IsAssignableFrom(handler.PlatformView)); + Assert.Same(imageService.MutatedDrawable, drawable); + Assert.True(imageService.MutatedDrawable.BoundsSet); + Assert.Equal(1, imageService.LoadCount); + + item.IconColor = Colors.Red; + handler.UpdateValue(nameof(ISwipeItemMenuItemIconColor.IconColor)); + + Assert.Equal(1, imageService.LoadCount); + }); + } + static global::Android.Graphics.Drawables.Drawable GetTopDrawable(TextView textView) { var drawables = textView.GetCompoundDrawables(); return drawables.Length > 1 ? drawables[1] : null; } + + sealed class MutatingFileImageSourceService : IImageSourceService + { + readonly MutatingDrawable _sourceDrawable = new(); + + public TrackingDrawable MutatedDrawable => _sourceDrawable.MutatedDrawable; + + public int LoadCount { get; private set; } + + public Task LoadDrawableAsync( + IImageSource imageSource, + global::Android.Widget.ImageView imageView, + CancellationToken cancellationToken = default) + { + LoadCount++; + imageView.SetImageDrawable(_sourceDrawable); + return Task.FromResult( + new ImageSourceServiceResult(_sourceDrawable)); + } + + public Task> GetDrawableAsync( + IImageSource imageSource, + global::Android.Content.Context context, + CancellationToken cancellationToken = default) + { + LoadCount++; + return Task.FromResult>( + new ImageSourceServiceResult(_sourceDrawable)); + } + } + + sealed class MutatingDrawable : ColorDrawable + { + public TrackingDrawable MutatedDrawable { get; } = new(); + + public override Drawable Mutate() => MutatedDrawable; + } + + sealed class TrackingDrawable : ColorDrawable + { + public bool BoundsSet { get; private set; } + + public override void SetBounds(int left, int top, int right, int bottom) + { + BoundsSet = true; + base.SetBounds(left, top, right, bottom); + } + } } } diff --git a/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Windows.cs b/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Windows.cs index 78fb010e42bb..4969266df003 100644 --- a/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Windows.cs +++ b/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Windows.cs @@ -54,8 +54,7 @@ public Task TintedPackagedFileUsesFlattenedMauiAssetName() var icon = Assert.IsType( SwipeItemMenuItemHandler.CreateTintedIconSource( source, - service, - MauiContext)); + service)); Assert.Equal(new Uri("ms-appx:///delete.png"), icon.UriSource); }); @@ -72,8 +71,7 @@ public Task TintedRootedFileFallsBackToUntintedLoader() .GetRequiredImageSourceService(source); var icon = SwipeItemMenuItemHandler.CreateTintedIconSource( source, - service, - MauiContext); + service); Assert.Null(icon); }); @@ -104,6 +102,79 @@ await InvokeOnMainThreadAsync(async () => var icon = Assert.IsType(handler.PlatformView.IconSource); Assert.Same(image, icon.ImageSource); + + item.IconColor = Colors.Blue; + handler.UpdateValue(nameof(ISwipeItemMenuItemIconColor.IconColor)); + + Assert.Empty(imageService.Requests); + Assert.Same(icon, handler.PlatformView.IconSource); + }); + } + + [Fact] + public async Task UriImageUsesRegisteredServiceWhenTintIsRequested() + { + var imageService = new DelayedUriImageSourceService(); + EnsureHandlerCreated(builder => builder.ConfigureImageSources( + services => services.AddService(_ => imageService))); + + await InvokeOnMainThreadAsync(async () => + { + var item = new SwipeItemMenuItemStub + { + IconColor = Colors.Red, + Source = new UriImageSourceStub("https://example.com/delete.png") + }; + var handler = CreateHandler(item); + + var load = SwipeItemMenuItemHandler.MapSourceAsync(handler, item); + var request = imageService.Requests.Dequeue(); + var image = new BitmapImage(); + request.SetResult(new ImageSourceServiceResult(image)); + + await load; + + var icon = Assert.IsType(handler.PlatformView.IconSource); + Assert.Same(image, icon.ImageSource); + + item.IconColor = Colors.Blue; + handler.UpdateValue(nameof(ISwipeItemMenuItemIconColor.IconColor)); + + Assert.Empty(imageService.Requests); + Assert.Same(icon, handler.PlatformView.IconSource); + }); + } + + [Fact] + public async Task TintedFontImageUsesRegisteredService() + { + var imageService = new CapturingFontImageSourceService(); + EnsureHandlerCreated(builder => builder.ConfigureImageSources( + services => services.AddService(_ => imageService))); + + await InvokeOnMainThreadAsync(async () => + { + var source = new FontImageSourceStub + { + Color = Colors.Green, + Font = Font.Default, + Glyph = "A" + }; + var item = new SwipeItemMenuItemStub + { + IconColor = Colors.Red, + Source = source + }; + var handler = CreateHandler(item); + + await SwipeItemMenuItemHandler.MapSourceAsync(handler, item); + + var loadedSource = Assert.IsAssignableFrom(imageService.LastSource); + Assert.NotSame(source, loadedSource); + Assert.Equal(Colors.Red, loadedSource.Color); + + var icon = Assert.IsType(handler.PlatformView.IconSource); + Assert.Same(imageService.Image, icon.ImageSource); }); } @@ -158,5 +229,38 @@ sealed class DelayedFileImageSourceService : IImageSourceService + { + public Queue?>> Requests { get; } = new(); + + public Task?> GetImageSourceAsync( + IImageSource imageSource, + float scale = 1, + CancellationToken cancellationToken = default) + { + var completion = new TaskCompletionSource?>( + TaskCreationOptions.RunContinuationsAsynchronously); + Requests.Enqueue(completion); + return completion.Task; + } + } + + sealed class CapturingFontImageSourceService : IImageSourceService + { + public WImageSource Image { get; } = new BitmapImage(); + + public IImageSource? LastSource { get; private set; } + + public Task?> GetImageSourceAsync( + IImageSource imageSource, + float scale = 1, + CancellationToken cancellationToken = default) + { + LastSource = imageSource; + return Task.FromResult?>( + new ImageSourceServiceResult(Image)); + } + } } } diff --git a/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.iOS.cs b/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.iOS.cs index be4cdc1d7314..4f3233b8b75c 100644 --- a/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.iOS.cs +++ b/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.iOS.cs @@ -1,7 +1,10 @@ +using System.Threading; using System.Threading.Tasks; using CoreGraphics; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Maui.DeviceTests.Stubs; using Microsoft.Maui.Handlers; +using Microsoft.Maui.Hosting; using UIKit; using Xunit; using static Microsoft.Maui.DeviceTests.AssertHelpers; @@ -88,5 +91,47 @@ await AssertEventually(() => handler.PlatformView.ImageForState(UIControlState.Normal)?.RenderingMode == UIImageRenderingMode.AlwaysOriginal); }); } + + [Fact] + public async Task IconColorChangeDoesNotReloadImageSource() + { + var imageService = new CountingFileImageSourceService(); + EnsureHandlerCreated(builder => builder.ConfigureImageSources( + services => services.AddService(_ => imageService))); + + await InvokeOnMainThreadAsync(async () => + { + var item = new SwipeItemMenuItemStub(); + var handler = CreateHandler(item); + handler.PlatformView.Frame = new CGRect(0, 0, 100, 100); + item.Source = new FileImageSourceStub("custom.png"); + + await SwipeItemMenuItemHandler.MapSourceAsync(handler, item); + Assert.Equal(1, imageService.LoadCount); + + item.IconColor = Colors.Red; + handler.UpdateValue(nameof(ISwipeItemMenuItemIconColor.IconColor)); + + Assert.Equal(1, imageService.LoadCount); + Assert.Equal(Colors.Red, handler.PlatformView.TintColor.ToColor()); + }); + } + + sealed class CountingFileImageSourceService : IImageSourceService + { + readonly UIImage _image = UIImage.GetSystemImage("trash"); + + public int LoadCount { get; private set; } + + public Task> GetImageAsync( + IImageSource imageSource, + float scale = 1, + CancellationToken cancellationToken = default) + { + LoadCount++; + return Task.FromResult>( + new ImageSourceServiceResult(_image)); + } + } } } From 9b58e930365fffc597052b4da57f6b946d3209e7 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:46:37 +0200 Subject: [PATCH 15/22] Address SwipeItem platform review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .../SwipeItemMenuItemHandler.Android.cs | 23 +++--- .../SwipeItemMenuItemHandler.Windows.cs | 80 ++++++++++++++++--- .../SwipeItemMenuItemHandler.iOS.cs | 19 ++++- .../SwipeItemMenuItemHandlerTests.Android.cs | 37 ++++++++- .../SwipeItemMenuItemHandlerTests.Windows.cs | 38 +++++++++ .../SwipeItemMenuItemHandlerTests.iOS.cs | 28 ++++++- 6 files changed, 193 insertions(+), 32 deletions(-) diff --git a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Android.cs b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Android.cs index 9eac238d6459..706cea94b21d 100644 --- a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Android.cs +++ b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Android.cs @@ -179,7 +179,11 @@ static partial void UpdateIconColorPlatform( } var drawables = button.GetCompoundDrawables(); - loader.Setter.SetImageSource(drawables.Length > 1 ? drawables[1] : null); + var current = drawables.Length > 1 ? drawables[1] : null; + if (current is null) + return; + + loader.Setter.SetImageSource(current); handled = true; } @@ -193,11 +197,15 @@ public override void SetImageSource(Drawable? platformImage) if (platformImage is not null) { var iconSize = GetIconSize(Handler); + var tintColor = item.GetIconTintColor()?.ToPlatform(); + bool clearTint = tintColor is null && platformImage.ColorFilter is not null; - // File/resource image services can return drawables backed by a shared ConstantState. - // Mutate first because custom drawables may return a distinct instance whose bounds - // still need to be initialized before it is attached. - platformImage = platformImage.Mutate(); + if (tintColor is not null || clearTint) + { + // File/resource image services can return drawables backed by a shared + // ConstantState. Mutate only before changing its color filter. + platformImage = platformImage.Mutate(); + } int drawableWidth = platformImage.IntrinsicWidth; int drawableHeight = platformImage.IntrinsicHeight; @@ -215,15 +223,12 @@ public override void SetImageSource(Drawable? platformImage) platformImage.SetBounds(0, 0, iconWidth, iconHeight); } - var tintColor = item.GetIconTintColor()?.ToPlatform(); - if (tintColor is not null) { platformImage.SetColorFilter(tintColor.Value, FilterMode.SrcAtop); } - else + else if (clearTint) { - // Drawables can be cached and reused, so an earlier tint has to be cleared explicitly. platformImage.ClearColorFilter(); } } diff --git a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Windows.cs b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Windows.cs index f8f97714760c..eb3952f9eff4 100644 --- a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Windows.cs +++ b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Windows.cs @@ -18,6 +18,9 @@ public partial class SwipeItemMenuItemHandler : ElementHandler s_externalHandlerStates = new(); int _iconLoadGeneration; + IFontImageSource? _fontIconLoadSource; + Color? _fontIconLoadColor; + int _fontIconLoadGeneration; protected override WSwipeItem CreatePlatformElement() { @@ -74,6 +77,13 @@ internal static async Task LoadFileIconAsync(ISwipeItemMenuItemHandler handler, int generation = BeginIconLoad(handler); var source = item.Source; + var fontSource = source as IFontImageSource; + var resolvedFontColor = fontSource is null ? null : item.GetIconTintColor(); + bool fontIconApplied = false; + var platformHandler = handler as SwipeItemMenuItemHandler; + + platformHandler?.SetFontIconLoad(fontSource, resolvedFontColor, generation); + if (source is null) { swipeItem.IconSource = null; @@ -109,13 +119,10 @@ internal static async Task LoadFileIconAsync(ISwipeItemMenuItemHandler handler, var scale = handler.MauiContext.GetOptionalPlatformWindow()?.GetDisplayDensity() ?? 1.0f; IImageSource loadSource = source; - if (source is IFontImageSource fontImageSource) + if (fontSource is not null) { - var resolvedFontColor = tintColor ?? - (fontImageSource.Color is null ? item.GetTextColor() : null); - if (resolvedFontColor is Color fontColor) - loadSource = new TintedFontImageSource(fontImageSource, fontColor); + loadSource = new TintedFontImageSource(fontSource, fontColor); } var service = imageSourceServiceProvider.GetRequiredImageSourceService(loadSource); @@ -130,13 +137,24 @@ internal static async Task LoadFileIconAsync(ISwipeItemMenuItemHandler handler, if (ReferenceEquals(item.Source, source)) { - swipeItem.IconSource = result?.Value is WImageSource platformImage ? new ImageIconSource { ImageSource = platformImage } : null; + var iconSource = result?.Value is WImageSource platformImage ? new ImageIconSource { ImageSource = platformImage } : null; + swipeItem.IconSource = iconSource; + fontIconApplied = fontSource is not null && iconSource is not null; } } catch (System.Exception ex) { handler.MauiContext?.CreateLogger()?.Log(LogLevel.Warning, new EventId(), "Cannot load SwipeItem Icon", ex, static (state, _) => state); } + finally + { + if (fontSource is not null && + !fontIconApplied && + platformHandler is not null) + { + platformHandler.ClearFontIconLoad(generation); + } + } } internal static IconSource? CreateTintedIconSource( @@ -151,7 +169,8 @@ internal static async Task LoadFileIconAsync(ISwipeItemMenuItemHandler handler, return new BitmapIconSource { - UriSource = new Uri("ms-appx:///" + Path.GetFileName(fileImageSource.File)) + UriSource = new Uri("ms-appx:///" + Path.GetFileName(fileImageSource.File)), + ShowAsMonochrome = true }; } @@ -175,8 +194,16 @@ static partial void UpdateIconColorPlatform( return; } - if (source is IFontImageSource) + if (source is IFontImageSource fontSource) + { + if (handler is SwipeItemMenuItemHandler platformHandler && + platformHandler.IsFontIconLoadCurrent(fontSource, view.GetIconTintColor())) + { + handled = true; + } + return; + } if (source is not IFileImageSource || handler.MauiContext is null) { @@ -211,16 +238,45 @@ internal static bool IsIconLoadCurrent( object platformView, int generation) { - int currentGeneration = handler is SwipeItemMenuItemHandler platformHandler - ? System.Threading.Volatile.Read(ref platformHandler._iconLoadGeneration) - : System.Threading.Volatile.Read( - ref s_externalHandlerStates.GetValue(handler, static _ => new HandlerState()).IconLoadGeneration); + int currentGeneration; + if (handler is SwipeItemMenuItemHandler platformHandler) + { + currentGeneration = System.Threading.Volatile.Read(ref platformHandler._iconLoadGeneration); + } + else if (s_externalHandlerStates.TryGetValue(handler, out var state)) + { + currentGeneration = System.Threading.Volatile.Read(ref state.IconLoadGeneration); + } + else + { + return false; + } return generation == currentGeneration && ReferenceEquals(handler.VirtualView, item) && ReferenceEquals(handler.PlatformView, platformView); } + void SetFontIconLoad(IFontImageSource? source, Color? color, int generation) + { + _fontIconLoadSource = source; + _fontIconLoadColor = color; + _fontIconLoadGeneration = generation; + } + + void ClearFontIconLoad(int generation) + { + if (_fontIconLoadGeneration != generation) + return; + + _fontIconLoadSource = null; + _fontIconLoadColor = null; + } + + bool IsFontIconLoadCurrent(IFontImageSource source, Color? color) => + ReferenceEquals(_fontIconLoadSource, source) && + Equals(_fontIconLoadColor, color); + sealed class HandlerState { public int IconLoadGeneration; diff --git a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.iOS.cs b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.iOS.cs index bef52bb8b2c1..9b095ebcf878 100644 --- a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.iOS.cs +++ b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.iOS.cs @@ -25,6 +25,7 @@ public override CGRect Frame public partial class SwipeItemMenuItemHandler : ElementHandler { readonly SwipeItemButtonProxy _proxy = new(); + UIColor? _defaultTitleColor; protected override UIButton CreatePlatformElement() { @@ -39,6 +40,7 @@ protected override UIButton CreatePlatformElement() protected override void ConnectHandler(UIButton platformView) { + _defaultTitleColor = platformView.CurrentTitleColor; base.ConnectHandler(platformView); if (platformView is SwipeItemButton swipeItemButton) @@ -51,12 +53,17 @@ protected override void DisconnectHandler(UIButton platformView) if (platformView is SwipeItemButton swipeItemButton) _proxy.Disconnect(swipeItemButton); + + _defaultTitleColor = null; } public static void MapTextColor(ISwipeItemMenuItemHandler handler, ISwipeItemMenuItem view) { - var color = view.GetTextColor(); - handler.PlatformView.SetTitleColor(color?.ToPlatform(), UIControlState.Normal); + var color = view.GetTextColor()?.ToPlatform(); + if (color is null && handler is SwipeItemMenuItemHandler platformHandler) + color = platformHandler._defaultTitleColor; + + handler.PlatformView.SetTitleColor(color, UIControlState.Normal); UpdateTextColorIconDependency(handler, view); } @@ -106,7 +113,11 @@ static partial void UpdateIconColorPlatform( return; } - loader.Setter.SetImageSource(button.ImageForState(UIControlState.Normal)); + var current = button.ImageForState(UIControlState.Normal); + if (current is null) + return; + + loader.Setter.SetImageSource(current); handled = true; } @@ -158,7 +169,7 @@ static UIImage MaxResizeSwipeItemIconImage(UIImage sourceImage, nfloat maxWidth, var sourceSize = sourceImage.Size; var maxResizeFactor = Math.Min(maxWidth / sourceSize.Width, maxHeight / sourceSize.Height); - if (maxResizeFactor > 1) + if (maxResizeFactor >= 1) { return sourceImage; } diff --git a/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Android.cs b/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Android.cs index 378dc0d9da28..af29af033a82 100644 --- a/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Android.cs +++ b/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Android.cs @@ -48,7 +48,24 @@ await AssertEventually(() => GetTopDrawable(tintedButton) is not null && } [Fact] - public async Task IconColorChangeReusesMutatedDrawable() + public async Task IconColorLoadsSourceWhenPlatformImageIsMissing() + { + await InvokeOnMainThreadAsync(async () => + { + var item = new SwipeItemMenuItemStub(); + var handler = CreateHandler(item); + var button = Assert.IsAssignableFrom(handler.PlatformView); + + item.Source = new FileImageSourceStub("red.png"); + item.IconColor = Colors.Red; + handler.UpdateValue(nameof(ISwipeItemMenuItemIconColor.IconColor)); + + await AssertEventually(() => GetTopDrawable(button)?.ColorFilter is not null); + }); + } + + [Fact] + public async Task IconColorChangeMutatesAttachedDrawableWithoutReloading() { var imageService = new MutatingFileImageSourceService(); EnsureHandlerCreated(builder => builder.ConfigureImageSources( @@ -63,13 +80,17 @@ await InvokeOnMainThreadAsync(async () => await SwipeItemMenuItemHandler.MapSourceAsync(handler, item); var drawable = GetTopDrawable(Assert.IsAssignableFrom(handler.PlatformView)); - Assert.Same(imageService.MutatedDrawable, drawable); - Assert.True(imageService.MutatedDrawable.BoundsSet); + Assert.Same(imageService.SourceDrawable, drawable); + Assert.Equal(0, imageService.SourceDrawable.MutateCount); Assert.Equal(1, imageService.LoadCount); item.IconColor = Colors.Red; handler.UpdateValue(nameof(ISwipeItemMenuItemIconColor.IconColor)); + drawable = GetTopDrawable(Assert.IsAssignableFrom(handler.PlatformView)); + Assert.Same(imageService.MutatedDrawable, drawable); + Assert.True(imageService.MutatedDrawable.BoundsSet); + Assert.Equal(1, imageService.SourceDrawable.MutateCount); Assert.Equal(1, imageService.LoadCount); }); } @@ -84,6 +105,8 @@ sealed class MutatingFileImageSourceService : IImageSourceService _sourceDrawable; + public TrackingDrawable MutatedDrawable => _sourceDrawable.MutatedDrawable; public int LoadCount { get; private set; } @@ -114,7 +137,13 @@ sealed class MutatingDrawable : ColorDrawable { public TrackingDrawable MutatedDrawable { get; } = new(); - public override Drawable Mutate() => MutatedDrawable; + public int MutateCount { get; private set; } + + public override Drawable Mutate() + { + MutateCount++; + return MutatedDrawable; + } } sealed class TrackingDrawable : ColorDrawable diff --git a/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Windows.cs b/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Windows.cs index 4969266df003..690151660603 100644 --- a/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Windows.cs +++ b/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Windows.cs @@ -57,6 +57,7 @@ public Task TintedPackagedFileUsesFlattenedMauiAssetName() service)); Assert.Equal(new Uri("ms-appx:///delete.png"), icon.UriSource); + Assert.True(icon.ShowAsMonochrome); }); } @@ -178,6 +179,40 @@ await InvokeOnMainThreadAsync(async () => }); } + [Fact] + public async Task FontImageReloadsOnlyWhenResolvedTintChanges() + { + var imageService = new CapturingFontImageSourceService(); + EnsureHandlerCreated(builder => builder.ConfigureImageSources( + services => services.AddService(_ => imageService))); + + await InvokeOnMainThreadAsync(async () => + { + var item = new SwipeItemMenuItemStub + { + TextColor = Colors.Blue + }; + var handler = CreateHandler(item); + item.Source = new FontImageSourceStub + { + Font = Font.Default, + Glyph = "A" + }; + + await SwipeItemMenuItemHandler.MapSourceAsync(handler, item); + Assert.Equal(1, imageService.LoadCount); + + handler.UpdateValue(nameof(ITextStyle.TextColor)); + Assert.Equal(1, imageService.LoadCount); + + item.TextColor = Colors.Red; + handler.UpdateValue(nameof(ITextStyle.TextColor)); + + Assert.Equal(2, imageService.LoadCount); + Assert.Equal(Colors.Red, Assert.IsAssignableFrom(imageService.LastSource).Color); + }); + } + [Fact] public async Task StaleSameSourceLoadCannotOverwriteNewerTintChange() { @@ -252,11 +287,14 @@ sealed class CapturingFontImageSourceService : IImageSourceService?> GetImageSourceAsync( IImageSource imageSource, float scale = 1, CancellationToken cancellationToken = default) { + LoadCount++; LastSource = imageSource; return Task.FromResult?>( new ImageSourceServiceResult(Image)); diff --git a/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.iOS.cs b/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.iOS.cs index 4f3233b8b75c..e3d34e258034 100644 --- a/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.iOS.cs +++ b/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.iOS.cs @@ -21,17 +21,20 @@ public Task TextColorCanBeCleared() { var item = new SwipeItemMenuItemStub { - Text = "Delete", - TextColor = Colors.Red + Text = "Delete" }; var handler = CreateHandler(item); + var defaultTitleColor = handler.PlatformView.CurrentTitleColor.ToColor(); + + item.TextColor = Colors.Red; + handler.UpdateValue(nameof(ITextStyle.TextColor)); Assert.Equal(Colors.Red, handler.PlatformView.CurrentTitleColor.ToColor()); item.TextColor = null; handler.UpdateValue(nameof(ITextStyle.TextColor)); - Assert.NotEqual(Colors.Red, handler.PlatformView.CurrentTitleColor.ToColor()); + Assert.Equal(defaultTitleColor, handler.PlatformView.CurrentTitleColor.ToColor()); }); } @@ -92,6 +95,25 @@ await AssertEventually(() => }); } + [Fact] + public async Task IconColorLoadsSourceWhenPlatformImageIsMissing() + { + await InvokeOnMainThreadAsync(async () => + { + var item = new SwipeItemMenuItemStub(); + var handler = CreateHandler(item); + handler.PlatformView.Frame = new CGRect(0, 0, 100, 100); + + item.Source = new FileImageSourceStub("red.png"); + item.IconColor = Colors.Blue; + handler.UpdateValue(nameof(ISwipeItemMenuItemIconColor.IconColor)); + + await AssertEventually(() => + handler.PlatformView.ImageForState(UIControlState.Normal) is not null && + handler.PlatformView.TintColor?.ToColor() == Colors.Blue); + }); + } + [Fact] public async Task IconColorChangeDoesNotReloadImageSource() { From c8f0001d7a2d5274774f9998d2d8637852f29231 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:57:16 +0200 Subject: [PATCH 16/22] Preserve custom Windows font image services Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .../SwipeItemMenuItemHandler.Windows.cs | 18 +++++-- .../SwipeItemMenuItemHandlerTests.Windows.cs | 50 +++++++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Windows.cs b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Windows.cs index eb3952f9eff4..a739e3bfc975 100644 --- a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Windows.cs +++ b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Windows.cs @@ -98,10 +98,10 @@ internal static async Task LoadFileIconAsync(ISwipeItemMenuItemHandler handler, try { var imageSourceServiceProvider = handler.MauiContext.Services.GetRequiredService(); + var sourceService = imageSourceServiceProvider.GetRequiredImageSourceService(source); if (tintColor is not null) { - var sourceService = imageSourceServiceProvider.GetRequiredImageSourceService(source); var tintedIconSource = CreateTintedIconSource(source, sourceService); if (tintedIconSource is not null) @@ -119,13 +119,25 @@ internal static async Task LoadFileIconAsync(ISwipeItemMenuItemHandler handler, var scale = handler.MauiContext.GetOptionalPlatformWindow()?.GetDisplayDensity() ?? 1.0f; IImageSource loadSource = source; + var service = sourceService; if (fontSource is not null) { if (resolvedFontColor is Color fontColor) - loadSource = new TintedFontImageSource(fontSource, fontColor); + { + var tintedFontSource = new TintedFontImageSource(fontSource, fontColor); + var tintedService = imageSourceServiceProvider.GetRequiredImageSourceService(tintedFontSource); + + // Preserve a concrete custom source registration. Interface-level services, + // including the built-in font service, resolve both source types to the same + // singleton and can safely receive the color-carrying wrapper. + if (ReferenceEquals(sourceService, tintedService)) + { + loadSource = tintedFontSource; + service = tintedService; + } + } } - var service = imageSourceServiceProvider.GetRequiredImageSourceService(loadSource); // Do not use ConfigureAwait(false): WinUI DependencyProperty writes require the UI thread. var result = await service.GetImageSourceAsync(loadSource, scale); diff --git a/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Windows.cs b/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Windows.cs index 690151660603..457a3d848458 100644 --- a/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Windows.cs +++ b/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Windows.cs @@ -179,6 +179,35 @@ await InvokeOnMainThreadAsync(async () => }); } + [Fact] + public async Task ConcreteCustomFontImageUsesRegisteredServiceWhenTintIsRequested() + { + var imageService = new CapturingCustomFontImageSourceService(); + EnsureHandlerCreated(builder => builder.ConfigureImageSources( + services => services.AddService(_ => imageService))); + + await InvokeOnMainThreadAsync(async () => + { + var source = new CustomFontImageSourceStub + { + Font = Font.Default, + Glyph = "A" + }; + var item = new SwipeItemMenuItemStub + { + IconColor = Colors.Red, + Source = source + }; + var handler = CreateHandler(item); + + await SwipeItemMenuItemHandler.MapSourceAsync(handler, item); + + Assert.Same(source, imageService.LastSource); + var icon = Assert.IsType(handler.PlatformView.IconSource); + Assert.Same(imageService.Image, icon.ImageSource); + }); + } + [Fact] public async Task FontImageReloadsOnlyWhenResolvedTintChanges() { @@ -281,6 +310,27 @@ sealed class DelayedUriImageSourceService : IImageSourceService } } + sealed class CustomFontImageSourceStub : FontImageSourceStub + { + } + + sealed class CapturingCustomFontImageSourceService : IImageSourceService + { + public WImageSource Image { get; } = new BitmapImage(); + + public IImageSource? LastSource { get; private set; } + + public Task?> GetImageSourceAsync( + IImageSource imageSource, + float scale = 1, + CancellationToken cancellationToken = default) + { + LastSource = imageSource; + return Task.FromResult?>( + new ImageSourceServiceResult(Image)); + } + } + sealed class CapturingFontImageSourceService : IImageSourceService { public WImageSource Image { get; } = new BitmapImage(); From f8601201b961feb8052425d5186e48a972378a06 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:28:46 +0200 Subject: [PATCH 17/22] Avoid redundant font tint wrappers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .../SwipeItemMenuItemHandler.Windows.cs | 2 +- .../SwipeItemMenuItemHandlerTests.Windows.cs | 34 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Windows.cs b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Windows.cs index a739e3bfc975..97ea649ab189 100644 --- a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Windows.cs +++ b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Windows.cs @@ -122,7 +122,7 @@ internal static async Task LoadFileIconAsync(ISwipeItemMenuItemHandler handler, var service = sourceService; if (fontSource is not null) { - if (resolvedFontColor is Color fontColor) + if (resolvedFontColor is Color fontColor && fontSource.Color != fontColor) { var tintedFontSource = new TintedFontImageSource(fontSource, fontColor); var tintedService = imageSourceServiceProvider.GetRequiredImageSourceService(tintedFontSource); diff --git a/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Windows.cs b/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Windows.cs index 457a3d848458..c174f18589e9 100644 --- a/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Windows.cs +++ b/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Windows.cs @@ -179,6 +179,40 @@ await InvokeOnMainThreadAsync(async () => }); } + [Fact] + public async Task FontImageUsesOriginalSourceWhenResolvedTintMatchesSourceColor() + { + var imageService = new CapturingFontImageSourceService(); + EnsureHandlerCreated(builder => builder.ConfigureImageSources( + services => services.AddService(_ => imageService))); + + await InvokeOnMainThreadAsync(async () => + { + var sourceColor = new Color(0.2f, 0.4f, 0.6f); + var requestedColor = new Color(0.2f, 0.4f, 0.6f); + var source = new FontImageSourceStub + { + Color = sourceColor, + Font = Font.Default, + Glyph = "A" + }; + var item = new SwipeItemMenuItemStub + { + IconColor = requestedColor, + Source = source + }; + var handler = CreateHandler(item); + + await SwipeItemMenuItemHandler.MapSourceAsync(handler, item); + + Assert.NotSame(sourceColor, requestedColor); + Assert.Equal(sourceColor, requestedColor); + Assert.Same(source, imageService.LastSource); + var icon = Assert.IsType(handler.PlatformView.IconSource); + Assert.Same(imageService.Image, icon.ImageSource); + }); + } + [Fact] public async Task ConcreteCustomFontImageUsesRegisteredServiceWhenTintIsRequested() { From fa79dcf1a59034cf072929bdef09bba5df5c421b Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:56:24 +0200 Subject: [PATCH 18/22] Clear SwipeItem font state on disconnect Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .../SwipeItemMenuItemHandler.Windows.cs | 6 ++-- .../SwipeItemMenuItemHandlerTests.Windows.cs | 31 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Windows.cs b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Windows.cs index 97ea649ab189..e36497acce0d 100644 --- a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Windows.cs +++ b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Windows.cs @@ -58,8 +58,10 @@ protected override void ConnectHandler(WSwipeItem platformView) protected override void DisconnectHandler(WSwipeItem platformView) { + System.Threading.Interlocked.Increment(ref _iconLoadGeneration); + ClearFontIconLoad(_fontIconLoadGeneration); base.DisconnectHandler(platformView); - PlatformView.Invoked -= OnSwipeItemInvoked; + platformView.Invoked -= OnSwipeItemInvoked; } void OnSwipeItemInvoked(WSwipeItem sender, Microsoft.UI.Xaml.Controls.SwipeItemInvokedEventArgs args) @@ -285,7 +287,7 @@ void ClearFontIconLoad(int generation) _fontIconLoadColor = null; } - bool IsFontIconLoadCurrent(IFontImageSource source, Color? color) => + internal bool IsFontIconLoadCurrent(IFontImageSource source, Color? color) => ReferenceEquals(_fontIconLoadSource, source) && Equals(_fontIconLoadColor, color); diff --git a/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Windows.cs b/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Windows.cs index c174f18589e9..3a5925fc5c81 100644 --- a/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Windows.cs +++ b/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Windows.cs @@ -242,6 +242,37 @@ await InvokeOnMainThreadAsync(async () => }); } + [Fact] + public async Task DisconnectClearsFontImageLoadState() + { + var imageService = new CapturingFontImageSourceService(); + EnsureHandlerCreated(builder => builder.ConfigureImageSources( + services => services.AddService(_ => imageService))); + + await InvokeOnMainThreadAsync(async () => + { + var source = new FontImageSourceStub + { + Color = Colors.Green, + Font = Font.Default, + Glyph = "A" + }; + var item = new SwipeItemMenuItemStub + { + IconColor = Colors.Red, + Source = source + }; + var handler = CreateHandler(item); + + await SwipeItemMenuItemHandler.MapSourceAsync(handler, item); + Assert.True(handler.IsFontIconLoadCurrent(source, Colors.Red)); + + ((IElementHandler)handler).DisconnectHandler(); + + Assert.False(handler.IsFontIconLoadCurrent(source, Colors.Red)); + }); + } + [Fact] public async Task FontImageReloadsOnlyWhenResolvedTintChanges() { From 87d27a7c5844d61bc0496c7c23cd21ba6cee11da Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Sat, 8 Aug 2026 05:23:11 +0200 Subject: [PATCH 19/22] Address SwipeItem icon tint review feedback Clarify platform-specific icon behavior, reset Windows font load state unconditionally, and cover external-handler generation tracking and iOS color-only sizing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .../SwipeItemMenuItemHandler.Tizen.cs | 3 +- .../SwipeItemMenuItemHandler.Windows.cs | 8 +++- .../SwipeItemMenuItemHandler.iOS.cs | 2 + .../SwipeItemMenuItemHandlerTests.Windows.cs | 39 +++++++++++++++++++ .../SwipeItemMenuItemHandlerTests.iOS.cs | 2 + 5 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Tizen.cs b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Tizen.cs index 9167cc45737b..073e725ed937 100644 --- a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Tizen.cs +++ b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Tizen.cs @@ -66,7 +66,8 @@ static partial void UpdateIconColorPlatform( ISwipeItemMenuItem view, ref bool handled) { - // IconColor is not supported on Tizen, so a color-only change requires no image reload. + // SwipeItemMenuItemImageSourcePartSetter.SetImageSource only assigns ResourceUrl and + // does not apply GetIconTintColor(); update both paths together if Tizen adds tinting. handled = true; } diff --git a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Windows.cs b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Windows.cs index e36497acce0d..fc909753b9c2 100644 --- a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Windows.cs +++ b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Windows.cs @@ -59,7 +59,7 @@ protected override void ConnectHandler(WSwipeItem platformView) protected override void DisconnectHandler(WSwipeItem platformView) { System.Threading.Interlocked.Increment(ref _iconLoadGeneration); - ClearFontIconLoad(_fontIconLoadGeneration); + ResetFontIconLoad(); base.DisconnectHandler(platformView); platformView.Invoked -= OnSwipeItemInvoked; } @@ -283,8 +283,14 @@ void ClearFontIconLoad(int generation) if (_fontIconLoadGeneration != generation) return; + ResetFontIconLoad(); + } + + void ResetFontIconLoad() + { _fontIconLoadSource = null; _fontIconLoadColor = null; + _fontIconLoadGeneration = 0; } internal bool IsFontIconLoadCurrent(IFontImageSource source, Color? color) => diff --git a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.iOS.cs b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.iOS.cs index 9b095ebcf878..3d6299c79970 100644 --- a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.iOS.cs +++ b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.iOS.cs @@ -169,6 +169,8 @@ static UIImage MaxResizeSwipeItemIconImage(UIImage sourceImage, nfloat maxWidth, var sourceSize = sourceImage.Size; var maxResizeFactor = Math.Min(maxWidth / sourceSize.Width, maxHeight / sourceSize.Height); + // Color-only updates feed the already-sized native image back through this setter. + // Keep an image that exactly fits the bounds instead of redrawing it. if (maxResizeFactor >= 1) { return sourceImage; diff --git a/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Windows.cs b/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Windows.cs index 3a5925fc5c81..d1aeac26309b 100644 --- a/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Windows.cs +++ b/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Windows.cs @@ -11,6 +11,7 @@ using Microsoft.UI.Xaml.Media.Imaging; using Xunit; using WImageSource = Microsoft.UI.Xaml.Media.ImageSource; +using WSwipeItem = Microsoft.UI.Xaml.Controls.SwipeItem; namespace Microsoft.Maui.DeviceTests { @@ -273,6 +274,34 @@ await InvokeOnMainThreadAsync(async () => }); } + [Fact] + public Task ExternalHandlerUsesGenerationTracking() + { + return InvokeOnMainThreadAsync(() => + { + var item = new SwipeItemMenuItemStub(); + var handler = new ExternalSwipeItemMenuItemHandler(); + handler.SetMauiContext(MauiContext); + handler.SetVirtualView(item); + + var staleGeneration = SwipeItemMenuItemHandler.BeginIconLoad(handler); + var currentGeneration = SwipeItemMenuItemHandler.BeginIconLoad(handler); + + Assert.False(SwipeItemMenuItemHandler.IsIconLoadCurrent( + handler, + item, + handler.PlatformView, + staleGeneration)); + Assert.True(SwipeItemMenuItemHandler.IsIconLoadCurrent( + handler, + item, + handler.PlatformView, + currentGeneration)); + + ((IElementHandler)handler).DisconnectHandler(); + }); + } + [Fact] public async Task FontImageReloadsOnlyWhenResolvedTintChanges() { @@ -415,5 +444,15 @@ sealed class CapturingFontImageSourceService : IImageSourceService, ISwipeItemMenuItemHandler + { + public ExternalSwipeItemMenuItemHandler() + : base(new PropertyMapper()) + { + } + + protected override WSwipeItem CreatePlatformElement() => new(); + } } } diff --git a/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.iOS.cs b/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.iOS.cs index e3d34e258034..cbebd03b625a 100644 --- a/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.iOS.cs +++ b/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.iOS.cs @@ -130,12 +130,14 @@ await InvokeOnMainThreadAsync(async () => await SwipeItemMenuItemHandler.MapSourceAsync(handler, item); Assert.Equal(1, imageService.LoadCount); + var originalSize = handler.PlatformView.ImageForState(UIControlState.Normal).Size; item.IconColor = Colors.Red; handler.UpdateValue(nameof(ISwipeItemMenuItemIconColor.IconColor)); Assert.Equal(1, imageService.LoadCount); Assert.Equal(Colors.Red, handler.PlatformView.TintColor.ToColor()); + Assert.Equal(originalSize, handler.PlatformView.ImageForState(UIControlState.Normal).Size); }); } From 9d5d99df01da0f2f454df512b9ca4cca68dce2a4 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Sat, 8 Aug 2026 08:53:48 +0200 Subject: [PATCH 20/22] Fix SwipeItem tint clearing and test waits Track Android tint ownership instead of relying on Drawable.ColorFilter, and make the delayed Windows image tests fail fast without duplicate initial loads. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .../SwipeItemMenuItemHandler.Android.cs | 43 ++++++++--- .../SwipeItemMenuItemHandlerTests.Android.cs | 75 +++++++++++++++++++ .../SwipeItemMenuItemHandlerTests.Windows.cs | 50 +++++++++---- 3 files changed, 142 insertions(+), 26 deletions(-) diff --git a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Android.cs b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Android.cs index 706cea94b21d..4b3121ce1c8b 100644 --- a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Android.cs +++ b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Android.cs @@ -13,6 +13,10 @@ namespace Microsoft.Maui.Handlers { public partial class SwipeItemMenuItemHandler : ElementHandler { + const int IconDrawableSlot = 1; + + Drawable? _appliedIconTintDrawable; + protected override void ConnectHandler(AView platformView) { base.ConnectHandler(platformView); @@ -26,8 +30,9 @@ void OnViewAttachedToWindow(object? sender, AView.ViewAttachedToWindowEventArgs protected override void DisconnectHandler(AView platformView) { - base.DisconnectHandler(platformView); platformView.ViewAttachedToWindow -= OnViewAttachedToWindow; + _appliedIconTintDrawable = null; + base.DisconnectHandler(platformView); } public static void MapTextColor(ISwipeItemMenuItemHandler handler, ITextStyle view) @@ -154,10 +159,9 @@ void UpdateSize() if (PlatformView is TextView textView) { lineHeight = !string.IsNullOrEmpty(textView.Text) ? (int)textView.LineHeight : 0; - var icons = textView.GetCompoundDrawables(); - if (icons.Length > 1 && icons[1] != null) + if (GetIconDrawable(textView) is Drawable icon) { - SourceLoader.Setter.SetImageSource(icons[1]); + SourceLoader.Setter.SetImageSource(icon); } } @@ -178,27 +182,42 @@ static partial void UpdateIconColorPlatform( return; } - var drawables = button.GetCompoundDrawables(); - var current = drawables.Length > 1 ? drawables[1] : null; - if (current is null) + if (GetIconDrawable(button) is not Drawable current) return; loader.Setter.SetImageSource(current); handled = true; } + static Drawable? GetIconDrawable(TextView textView) + { + var drawables = textView.GetCompoundDrawables(); + return drawables.Length > IconDrawableSlot ? drawables[IconDrawableSlot] : null; + } + + static void SetIconDrawable(TextView textView, Drawable? drawable) + { + textView.SetCompoundDrawables(null, drawable, null, null); + } + partial class SwipeItemMenuItemImageSourcePartSetter { public override void SetImageSource(Drawable? platformImage) { - if (Handler?.PlatformView is not TextView button || Handler?.VirtualView is not ISwipeItemMenuItem item) + if (Handler is not SwipeItemMenuItemHandler platformHandler || + Handler.PlatformView is not TextView button || + Handler.VirtualView is not ISwipeItemMenuItem item) return; + var tintColor = item.GetIconTintColor()?.ToPlatform(); + if (platformImage is not null) { var iconSize = GetIconSize(Handler); - var tintColor = item.GetIconTintColor()?.ToPlatform(); - bool clearTint = tintColor is null && platformImage.ColorFilter is not null; + // Drawable.ColorFilter is not authoritative because the base Android + // Drawable implementation always returns null. + bool clearTint = tintColor is null && + ReferenceEquals(platformImage, platformHandler._appliedIconTintDrawable); if (tintColor is not null || clearTint) { @@ -233,7 +252,9 @@ public override void SetImageSource(Drawable? platformImage) } } - button.SetCompoundDrawables(null, platformImage, null, null); + platformHandler._appliedIconTintDrawable = + tintColor is not null ? platformImage : null; + SetIconDrawable(button, platformImage); } } } diff --git a/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Android.cs b/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Android.cs index af29af033a82..8a29c949f453 100644 --- a/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Android.cs +++ b/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Android.cs @@ -1,5 +1,6 @@ using System.Threading; using System.Threading.Tasks; +using Android.Graphics; using Android.Graphics.Drawables; using Android.Widget; using Microsoft.Extensions.DependencyInjection; @@ -47,6 +48,36 @@ await AssertEventually(() => GetTopDrawable(tintedButton) is not null && }); } + [Fact] + public async Task IconTintCanBeClearedWhenDrawableDoesNotReportColorFilter() + { + var imageService = new NonReportingFileImageSourceService(); + EnsureHandlerCreated(builder => builder.ConfigureImageSources( + services => services.AddService(_ => imageService))); + + await InvokeOnMainThreadAsync(async () => + { + var item = new SwipeItemMenuItemStub + { + IconColor = Colors.Blue + }; + var handler = CreateHandler(item); + item.Source = new FileImageSourceStub("custom.png"); + + await SwipeItemMenuItemHandler.MapSourceAsync(handler, item); + + var drawable = GetTopDrawable(Assert.IsAssignableFrom(handler.PlatformView)); + Assert.Same(imageService.Drawable, drawable); + Assert.Null(drawable.ColorFilter); + Assert.NotNull(imageService.Drawable.AppliedColorFilter); + + item.IconColor = null; + handler.UpdateValue(nameof(ISwipeItemMenuItemIconColor.IconColor)); + + Assert.Null(imageService.Drawable.AppliedColorFilter); + }); + } + [Fact] public async Task IconColorLoadsSourceWhenPlatformImageIsMissing() { @@ -133,6 +164,50 @@ public Task> GetDrawableAsync( } } + sealed class NonReportingFileImageSourceService : IImageSourceService + { + public NonReportingColorFilterDrawable Drawable { get; } = new(); + + public Task LoadDrawableAsync( + IImageSource imageSource, + global::Android.Widget.ImageView imageView, + CancellationToken cancellationToken = default) + { + imageView.SetImageDrawable(Drawable); + return Task.FromResult( + new ImageSourceServiceResult(Drawable)); + } + + public Task> GetDrawableAsync( + IImageSource imageSource, + global::Android.Content.Context context, + CancellationToken cancellationToken = default) + { + return Task.FromResult>( + new ImageSourceServiceResult(Drawable)); + } + } + + sealed class NonReportingColorFilterDrawable : Drawable + { + public ColorFilter? AppliedColorFilter { get; private set; } + + public override int Opacity => (int)Format.Translucent; + + public override void Draw(Canvas canvas) + { + } + + public override void SetAlpha(int alpha) + { + } + + public override void SetColorFilter(ColorFilter? colorFilter) + { + AppliedColorFilter = colorFilter; + } + } + sealed class MutatingDrawable : ColorDrawable { public TrackingDrawable MutatedDrawable { get; } = new(); diff --git a/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Windows.cs b/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Windows.cs index d1aeac26309b..6f5ced73442f 100644 --- a/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Windows.cs +++ b/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Windows.cs @@ -1,6 +1,6 @@ using System; -using System.Collections.Generic; using System.Threading; +using System.Threading.Channels; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Maui.DeviceTests.Stubs; @@ -18,6 +18,8 @@ namespace Microsoft.Maui.DeviceTests [Category(TestCategory.SwipeView)] public class SwipeItemMenuItemHandlerTests : CoreHandlerTestBase { + static readonly TimeSpan ImageLoadTimeout = TimeSpan.FromSeconds(5); + [Fact] public Task TextColorCanBeCleared() { @@ -96,11 +98,11 @@ await InvokeOnMainThreadAsync(async () => item.Source = new FileImageSourceStub("custom-relative-path.png"); var load = SwipeItemMenuItemHandler.MapSourceAsync(handler, item); - var request = imageService.Requests.Dequeue(); + var request = await imageService.Requests.DequeueAsync(); var image = new BitmapImage(); request.SetResult(new ImageSourceServiceResult(image)); - await load; + await load.WaitAsync(ImageLoadTimeout); var icon = Assert.IsType(handler.PlatformView.IconSource); Assert.Same(image, icon.ImageSource); @@ -108,7 +110,7 @@ await InvokeOnMainThreadAsync(async () => item.IconColor = Colors.Blue; handler.UpdateValue(nameof(ISwipeItemMenuItemIconColor.IconColor)); - Assert.Empty(imageService.Requests); + Assert.True(imageService.Requests.IsEmpty); Assert.Same(icon, handler.PlatformView.IconSource); }); } @@ -124,17 +126,18 @@ await InvokeOnMainThreadAsync(async () => { var item = new SwipeItemMenuItemStub { - IconColor = Colors.Red, - Source = new UriImageSourceStub("https://example.com/delete.png") + IconColor = Colors.Red }; var handler = CreateHandler(item); + // Keep the explicit map below as the only pending delayed load. + item.Source = new UriImageSourceStub("https://example.com/delete.png"); var load = SwipeItemMenuItemHandler.MapSourceAsync(handler, item); - var request = imageService.Requests.Dequeue(); + var request = await imageService.Requests.DequeueAsync(); var image = new BitmapImage(); request.SetResult(new ImageSourceServiceResult(image)); - await load; + await load.WaitAsync(ImageLoadTimeout); var icon = Assert.IsType(handler.PlatformView.IconSource); Assert.Same(image, icon.ImageSource); @@ -142,7 +145,7 @@ await InvokeOnMainThreadAsync(async () => item.IconColor = Colors.Blue; handler.UpdateValue(nameof(ISwipeItemMenuItemIconColor.IconColor)); - Assert.Empty(imageService.Requests); + Assert.True(imageService.Requests.IsEmpty); Assert.Same(icon, handler.PlatformView.IconSource); }); } @@ -351,21 +354,21 @@ await InvokeOnMainThreadAsync(async () => item.Source = source; var staleLoad = SwipeItemMenuItemHandler.MapSourceAsync(handler, item); - var initialRequest = imageService.Requests.Dequeue(); + var initialRequest = await imageService.Requests.DequeueAsync(); item.IconColor = Colors.Blue; var newerLoad = SwipeItemMenuItemHandler.MapSourceAsync(handler, item); - var newerRequest = imageService.Requests.Dequeue(); + var newerRequest = await imageService.Requests.DequeueAsync(); var newerImage = new BitmapImage(); newerRequest.SetResult(new ImageSourceServiceResult(newerImage)); - await newerLoad; + await newerLoad.WaitAsync(ImageLoadTimeout); var currentIcon = Assert.IsType(handler.PlatformView.IconSource); Assert.Same(newerImage, currentIcon.ImageSource); var staleImage = new BitmapImage(); initialRequest.SetResult(new ImageSourceServiceResult(staleImage)); - await staleLoad; + await staleLoad.WaitAsync(ImageLoadTimeout); currentIcon = Assert.IsType(handler.PlatformView.IconSource); Assert.Same(newerImage, currentIcon.ImageSource); @@ -374,7 +377,7 @@ await InvokeOnMainThreadAsync(async () => sealed class DelayedFileImageSourceService : IImageSourceService { - public Queue?>> Requests { get; } = new(); + public ImageLoadRequests Requests { get; } = new(); public Task?> GetImageSourceAsync( IImageSource imageSource, @@ -390,7 +393,7 @@ sealed class DelayedFileImageSourceService : IImageSourceService { - public Queue?>> Requests { get; } = new(); + public ImageLoadRequests Requests { get; } = new(); public Task?> GetImageSourceAsync( IImageSource imageSource, @@ -404,6 +407,23 @@ sealed class DelayedUriImageSourceService : IImageSourceService } } + sealed class ImageLoadRequests + { + readonly Channel?>> _requests = + Channel.CreateUnbounded?>>(); + + public bool IsEmpty => !_requests.Reader.TryPeek(out _); + + public void Enqueue(TaskCompletionSource?> request) + { + if (!_requests.Writer.TryWrite(request)) + throw new InvalidOperationException("Unable to enqueue the pending image load."); + } + + public Task?>> DequeueAsync() => + _requests.Reader.ReadAsync().AsTask().WaitAsync(ImageLoadTimeout); + } + sealed class CustomFontImageSourceStub : FontImageSourceStub { } From 168f9b95d1720c02af09454c7649d266f34f9015 Mon Sep 17 00:00:00 2001 From: Copilot CI Date: Mon, 10 Aug 2026 18:15:30 +0200 Subject: [PATCH 21/22] Fix iOS SwipeItem retint redraw Treat renderer output within one physical pixel of the target bounds as already resized, and cover the color-only update with an oversized renderer-backed image identity assertion. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .../SwipeItemMenuItemHandler.iOS.cs | 9 +++++--- .../SwipeItemMenuItemHandlerTests.iOS.cs | 23 +++++++++++++++---- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.iOS.cs b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.iOS.cs index 3d6299c79970..49fbf14c27fd 100644 --- a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.iOS.cs +++ b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.iOS.cs @@ -167,15 +167,18 @@ item.Source is IFontImageSource || tintColor is not null static UIImage MaxResizeSwipeItemIconImage(UIImage sourceImage, nfloat maxWidth, nfloat maxHeight) { var sourceSize = sourceImage.Size; - var maxResizeFactor = Math.Min(maxWidth / sourceSize.Width, maxHeight / sourceSize.Height); + var scale = sourceImage.CurrentScale; + var pointTolerance = scale > 0 ? 1 / scale : 0; // Color-only updates feed the already-sized native image back through this setter. - // Keep an image that exactly fits the bounds instead of redrawing it. - if (maxResizeFactor >= 1) + // UIGraphicsImageRenderer can quantize its result up by one physical pixel. + if (sourceSize.Width <= maxWidth + pointTolerance && + sourceSize.Height <= maxHeight + pointTolerance) { return sourceImage; } + var maxResizeFactor = Math.Min(maxWidth / sourceSize.Width, maxHeight / sourceSize.Height); var width = maxResizeFactor * sourceSize.Width; var height = maxResizeFactor * sourceSize.Height; diff --git a/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.iOS.cs b/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.iOS.cs index cbebd03b625a..9ea244aa83fd 100644 --- a/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.iOS.cs +++ b/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.iOS.cs @@ -115,7 +115,7 @@ await AssertEventually(() => } [Fact] - public async Task IconColorChangeDoesNotReloadImageSource() + public async Task IconColorChangeDoesNotReloadOrRedrawImage() { var imageService = new CountingFileImageSourceService(); EnsureHandlerCreated(builder => builder.ConfigureImageSources( @@ -125,12 +125,14 @@ await InvokeOnMainThreadAsync(async () => { var item = new SwipeItemMenuItemStub(); var handler = CreateHandler(item); - handler.PlatformView.Frame = new CGRect(0, 0, 100, 100); + handler.PlatformView.Frame = new CGRect(0, 0, 100.2, 100.2); item.Source = new FileImageSourceStub("custom.png"); await SwipeItemMenuItemHandler.MapSourceAsync(handler, item); Assert.Equal(1, imageService.LoadCount); - var originalSize = handler.PlatformView.ImageForState(UIControlState.Normal).Size; + var originalImage = handler.PlatformView.ImageForState(UIControlState.Normal); + var originalSize = originalImage.Size; + var originalImageHandle = originalImage.CGImage.Handle; item.IconColor = Colors.Red; handler.UpdateValue(nameof(ISwipeItemMenuItemIconColor.IconColor)); @@ -138,12 +140,13 @@ await InvokeOnMainThreadAsync(async () => Assert.Equal(1, imageService.LoadCount); Assert.Equal(Colors.Red, handler.PlatformView.TintColor.ToColor()); Assert.Equal(originalSize, handler.PlatformView.ImageForState(UIControlState.Normal).Size); + Assert.Equal(originalImageHandle, handler.PlatformView.ImageForState(UIControlState.Normal).CGImage.Handle); }); } sealed class CountingFileImageSourceService : IImageSourceService { - readonly UIImage _image = UIImage.GetSystemImage("trash"); + readonly UIImage _image = CreateOversizedImage(); public int LoadCount { get; private set; } @@ -156,6 +159,18 @@ public Task> GetImageAsync( return Task.FromResult>( new ImageSourceServiceResult(_image)); } + + static UIImage CreateOversizedImage() + { + var bounds = new CGRect(0, 0, 200, 200); + using var renderer = new UIGraphicsImageRenderer(bounds.Size); + + return renderer.CreateImage(context => + { + UIColor.Red.SetFill(); + context.FillRect(bounds); + }); + } } } } From f0aabd2ba25bc0bfc248156dfc92bdea125bd499 Mon Sep 17 00:00:00 2001 From: Copilot CI Date: Thu, 13 Aug 2026 00:16:27 +0200 Subject: [PATCH 22/22] Address SwipeItem icon review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d00747b7-96f3-4e7a-8dfb-e3a48db04b2d --- .../tests/Core.UnitTests/SwipeViewTests.cs | 13 ++-- src/Core/src/Core/ISwipeItemMenuItem.cs | 5 ++ .../SwipeItemMenuItemHandler.Windows.cs | 46 ++++++++++++-- .../SwipeItemMenuItemHandler.cs | 4 +- .../SwipeItemMenuItemHandlerTests.Windows.cs | 60 ++++++++++++++++++- .../SwipeItemMenuItemHandlerTests.iOS.cs | 5 +- 6 files changed, 117 insertions(+), 16 deletions(-) diff --git a/src/Controls/tests/Core.UnitTests/SwipeViewTests.cs b/src/Controls/tests/Core.UnitTests/SwipeViewTests.cs index 466b868d908a..8231bb4b6910 100644 --- a/src/Controls/tests/Core.UnitTests/SwipeViewTests.cs +++ b/src/Controls/tests/Core.UnitTests/SwipeViewTests.cs @@ -830,7 +830,7 @@ public void ExplicitTextColorOverridesFontImageSourceColor() } // A colorless font icon derives its tint from TextColor, so the TextColor mapper must - // also refresh the source after initial mapping has completed. + // route through the IconColor mapper chain after initial mapping has completed. [Fact] public void ChangingTextColorRefreshesIconForColorlessFontIcon() { @@ -847,7 +847,8 @@ public void ChangingTextColorRefreshesIconForColorlessFontIcon() SwipeItemMenuItemHandler.UpdateTextColorIconDependency(handler, (ISwipeItemMenuItem)swipeItem); handler.Received(1).UpdateValue(nameof(ITextStyle.TextColor)); - handler.Received(1).UpdateValue(nameof(IMenuElement.Source)); + handler.Received(1).UpdateValue(nameof(ISwipeItemMenuItemIconColor.IconColor)); + handler.DidNotReceive().UpdateValue(nameof(IMenuElement.Source)); } // Discriminating test for the IFontImageSource (interface, not concrete FontImageSource) guard: @@ -868,7 +869,8 @@ public void ChangingTextColorRefreshesIconForCustomColorlessFontImageSource() swipeItem.TextColor = Colors.Red; SwipeItemMenuItemHandler.UpdateTextColorIconDependency(handler, (ISwipeItemMenuItem)swipeItem); - handler.Received(1).UpdateValue(nameof(IMenuElement.Source)); + handler.Received(1).UpdateValue(nameof(ISwipeItemMenuItemIconColor.IconColor)); + handler.DidNotReceive().UpdateValue(nameof(IMenuElement.Source)); } [Fact] @@ -888,6 +890,7 @@ public void ChangingTextColorDoesNotRefreshIconWhenIconColorIsExplicit() SwipeItemMenuItemHandler.UpdateTextColorIconDependency(handler, (ISwipeItemMenuItem)swipeItem); handler.Received(1).UpdateValue(nameof(ITextStyle.TextColor)); + handler.DidNotReceive().UpdateValue(nameof(ISwipeItemMenuItemIconColor.IconColor)); handler.DidNotReceive().UpdateValue(nameof(IMenuElement.Source)); } @@ -906,6 +909,7 @@ public void ChangingTextColorDoesNotRefreshIconWhenFontIconHasItsOwnColor() swipeItem.TextColor = Colors.Red; SwipeItemMenuItemHandler.UpdateTextColorIconDependency(handler, (ISwipeItemMenuItem)swipeItem); + handler.DidNotReceive().UpdateValue(nameof(ISwipeItemMenuItemIconColor.IconColor)); handler.DidNotReceive().UpdateValue(nameof(IMenuElement.Source)); } @@ -924,6 +928,7 @@ public void ChangingTextColorDoesNotRefreshIconForNonFontIcon() swipeItem.TextColor = Colors.Red; SwipeItemMenuItemHandler.UpdateTextColorIconDependency(handler, (ISwipeItemMenuItem)swipeItem); + handler.DidNotReceive().UpdateValue(nameof(ISwipeItemMenuItemIconColor.IconColor)); handler.DidNotReceive().UpdateValue(nameof(IMenuElement.Source)); } @@ -998,7 +1003,7 @@ public void InitialTextColorMappingDoesNotReloadSource() handler.State = ElementHandlerState.Connected; SwipeItemMenuItemHandler.UpdateTextColorIconDependency(handler, (ISwipeItemMenuItem)swipeItem); - Assert.Equal(new[] { nameof(IMenuElement.Source) }, handler.UpdatedProperties); + Assert.Equal(new[] { nameof(ISwipeItemMenuItemIconColor.IconColor) }, handler.UpdatedProperties); } [Fact] diff --git a/src/Core/src/Core/ISwipeItemMenuItem.cs b/src/Core/src/Core/ISwipeItemMenuItem.cs index 2bdeea7c2437..4130335c5380 100644 --- a/src/Core/src/Core/ISwipeItemMenuItem.cs +++ b/src/Core/src/Core/ISwipeItemMenuItem.cs @@ -20,6 +20,11 @@ public interface ISwipeItemMenuItem : IMenuElement, ISwipeItem /// /// Provides an optional icon tint for an . /// + /// + /// This is a separate optional interface to preserve compatibility for existing + /// implementations, including netstandard2.0 targets + /// where adding the member to the existing interface would require implementers to add it. + /// [EditorBrowsable(EditorBrowsableState.Never)] public interface ISwipeItemMenuItemIconColor { diff --git a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Windows.cs b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Windows.cs index 238cd4e47c71..573e5acf69ce 100644 --- a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Windows.cs +++ b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Windows.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Runtime.CompilerServices; +using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -18,6 +19,7 @@ public partial class SwipeItemMenuItemHandler : ElementHandler s_externalHandlerStates = new(); int _iconLoadGeneration; + readonly ImageSourceServiceResultManager _iconSourceManager = new(); IFontImageSource? _fontIconLoadSource; Color? _fontIconLoadColor; int _fontIconLoadGeneration; @@ -100,6 +102,7 @@ protected override void ConnectHandler(WSwipeItem platformView) protected override void DisconnectHandler(WSwipeItem platformView) { System.Threading.Interlocked.Increment(ref _iconLoadGeneration); + _iconSourceManager.Reset(); ResetFontIconLoad(); base.DisconnectHandler(platformView); platformView.Invoked -= OnSwipeItemInvoked; @@ -110,7 +113,7 @@ void OnSwipeItemInvoked(WSwipeItem sender, Microsoft.UI.Xaml.Controls.SwipeItemI VirtualView.OnInvoked(); } - internal static async Task LoadFileIconAsync(ISwipeItemMenuItemHandler handler, ISwipeItemMenuItem item) + internal static async Task LoadIconAsync(ISwipeItemMenuItemHandler handler, ISwipeItemMenuItem item) { if (handler.PlatformView is not WSwipeItem swipeItem || handler.MauiContext is null) { @@ -118,18 +121,22 @@ internal static async Task LoadFileIconAsync(ISwipeItemMenuItemHandler handler, } int generation = BeginIconLoad(handler); + var sourceManager = GetIconSourceManager(handler); + var cancellationToken = sourceManager.BeginLoad(); var source = item.Source; var fontSource = source as IFontImageSource; var resolvedFontColor = fontSource is null ? null : item.GetIconTintColor(); bool fontIconApplied = false; var platformHandler = handler as SwipeItemMenuItemHandler; + IImageSourceServiceResult? result = null; platformHandler?.SetFontIconLoad(fontSource, resolvedFontColor, generation); if (source is null) { swipeItem.IconSource = null; + sourceManager.CompleteLoad(null); return; } @@ -149,11 +156,15 @@ internal static async Task LoadFileIconAsync(ISwipeItemMenuItemHandler handler, if (tintedIconSource is not null) { - if (IsIconLoadCurrent(handler, item, swipeItem, generation) && - ReferenceEquals(item.Source, source)) + if (IsIconLoadCurrent(handler, item, swipeItem, generation)) { - tintedIconSource.Foreground = tintColor.ToPlatform(); - swipeItem.IconSource = tintedIconSource; + if (ReferenceEquals(item.Source, source)) + { + tintedIconSource.Foreground = tintColor.ToPlatform(); + swipeItem.IconSource = tintedIconSource; + } + + sourceManager.CompleteLoad(null); } return; @@ -182,7 +193,7 @@ internal static async Task LoadFileIconAsync(ISwipeItemMenuItemHandler handler, } // Do not use ConfigureAwait(false): WinUI DependencyProperty writes require the UI thread. - var result = await service.GetImageSourceAsync(loadSource, scale); + result = await service.GetImageSourceAsync(loadSource, scale, cancellationToken); // Only apply the result if no newer load has started on THIS handler while this one was // in flight. Checking item.Source == source alone is not enough: the source can be @@ -195,14 +206,25 @@ internal static async Task LoadFileIconAsync(ISwipeItemMenuItemHandler handler, var iconSource = result?.Value is WImageSource platformImage ? new ImageIconSource { ImageSource = platformImage } : null; swipeItem.IconSource = iconSource; fontIconApplied = fontSource is not null && iconSource is not null; + sourceManager.CompleteLoad(result); + result = null; } } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // A newer source/tint load or disconnect owns the platform view now. + } catch (System.Exception ex) { + if (IsIconLoadCurrent(handler, item, swipeItem, generation)) + sourceManager.CompleteLoad(null); + handler.MauiContext?.CreateLogger()?.Log(LogLevel.Warning, new EventId(), "Cannot load SwipeItem Icon", ex, static (state, _) => state); } finally { + result?.Dispose(); + if (fontSource is not null && !fontIconApplied && platformHandler is not null) @@ -287,6 +309,14 @@ internal static int BeginIconLoad(ISwipeItemMenuItemHandler handler) return System.Threading.Interlocked.Increment(ref state.IconLoadGeneration); } + static ImageSourceServiceResultManager GetIconSourceManager(ISwipeItemMenuItemHandler handler) + { + if (handler is SwipeItemMenuItemHandler platformHandler) + return platformHandler._iconSourceManager; + + return s_externalHandlerStates.GetValue(handler, static _ => new HandlerState()).SourceManager; + } + internal static bool IsIconLoadCurrent( ISwipeItemMenuItemHandler handler, ISwipeItemMenuItem item, @@ -341,6 +371,7 @@ internal bool IsFontIconLoadCurrent(IFontImageSource source, Color? color) => sealed class HandlerState { public int IconLoadGeneration; + public ImageSourceServiceResultManager SourceManager { get; } = new(); } sealed class TintedFontImageSource : IFontImageSource @@ -365,6 +396,9 @@ partial class SwipeItemMenuItemImageSourcePartSetter { public override void SetImageSource(ImageSource? platformImage) { + // The built-in Windows mapper uses LoadIconAsync for tint selection and result + // ownership. This override remains required for subclasses/custom mappers that + // deliberately opt into SourceLoader. if (Handler?.PlatformView is not WSwipeItem button) return; diff --git a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.cs b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.cs index 1a71b94cfc74..64948efd669a 100644 --- a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.cs +++ b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.cs @@ -88,7 +88,7 @@ internal static void UpdateTextColorIconDependency( view is not ISwipeItemMenuItemIconColor { IconColor: not null } && view.Source is IFontImageSource { Color: null }) { - UpdateIconColor(handler, view); + handler.UpdateValue(nameof(ISwipeItemMenuItemIconColor.IconColor)); } } @@ -113,7 +113,7 @@ static partial void UpdateIconColorPlatform( public static Task MapSourceAsync(ISwipeItemMenuItemHandler handler, ISwipeItemMenuItem image) { #if WINDOWS - return LoadFileIconAsync(handler, image); + return LoadIconAsync(handler, image); #else if (handler.SourceLoader is ImageSourcePartLoader loader) return loader.UpdateImageSourceAsync(); diff --git a/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Windows.cs b/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Windows.cs index 6f5ced73442f..4f96f9f765d1 100644 --- a/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Windows.cs +++ b/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Windows.cs @@ -115,6 +115,49 @@ await InvokeOnMainThreadAsync(async () => }); } + [Fact] + public async Task ImageServiceResultsAreDisposedWhenReplacedAndDisconnected() + { + var imageService = new DelayedFileImageSourceService(); + EnsureHandlerCreated(builder => builder.ConfigureImageSources( + services => services.AddService(_ => imageService))); + + await InvokeOnMainThreadAsync(async () => + { + var item = new SwipeItemMenuItemStub(); + var handler = CreateHandler(item); + + item.Source = new FileImageSourceStub("first.png"); + var firstLoad = SwipeItemMenuItemHandler.MapSourceAsync(handler, item); + var firstRequest = await imageService.Requests.DequeueAsync(); + var firstDisposeCount = 0; + firstRequest.SetResult(new ImageSourceServiceResult( + new BitmapImage(), + () => firstDisposeCount++)); + await firstLoad.WaitAsync(ImageLoadTimeout); + + Assert.Equal(0, firstDisposeCount); + + item.Source = new FileImageSourceStub("second.png"); + var secondLoad = SwipeItemMenuItemHandler.MapSourceAsync(handler, item); + var secondRequest = await imageService.Requests.DequeueAsync(); + var secondDisposeCount = 0; + + Assert.Equal(1, firstDisposeCount); + + secondRequest.SetResult(new ImageSourceServiceResult( + new BitmapImage(), + () => secondDisposeCount++)); + await secondLoad.WaitAsync(ImageLoadTimeout); + + Assert.Equal(0, secondDisposeCount); + + ((IElementHandler)handler).DisconnectHandler(); + + Assert.Equal(1, secondDisposeCount); + }); + } + [Fact] public async Task UriImageUsesRegisteredServiceWhenTintIsRequested() { @@ -360,18 +403,31 @@ await InvokeOnMainThreadAsync(async () => var newerLoad = SwipeItemMenuItemHandler.MapSourceAsync(handler, item); var newerRequest = await imageService.Requests.DequeueAsync(); var newerImage = new BitmapImage(); - newerRequest.SetResult(new ImageSourceServiceResult(newerImage)); + var newerDisposeCount = 0; + newerRequest.SetResult(new ImageSourceServiceResult( + newerImage, + () => newerDisposeCount++)); await newerLoad.WaitAsync(ImageLoadTimeout); var currentIcon = Assert.IsType(handler.PlatformView.IconSource); Assert.Same(newerImage, currentIcon.ImageSource); + Assert.Equal(0, newerDisposeCount); var staleImage = new BitmapImage(); - initialRequest.SetResult(new ImageSourceServiceResult(staleImage)); + var staleDisposeCount = 0; + initialRequest.SetResult(new ImageSourceServiceResult( + staleImage, + () => staleDisposeCount++)); await staleLoad.WaitAsync(ImageLoadTimeout); currentIcon = Assert.IsType(handler.PlatformView.IconSource); Assert.Same(newerImage, currentIcon.ImageSource); + Assert.Equal(1, staleDisposeCount); + Assert.Equal(0, newerDisposeCount); + + ((IElementHandler)handler).DisconnectHandler(); + + Assert.Equal(1, newerDisposeCount); }); } diff --git a/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.iOS.cs b/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.iOS.cs index aa0dc36ed7af..2e92399af70e 100644 --- a/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.iOS.cs +++ b/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.iOS.cs @@ -145,10 +145,11 @@ await InvokeOnMainThreadAsync(async () => } [Fact] - public async Task ScaleOneImageJustOverLimitIsResized() + public async Task ScaleOneImageBeyondRenderToleranceIsResized() { + var renderScale = UIScreen.MainScreen.Scale; var imageService = new CountingFileImageSourceService( - CountingFileImageSourceService.CreateImage(51, 1)); + CountingFileImageSourceService.CreateImage(50 + (1 / renderScale) + 0.5f, 1)); EnsureHandlerCreated(builder => builder.ConfigureImageSources( services => services.AddService(_ => imageService)));