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 3f6e397ed696..571307a360d2 100644 --- a/src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt @@ -294,3 +294,9 @@ virtual Microsoft.Maui.Controls.Platform.Compatibility.ShellFlyoutTemplatedConte ~static readonly Microsoft.Maui.Controls.ToolbarItem.BadgeTextProperty -> Microsoft.Maui.Controls.BindableProperty ~virtual Microsoft.Maui.Controls.Platform.Compatibility.ShellItemRenderer.UpdateShellSectionBadge(Microsoft.Maui.Controls.ShellSection shellSection, int index) -> void ~virtual Microsoft.Maui.Controls.Platform.Compatibility.ShellToolbarAppearanceTracker.SetColors(AndroidX.AppCompat.Widget.Toolbar toolbar, Microsoft.Maui.Controls.Platform.Compatibility.IShellToolbarTracker toolbarTracker, Microsoft.Maui.Graphics.Color foreground, Microsoft.Maui.Controls.Brush background, Microsoft.Maui.Graphics.Color title) -> 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 8f4a157fd0fb..4bb42157ac21 100644 --- a/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt @@ -214,3 +214,9 @@ virtual Microsoft.Maui.Controls.LongPressingEventArgs.GetPosition(Microsoft.Maui ~static readonly Microsoft.Maui.Controls.ToolbarItem.BadgeColorProperty -> Microsoft.Maui.Controls.BindableProperty ~static readonly Microsoft.Maui.Controls.ToolbarItem.BadgeTextColorProperty -> Microsoft.Maui.Controls.BindableProperty ~static readonly Microsoft.Maui.Controls.ToolbarItem.BadgeTextProperty -> Microsoft.Maui.Controls.BindableProperty +~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 7e5b5f9e9955..a0f8fe3ad68b 100644 --- a/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt @@ -206,3 +206,9 @@ virtual Microsoft.Maui.Controls.LongPressingEventArgs.GetPosition(Microsoft.Maui ~static readonly Microsoft.Maui.Controls.ToolbarItem.BadgeColorProperty -> Microsoft.Maui.Controls.BindableProperty ~static readonly Microsoft.Maui.Controls.ToolbarItem.BadgeTextColorProperty -> Microsoft.Maui.Controls.BindableProperty ~static readonly Microsoft.Maui.Controls.ToolbarItem.BadgeTextProperty -> Microsoft.Maui.Controls.BindableProperty +~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 6ab89ba0fff4..8055f96fa7fd 100644 --- a/src/Controls/src/Core/PublicAPI/net-tizen/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-tizen/PublicAPI.Unshipped.txt @@ -197,4 +197,10 @@ 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 +~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 ~static Microsoft.Maui.Controls.Brush.HasTransparency(Microsoft.Maui.Controls.Brush background) -> bool 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 6ddee0a9ddde..96ae0df4cd69 100644 --- a/src/Controls/src/Core/PublicAPI/net-windows/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-windows/PublicAPI.Unshipped.txt @@ -245,6 +245,12 @@ 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 +~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 ~static Microsoft.Maui.Controls.Brush.HasTransparency(Microsoft.Maui.Controls.Brush background) -> bool static Microsoft.Maui.Controls.Platform.FormattedStringExtensions.UpdateInlines(this Microsoft.UI.Xaml.Controls.TextBlock! textBlock, Microsoft.Maui.IFontManager! fontManager, Microsoft.Maui.Controls.FormattedString! formattedString, double defaultLineHeight, Microsoft.Maui.TextAlignment defaultHorizontalAlignment, Microsoft.Maui.Font? defaultFont, Microsoft.Maui.Graphics.Color? defaultColor, Microsoft.Maui.TextTransform defaultTextTransform, double defaultCharacterSpacing) -> void static Microsoft.Maui.Controls.Platform.FormattedStringExtensions.ToRunAndColorsTuples(this Microsoft.Maui.Controls.FormattedString! formattedString, Microsoft.Maui.IFontManager! fontManager, double defaultLineHeight, Microsoft.Maui.TextAlignment defaultHorizontalAlignment, Microsoft.Maui.Font? defaultFont, Microsoft.Maui.Graphics.Color? defaultColor, Microsoft.Maui.TextTransform defaultTextTransform, double defaultCharacterSpacing) -> System.Collections.Generic.IEnumerable!>! diff --git a/src/Controls/src/Core/PublicAPI/net/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net/PublicAPI.Unshipped.txt index ff27dbb7a2b4..fb84d236815f 100644 --- a/src/Controls/src/Core/PublicAPI/net/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net/PublicAPI.Unshipped.txt @@ -192,4 +192,10 @@ 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 +~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 ~static Microsoft.Maui.Controls.Brush.HasTransparency(Microsoft.Maui.Controls.Brush background) -> bool diff --git a/src/Controls/src/Core/PublicAPI/netstandard/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/netstandard/PublicAPI.Unshipped.txt index 067390072045..47f99cebb788 100644 --- a/src/Controls/src/Core/PublicAPI/netstandard/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/netstandard/PublicAPI.Unshipped.txt @@ -183,4 +183,10 @@ 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 +~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 ~static Microsoft.Maui.Controls.Brush.HasTransparency(Microsoft.Maui.Controls.Brush background) -> bool diff --git a/src/Controls/src/Core/SwipeView/SwipeItem.cs b/src/Controls/src/Core/SwipeView/SwipeItem.cs index 9280923edb47..563bae600ab4 100644 --- a/src/Controls/src/Core/SwipeView/SwipeItem.cs +++ b/src/Controls/src/Core/SwipeView/SwipeItem.cs @@ -9,14 +9,20 @@ 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), BooleanBoxes.TrueBox, propertyChanged: OnIsVisibleChanged); + /// Bindable property for . + 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); + /// /// Gets or sets the background color of the swipe item. This is a bindable property. /// @@ -37,10 +43,55 @@ public bool IsVisible public event EventHandler Invoked; + /// + /// Gets or sets the color used to tint . This is a bindable 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 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 + { + get { return (Color)GetValue(IconColorProperty); } + 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 , 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 + { + get { return (Color)GetValue(TextColorProperty); } + set { SetValue(TextColorProperty, value); } + } + Paint ISwipeItemMenuItem.Background => new SolidPaint(BackgroundColor); + Color ISwipeItemMenuItemIconColor.IconColor => IconColor; + + Color ITextStyle.TextColor => TextColor; + Visibility ISwipeItemMenuItem.Visibility => this.IsVisible ? Visibility.Visible : Visibility.Collapsed; + static void OnBackgroundColorChanged(BindableObject bindable, object oldValue, object newValue) + { + var swipeItem = (SwipeItem)bindable; + swipeItem.Handler?.UpdateValue(nameof(ISwipeItemMenuItem.Background)); + } + 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..8231bb4b6910 100644 --- a/src/Controls/tests/Core.UnitTests/SwipeViewTests.cs +++ b/src/Controls/tests/Core.UnitTests/SwipeViewTests.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; using Microsoft.Maui.Graphics; +using Microsoft.Maui.Handlers; using Microsoft.Maui.Platform; +using NSubstitute; using Xunit; using Xunit.Sdk; @@ -683,6 +685,340 @@ 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()); + } + + [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()); + Assert.Equal(Colors.Red, ((ITextStyle)swipeItem).TextColor); + } + + [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()); + } + + // A colorless font icon derives its tint from TextColor, so the TextColor mapper must + // route through the IconColor mapper chain after initial mapping has completed. + [Fact] + public void ChangingTextColorRefreshesIconForColorlessFontIcon() + { + var swipeItem = new SwipeItem + { + IconImageSource = new FontImageSource { Glyph = "A" } + }; + 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(ISwipeItemMenuItemIconColor.IconColor)); + handler.DidNotReceive().UpdateValue(nameof(IMenuElement.Source)); + } + + // 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(); + handler.VirtualView.Returns((ISwipeItemMenuItem)swipeItem); + swipeItem.Handler = handler; + handler.ClearReceivedCalls(); + + swipeItem.TextColor = Colors.Red; + SwipeItemMenuItemHandler.UpdateTextColorIconDependency(handler, (ISwipeItemMenuItem)swipeItem); + + handler.Received(1).UpdateValue(nameof(ISwipeItemMenuItemIconColor.IconColor)); + handler.DidNotReceive().UpdateValue(nameof(IMenuElement.Source)); + } + + [Fact] + public void ChangingTextColorDoesNotRefreshIconWhenIconColorIsExplicit() + { + var swipeItem = new SwipeItem + { + IconColor = Colors.Blue, + IconImageSource = new FontImageSource { Glyph = "A" } + }; + 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(ISwipeItemMenuItemIconColor.IconColor)); + handler.DidNotReceive().UpdateValue(nameof(IMenuElement.Source)); + } + + [Fact] + public void ChangingTextColorDoesNotRefreshIconWhenFontIconHasItsOwnColor() + { + var swipeItem = new SwipeItem + { + IconImageSource = new FontImageSource { Glyph = "A", Color = Colors.Green } + }; + 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(ISwipeItemMenuItemIconColor.IconColor)); + handler.DidNotReceive().UpdateValue(nameof(IMenuElement.Source)); + } + + [Fact] + public void ChangingTextColorDoesNotRefreshIconForNonFontIcon() + { + var swipeItem = new SwipeItem + { + IconImageSource = "icon.png" + }; + 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(ISwipeItemMenuItemIconColor.IconColor)); + handler.DidNotReceive().UpdateValue(nameof(IMenuElement.Source)); + } + + // 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(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(ISwipeItemMenuItemIconColor.IconColor) }, handler.UpdatedProperties); + } + + [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++) @@ -692,5 +1028,59 @@ 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"; + } + + 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 4428d7a6b4a7..4130335c5380 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 { @@ -13,5 +14,33 @@ 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; } + + } + + /// + /// 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 + { + /// + /// 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; + /// 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 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. + /// + Color? IconColor { get; } } } diff --git a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Android.cs b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Android.cs index 0d207b910486..735f6828f396 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; @@ -12,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); @@ -25,13 +30,27 @@ 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) { - (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() 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) @@ -57,17 +76,12 @@ public static void MapText(ISwipeItemMenuItemHandler handler, ISwipeItemMenuItem public static void MapBackground(ISwipeItemMenuItemHandler handler, ISwipeItemMenuItem view) { - handler.PlatformView.UpdateBackground(handler.VirtualView.Background); - - var textColor = handler.VirtualView.GetTextColor()?.ToPlatform(); + handler.PlatformView.UpdateBackground(view.Background); if (handler.PlatformView is TextView textView) - { - if (textColor != null) - textView.SetTextColor(textColor.Value); - textView.TextAlignment = ATextAlignment.Center; - } + + UpdateBackgroundColorDependencies(handler); } public static void MapVisibility(ISwipeItemMenuItemHandler handler, ISwipeItemMenuItem view) @@ -145,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); } } @@ -158,17 +171,60 @@ 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; + } + + 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 textColor = item.GetTextColor()?.ToPlatform(); + // 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) + { + // 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; @@ -185,11 +241,19 @@ public override void SetImageSource(Drawable? platformImage) platformImage.SetBounds(0, 0, iconWidth, iconHeight); } - if (textColor != null) - platformImage.SetColorFilter(textColor.Value, FilterMode.SrcAtop); + if (tintColor is not null) + { + platformImage.SetColorFilter(tintColor.Value, FilterMode.SrcAtop); + } + else if (clearTint) + { + platformImage.ClearColorFilter(); + } } - button.SetCompoundDrawables(null, platformImage, null, null); + platformHandler._appliedIconTintDrawable = + tintColor is not null ? platformImage : null; + SetIconDrawable(button, platformImage); } } } diff --git a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Tizen.cs b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.Tizen.cs index 5e7156491f05..073e725ed937 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 { + static readonly ConditionalWeakTable s_externalHandlerStates = new(); + int _iconLoadGeneration; + readonly ImageSourceServiceResultManager _iconSourceManager = new(); + IFontImageSource? _fontIconLoadSource; + Color? _fontIconLoadColor; + int _fontIconLoadGeneration; + protected override WSwipeItem CreatePlatformElement() { return new WSwipeItem(); } - public static void MapTextColor(ISwipeItemMenuItemHandler handler, ISwipeItemMenuItem view) => + public static void MapTextColor(ISwipeItemMenuItemHandler handler, ISwipeItemMenuItem view) + { handler.PlatformView.UpdateTextColor(view); + UpdateTextColorIconDependency(handler, view); + } public static void MapCharacterSpacing(ISwipeItemMenuItemHandler handler, ITextStyle view) { } @@ -25,8 +44,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) { @@ -79,8 +101,11 @@ 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; + platformView.Invoked -= OnSwipeItemInvoked; } void OnSwipeItemInvoked(WSwipeItem sender, Microsoft.UI.Xaml.Controls.SwipeItemInvokedEventArgs args) @@ -88,10 +113,292 @@ void OnSwipeItemInvoked(WSwipeItem sender, Microsoft.UI.Xaml.Controls.SwipeItemI VirtualView.OnInvoked(); } + internal static async Task LoadIconAsync(ISwipeItemMenuItemHandler handler, ISwipeItemMenuItem item) + { + if (handler.PlatformView is not WSwipeItem swipeItem || handler.MauiContext is null) + { + return; + } + + 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; + } + + // 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 + { + var imageSourceServiceProvider = handler.MauiContext.Services.GetRequiredService(); + var sourceService = imageSourceServiceProvider.GetRequiredImageSourceService(source); + + if (tintColor is not null) + { + var tintedIconSource = CreateTintedIconSource(source, sourceService); + + if (tintedIconSource is not null) + { + if (IsIconLoadCurrent(handler, item, swipeItem, generation)) + { + if (ReferenceEquals(item.Source, source)) + { + tintedIconSource.Foreground = tintColor.ToPlatform(); + swipeItem.IconSource = tintedIconSource; + } + + sourceManager.CompleteLoad(null); + } + + return; + } + } + + var scale = handler.MauiContext.GetOptionalPlatformWindow()?.GetDisplayDensity() ?? 1.0f; + IImageSource loadSource = source; + var service = sourceService; + if (fontSource is not null) + { + if (resolvedFontColor is Color fontColor && fontSource.Color != 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; + } + } + } + + // Do not use ConfigureAwait(false): WinUI DependencyProperty writes require the UI thread. + 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 + // identical while IconColor changed, so a stale untinted result would overwrite a tinted one. + if (!IsIconLoadCurrent(handler, item, swipeItem, generation)) + return; + + if (ReferenceEquals(item.Source, source)) + { + 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) + { + platformHandler.ClearFontIconLoad(generation); + } + } + } + + internal static IconSource? CreateTintedIconSource( + IImageSource source, + IImageSourceService imageSourceService) + { + if (!CanCreateTintedIconSource(source, imageSourceService) || + source is not IFileImageSource fileImageSource) + { + return null; + } + + return new BitmapIconSource + { + UriSource = new Uri("ms-appx:///" + Path.GetFileName(fileImageSource.File)), + ShowAsMonochrome = true + }; + } + + 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; + } + + 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) + { + 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); + } + + 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, + object platformView, + int generation) + { + 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; + + ResetFontIconLoad(); + } + + void ResetFontIconLoad() + { + _fontIconLoadSource = null; + _fontIconLoadColor = null; + _fontIconLoadGeneration = 0; + } + + internal bool IsFontIconLoadCurrent(IFontImageSource source, Color? color) => + ReferenceEquals(_fontIconLoadSource, source) && + Equals(_fontIconLoadColor, color); + + sealed class HandlerState + { + public int IconLoadGeneration; + public ImageSourceServiceResultManager SourceManager { get; } = new(); + } + + 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) { + // 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 c7389d254ed7..64948efd669a 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(ISwipeItemMenuItemIconColor.IconColor)] = MapIconColor, }; public static CommandMapper CommandMapper = @@ -58,19 +59,67 @@ 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); + } + + public static void MapIconColor(ISwipeItemMenuItemHandler handler, ISwipeItemMenuItem view) + { + if (handler.IsMappingProperties()) + return; + + UpdateIconColor(handler, view); + } + + 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(ISwipeItemMenuItemIconColor.IconColor)); + } + } + + static void UpdateIconColor( + ISwipeItemMenuItemHandler handler, + ISwipeItemMenuItem view) + { + bool handled = false; + UpdateIconColorPlatform(handler, view, ref handled); + + // 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)); + } + + static partial void UpdateIconColorPlatform( + ISwipeItemMenuItemHandler handler, + ISwipeItemMenuItem view, + ref bool handled); public static Task MapSourceAsync(ISwipeItemMenuItemHandler handler, ISwipeItemMenuItem image) { #if WINDOWS - // TODO: make the mapper use the loader and the image if this is a stream source - handler.PlatformView.IconSource = image.Source?.ToIconSource(handler.MauiContext!); + return LoadIconAsync(handler, image); #else if (handler.SourceLoader is ImageSourcePartLoader loader) return loader.UpdateImageSourceAsync(); -#endif + return Task.CompletedTask; +#endif } partial class SwipeItemMenuItemImageSourcePartSetter : ImageSourcePartSetter @@ -80,5 +129,6 @@ public SwipeItemMenuItemImageSourcePartSetter(ISwipeItemMenuItemHandler handler) { } } + } } diff --git a/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.iOS.cs b/src/Core/src/Handlers/SwipeItemMenuItem/SwipeItemMenuItemHandler.iOS.cs index 0eb9faafd22e..6b4ffddc7eb0 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,14 +53,18 @@ 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(); + var color = view.GetTextColor()?.ToPlatform(); + if (color is null && handler is SwipeItemMenuItemHandler platformHandler) + color = platformHandler._defaultTitleColor; - if (color != null) - handler.PlatformView.SetTitleColor(color.ToPlatform(), UIControlState.Normal); + handler.PlatformView.SetTitleColor(color, UIControlState.Normal); + UpdateTextColorIconDependency(handler, view); } public static void MapCharacterSpacing(ISwipeItemMenuItemHandler handler, ITextStyle view) @@ -82,6 +88,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) @@ -95,6 +102,25 @@ 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; + } + + var current = button.ImageForState(UIControlState.Normal); + if (current is null) + return; + + loader.Setter.SetImageSource(current); + handled = true; + } + partial class SwipeItemMenuItemImageSourcePartSetter { public override void SetImageSource(UIImage? platformImage) @@ -114,21 +140,22 @@ public override void SetImageSource(UIImage? platformImage) { var maxWidth = frame.Width * 0.5f; var maxHeight = frame.Height * 0.5f; + var renderScale = button.Window?.Screen.Scale ?? UIScreen.MainScreen.Scale; - var resizedImage = MaxResizeSwipeItemIconImage(platformImage, maxWidth, maxHeight); + var resizedImage = MaxResizeSwipeItemIconImage(platformImage, maxWidth, maxHeight, renderScale); try { - button.SetImage(resizedImage.ImageWithRenderingMode(UIImageRenderingMode.AlwaysTemplate), UIControlState.Normal); - - if (item.Source is IFontImageSource fontImageSource && fontImageSource.Color != null) - button.TintColor = fontImageSource.Color.ToPlatform(); - else - { - var tintColor = item.GetTextColor(); - if (tintColor != null) - button.TintColor = tintColor.ToPlatform(); - } + // 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 = + item.Source is IFontImageSource || tintColor is not null + ? UIImageRenderingMode.AlwaysTemplate + : UIImageRenderingMode.AlwaysOriginal; + + button.SetImage(resizedImage.ImageWithRenderingMode(renderingMode), UIControlState.Normal); + button.TintColor = tintColor?.ToPlatform(); } catch (Exception) { @@ -138,23 +165,31 @@ public override void SetImageSource(UIImage? platformImage) } } - static UIImage MaxResizeSwipeItemIconImage(UIImage sourceImage, nfloat maxWidth, nfloat maxHeight) + static UIImage MaxResizeSwipeItemIconImage( + UIImage sourceImage, + nfloat maxWidth, + nfloat maxHeight, + nfloat renderScale) { var sourceSize = sourceImage.Size; - var maxResizeFactor = Math.Min(maxWidth / sourceSize.Width, maxHeight / sourceSize.Height); + var pointTolerance = renderScale > 0 ? 1 / renderScale : 0; - if (maxResizeFactor > 1) + // Color-only updates feed the already-sized native image back through this setter. + // 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; var format = new UIGraphicsImageRendererFormat { Opaque = false, - Scale = 0 + Scale = renderScale }; using (var renderer = new UIGraphicsImageRenderer(new CGSize(width, height), format)) diff --git a/src/Core/src/Platform/Android/TextViewExtensions.cs b/src/Core/src/Platform/Android/TextViewExtensions.cs index 5d79bae2bf69..a49382f2429e 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, 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) { - var textColor = textStyle.TextColor; - // Cache the original themed TextColors the first time this control is updated. if (textView.TextColors is ColorStateList currentColors) { diff --git a/src/Core/src/Platform/SwipeViewExtensions.cs b/src/Core/src/Platform/SwipeViewExtensions.cs index 536c163bf6b9..373e14718e3b 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)) @@ -19,6 +23,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 is ISwipeItemMenuItemIconColor { IconColor: 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/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 abeb1ef6112c..59e52f9529d1 100644 --- a/src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt @@ -360,3 +360,6 @@ virtual Microsoft.Maui.Platform.MauiDrawerLayout.LayoutAsFlyout() -> void virtual Microsoft.Maui.Platform.MauiDrawerLayout.LayoutSideBySide() -> void virtual Microsoft.Maui.Platform.StackNavigationManager.Connect(Microsoft.Maui.IView! navigationView, AndroidX.Fragment.App.FragmentContainerView? fragmentContainerView = null) -> void virtual Microsoft.Maui.Platform.StackNavigationManager.OnCreateNavigationAnimation(Android.Content.Context! context, bool isPopping, bool enter) -> Android.Views.Animations.Animation? +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-ios/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt index e14e568576dd..f4787f33e7b9 100644 --- a/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt @@ -60,3 +60,6 @@ static Microsoft.Maui.SafeAreaEdges.Container.get -> Microsoft.Maui.SafeAreaEdge virtual Microsoft.Maui.Animations.PlatformTicker.Dispose(bool disposing) -> void ~override Microsoft.Maui.Handlers.TabbedViewHandler.ConnectHandler(UIKit.UIView platformView) -> void ~override Microsoft.Maui.Handlers.TabbedViewHandler.DisconnectHandler(UIKit.UIView platformView) -> void +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-maccatalyst/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt index 06417bca4207..6f692e0aad37 100644 --- a/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt @@ -57,3 +57,6 @@ static Microsoft.Maui.SafeAreaEdges.Container.get -> Microsoft.Maui.SafeAreaEdge virtual Microsoft.Maui.Animations.PlatformTicker.Dispose(bool disposing) -> void ~override Microsoft.Maui.Handlers.TabbedViewHandler.ConnectHandler(UIKit.UIView platformView) -> void ~override Microsoft.Maui.Handlers.TabbedViewHandler.DisconnectHandler(UIKit.UIView platformView) -> void +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-tizen/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt index 0ed540052c95..db21e60cc162 100644 --- a/src/Core/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt @@ -18,3 +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.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 4c59ffcccf4a..0e20135b6570 100644 --- a/src/Core/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt @@ -45,3 +45,6 @@ static Microsoft.Maui.SafeAreaEdges.Container.get -> Microsoft.Maui.SafeAreaEdge virtual Microsoft.Maui.Animations.PlatformTicker.Dispose(bool disposing) -> void virtual Microsoft.Maui.LifecycleEvents.WindowsLifecycle.OnAppInstanceActivated.Invoke(Microsoft.UI.Xaml.Application! application, Microsoft.Windows.AppLifecycle.AppActivationArguments! args) -> bool virtual Microsoft.Maui.MauiWinUIApplication.OnAppInstanceActivated(Microsoft.Windows.AppLifecycle.AppActivationArguments! args) -> bool +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/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net/PublicAPI.Unshipped.txt index 7eac0a92854d..a430354bb4c7 100644 --- a/src/Core/src/PublicAPI/net/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/net/PublicAPI.Unshipped.txt @@ -25,6 +25,9 @@ static Microsoft.Maui.Handlers.FlyoutViewHandler.MapFlyoutWidth(Microsoft.Maui.H static Microsoft.Maui.Handlers.FlyoutViewHandler.MapIsGestureEnabled(Microsoft.Maui.Handlers.IFlyoutViewHandler! handler, Microsoft.Maui.IFlyoutView! flyoutView) -> void static Microsoft.Maui.Handlers.FlyoutViewHandler.MapIsPresented(Microsoft.Maui.Handlers.IFlyoutViewHandler! handler, Microsoft.Maui.IFlyoutView! flyoutView) -> void static Microsoft.Maui.SafeAreaEdges.Container.get -> Microsoft.Maui.SafeAreaEdges +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 static Microsoft.Maui.Handlers.SearchBarHandler.MapSelectionLength(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 486db1dd6cc6..bd5088b5829e 100644 --- a/src/Core/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt @@ -15,6 +15,9 @@ 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.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 static Microsoft.Maui.Handlers.SearchBarHandler.MapSelectionLength(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 486db1dd6cc6..bd5088b5829e 100644 --- a/src/Core/src/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -15,6 +15,9 @@ 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.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 static Microsoft.Maui.Handlers.SearchBarHandler.MapSelectionLength(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..8a29c949f453 --- /dev/null +++ b/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Android.cs @@ -0,0 +1,235 @@ +using System.Threading; +using System.Threading.Tasks; +using Android.Graphics; +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; + +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); + }); + } + + [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() + { + 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( + 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.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); + }); + } + + 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 MutatingDrawable SourceDrawable => _sourceDrawable; + + 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 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(); + + public int MutateCount { get; private set; } + + public override Drawable Mutate() + { + MutateCount++; + return 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 new file mode 100644 index 000000000000..4f96f9f765d1 --- /dev/null +++ b/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.Windows.cs @@ -0,0 +1,534 @@ +using System; +using System.Threading; +using System.Threading.Channels; +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; +using WSwipeItem = Microsoft.UI.Xaml.Controls.SwipeItem; + +namespace Microsoft.Maui.DeviceTests +{ + [Category(TestCategory.SwipeView)] + public class SwipeItemMenuItemHandlerTests : CoreHandlerTestBase + { + static readonly TimeSpan ImageLoadTimeout = TimeSpan.FromSeconds(5); + + [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 source = new FileImageSourceStub("Resources/Images/delete.png"); + var service = MauiContext.Services + .GetRequiredService() + .GetRequiredImageSourceService(source); + var icon = Assert.IsType( + SwipeItemMenuItemHandler.CreateTintedIconSource( + source, + service)); + + Assert.Equal(new Uri("ms-appx:///delete.png"), icon.UriSource); + Assert.True(icon.ShowAsMonochrome); + }); + } + + [Fact] + public Task TintedRootedFileFallsBackToUntintedLoader() + { + return InvokeOnMainThreadAsync(() => + { + var source = new FileImageSourceStub(@"C:\images\delete.png"); + var service = MauiContext.Services + .GetRequiredService() + .GetRequiredImageSourceService(source); + var icon = SwipeItemMenuItemHandler.CreateTintedIconSource( + source, + service); + + 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 = await imageService.Requests.DequeueAsync(); + var image = new BitmapImage(); + request.SetResult(new ImageSourceServiceResult(image)); + + await load.WaitAsync(ImageLoadTimeout); + + var icon = Assert.IsType(handler.PlatformView.IconSource); + Assert.Same(image, icon.ImageSource); + + item.IconColor = Colors.Blue; + handler.UpdateValue(nameof(ISwipeItemMenuItemIconColor.IconColor)); + + Assert.True(imageService.Requests.IsEmpty); + Assert.Same(icon, handler.PlatformView.IconSource); + }); + } + + [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() + { + var imageService = new DelayedUriImageSourceService(); + EnsureHandlerCreated(builder => builder.ConfigureImageSources( + services => services.AddService(_ => imageService))); + + await InvokeOnMainThreadAsync(async () => + { + var item = new SwipeItemMenuItemStub + { + 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 = await imageService.Requests.DequeueAsync(); + var image = new BitmapImage(); + request.SetResult(new ImageSourceServiceResult(image)); + + await load.WaitAsync(ImageLoadTimeout); + + var icon = Assert.IsType(handler.PlatformView.IconSource); + Assert.Same(image, icon.ImageSource); + + item.IconColor = Colors.Blue; + handler.UpdateValue(nameof(ISwipeItemMenuItemIconColor.IconColor)); + + Assert.True(imageService.Requests.IsEmpty); + 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); + }); + } + + [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() + { + 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 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 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() + { + 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() + { + 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 = await imageService.Requests.DequeueAsync(); + + item.IconColor = Colors.Blue; + var newerLoad = SwipeItemMenuItemHandler.MapSourceAsync(handler, item); + var newerRequest = await imageService.Requests.DequeueAsync(); + var newerImage = new BitmapImage(); + 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(); + 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); + }); + } + + sealed class DelayedFileImageSourceService : IImageSourceService + { + public ImageLoadRequests 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 DelayedUriImageSourceService : IImageSourceService + { + public ImageLoadRequests 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 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 + { + } + + 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(); + + public IImageSource? LastSource { get; private set; } + + public int LoadCount { get; private set; } + + public Task?> GetImageSourceAsync( + IImageSource imageSource, + float scale = 1, + CancellationToken cancellationToken = default) + { + LoadCount++; + LastSource = imageSource; + return Task.FromResult?>( + new ImageSourceServiceResult(Image)); + } + } + + sealed class ExternalSwipeItemMenuItemHandler : ElementHandler, 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 new file mode 100644 index 000000000000..2e92399af70e --- /dev/null +++ b/src/Core/tests/DeviceTests/Handlers/SwipeView/SwipeItemMenuItemHandlerTests.iOS.cs @@ -0,0 +1,217 @@ +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; + +namespace Microsoft.Maui.DeviceTests +{ + [Category(TestCategory.SwipeView)] + public class SwipeItemMenuItemHandlerTests : CoreHandlerTestBase + { + [Fact] + public Task TextColorCanBeCleared() + { + return InvokeOnMainThreadAsync(() => + { + var item = new SwipeItemMenuItemStub + { + 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.Equal(defaultTitleColor, 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); + }); + } + + [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); + }); + } + + [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 IconColorChangeDoesNotReloadOrRedrawImage() + { + 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.2, 100.2); + item.Source = new FileImageSourceStub("custom.png"); + + await SwipeItemMenuItemHandler.MapSourceAsync(handler, item); + Assert.Equal(1, imageService.LoadCount); + 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)); + + 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); + }); + } + + [Fact] + public async Task ScaleOneImageBeyondRenderToleranceIsResized() + { + var renderScale = UIScreen.MainScreen.Scale; + var imageService = new CountingFileImageSourceService( + CountingFileImageSourceService.CreateImage(50 + (1 / renderScale) + 0.5f, 1)); + 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); + + var resizedImage = handler.PlatformView.ImageForState(UIControlState.Normal); + Assert.NotNull(resizedImage); + Assert.True(resizedImage.Size.Width <= 50); + Assert.True(resizedImage.Size.Height <= 50); + Assert.NotEqual(imageService.SourceImage.CGImage.Handle, resizedImage.CGImage.Handle); + }); + } + + sealed class CountingFileImageSourceService : IImageSourceService + { + public CountingFileImageSourceService() + : this(CreateImage(200, UIScreen.MainScreen.Scale)) + { + } + + public CountingFileImageSourceService(UIImage image) + { + SourceImage = image; + } + + public int LoadCount { get; private set; } + + public UIImage SourceImage { get; } + + public Task> GetImageAsync( + IImageSource imageSource, + float scale = 1, + CancellationToken cancellationToken = default) + { + LoadCount++; + return Task.FromResult>( + new ImageSourceServiceResult(SourceImage)); + } + + public static UIImage CreateImage(nfloat size, nfloat scale) + { + var bounds = new CGRect(0, 0, size, size); + var format = new UIGraphicsImageRendererFormat + { + Opaque = false, + Scale = scale + }; + using var renderer = new UIGraphicsImageRenderer(bounds.Size, format); + + return renderer.CreateImage(context => + { + UIColor.Red.SetFill(); + context.FillRect(bounds); + }); + } + } + } +} 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) + { + } + } +}