diff --git a/README.md b/README.md index 71becab..6c1de1a 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,8 @@ SVG format is recommended for best cross-platform support. - [ ] Add highlight property - [ ] Add support for keyboard shortcuts - [ ] Improve accessibility features -- [ ] Add support for submenus and separators +- [ ] Add support for submenus +- [x] Add support for separators - [ ] Add font icon support - [x] Add comprehensive unit and UI tests diff --git a/global.json b/global.json index ceecdf7..7a9c9e0 100644 --- a/global.json +++ b/global.json @@ -1,10 +1,10 @@ { "sdk": { - "version": "9.0.100", + "version": "10.0.100", "rollForward": "latestMinor" }, "msbuild-sdks": { "MSBuild.Sdk.Extras": "3.0.44", "Microsoft.Build.NoTargets" : "3.7.56" - } + } } diff --git a/sample/APES.MAUI.Sample.csproj b/sample/APES.MAUI.Sample.csproj index bc7f01c..d985641 100644 --- a/sample/APES.MAUI.Sample.csproj +++ b/sample/APES.MAUI.Sample.csproj @@ -1,9 +1,9 @@ - net9.0-android - $(TargetFrameworks);net9.0-ios;net9.0-maccatalyst - $(TargetFrameworks);net9.0-windows10.0.19041.0 + net10.0-android; + $(TargetFrameworks);net10.0-ios;net10.0-maccatalyst + $(TargetFrameworks);net10.0-windows10.0.19041.0 Exe APES.MAUI.Sample true @@ -37,7 +37,7 @@ win10-x64 __WINDOWS__ - + true full false diff --git a/sample/MainPage.xaml b/sample/MainPage.xaml index 7aa0103..9ba1dfe 100644 --- a/sample/MainPage.xaml +++ b/sample/MainPage.xaml @@ -76,6 +76,7 @@ + diff --git a/sample/run.sh b/sample/run.sh index 145e50a..e144c84 100755 --- a/sample/run.sh +++ b/sample/run.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -TFM="net9.0-android" +TFM="net10.0-android" CONFIG="Debug" PKG="com.apes.maui.sample" # <-- change me LOG="maui-logcat.log" diff --git a/src/APES.MAUI.csproj b/src/APES.MAUI.csproj index 9bd280a..f47e0a5 100644 --- a/src/APES.MAUI.csproj +++ b/src/APES.MAUI.csproj @@ -1,13 +1,13 @@ - net9.0;net9.0-android - $(TargetFrameworks);net9.0-ios;net9.0-maccatalyst - $(TargetFrameworks);net9.0-windows10.0.19041.0 + net10.0;net10.0-android; + $(TargetFrameworks);net10.0-ios;net10.0-maccatalyst + $(TargetFrameworks);net10.0-windows10.0.19041.0 14.2 - 14.0 + 15.0 21.0 - 29.0 + 29.0 10.0.19041.0 10.0.17763.0 APES.MAUI @@ -57,11 +57,11 @@ true true - + $(DefineConstants);MAUI true - + $(DefineConstants);__WINDOWS__ @@ -73,7 +73,7 @@ - + @@ -86,18 +86,18 @@ - + - + - + - + diff --git a/src/Droid/ContextMenuContainerRenderer.cs b/src/Droid/ContextMenuContainerRenderer.cs index f857025..700724c 100644 --- a/src/Droid/ContextMenuContainerRenderer.cs +++ b/src/Droid/ContextMenuContainerRenderer.cs @@ -45,9 +45,9 @@ protected override void ConnectHandler(ContentViewGroup platformView) if (VirtualView is ContextMenuContainer newElement) { newElement.BindingContextChanged += Element_BindingContextChanged; - if (newElement.MenuItems != null) + if (newElement.MenuItems is not null) { - foreach (ContextMenuItem element in newElement.MenuItems) + foreach (var element in newElement.MenuItems) { element.PropertyChanged += Item_Changed; } @@ -62,15 +62,14 @@ protected override void ConnectHandler(ContentViewGroup platformView) protected override ContentViewGroup CreatePlatformView() { - if (VirtualView == null) + if (VirtualView is null) { throw new InvalidOperationException($"{nameof(VirtualView)} must be set to create a ContentViewGroup"); } if (VirtualView is not ContextMenuContainer) { - throw new InvalidOperationException( - $"{nameof(VirtualView)} must be of type ContextMenuContainer, but was {VirtualView.GetType()} "); + throw new InvalidOperationException($"{nameof(VirtualView)} must be of type ContextMenuContainer, but was {VirtualView.GetType()} "); } var viewGroup = new ContainerViewGroup(Context); @@ -88,28 +87,26 @@ private void RefillMenuItems() private void MenuItems_CollectionChanged( object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) - { - if (e.OldItems != null) + if (e.OldItems is not null) { - foreach (ContextMenuItem element in e.OldItems) + foreach (BaseContextMenuItem item in e.OldItems) { - element.PropertyChanged -= Item_Changed; + item.PropertyChanged -= Item_Changed; } } - if (e.NewItems != null) + if (e.NewItems is not null) { - foreach (ContextMenuItem element in e.NewItems) + foreach (BaseContextMenuItem item in e.NewItems) { - element.PropertyChanged += Item_Changed; + item.PropertyChanged += Item_Changed; } } RefillMenuItems(); } - private void Item_Changed(object? sender, PropertyChangedEventArgs e) => ((ContainerViewGroup)PlatformView).NeedToRefillMenu = true; private void Element_BindingContextChanged(object? sender, EventArgs e) @@ -219,7 +216,7 @@ public override bool DispatchTouchEvent(MotionEvent? e) private void DeconstructInteraction() { - if (Element != null && _contextMenu != null) + if (Element is not null && _contextMenu is not null) { _contextMenu.Dismiss(); _contextMenu.Menu.Clear(); @@ -228,7 +225,7 @@ private void DeconstructInteraction() private void OpenContextMenu() { - if (GetContextMenu() == null) + if (GetContextMenu() is null) { ConstructNativeMenu(); FillMenuItems(); @@ -240,13 +237,20 @@ private void OpenContextMenu() private void ConstructNativeMenu() { var child = GetChildAt(0); - if (child == null) + if (child is null) { return; } _contextMenu = new PopupMenu(Context, child); _contextMenu.MenuItemClick += ContextMenu_MenuItemClick; + + // Enable group dividers for separators (API 28+) + if (Android.OS.Build.VERSION.SdkInt >= Android.OS.BuildVersionCodes.P) + { + _contextMenu.Menu.SetGroupDividerEnabled(true); + } + Field field = _contextMenu.Class.GetDeclaredField("mPopup"); field.Accessible = true; Java.Lang.Object? menuPopupHelper = field.Get(_contextMenu); @@ -257,7 +261,7 @@ private void ConstructNativeMenu() private void DeconstructNativeMenu() { - if (_contextMenu == null) + if (_contextMenu is null) { return; } @@ -268,42 +272,150 @@ private void DeconstructNativeMenu() NeedToRefillMenu = false; } - private void AddMenuItem(ContextMenuItem item) + private void AddMenuItem(BaseContextMenuItem item) { - if (_contextMenu == null) + if (_contextMenu is null) { return; } - var title = new SpannableString(item.Text); - if (item.IsDestructive) + switch (item) { - title.SetSpan(new ForegroundColorSpan(AColor.Red), 0, title.Length(), 0); + // Separator Items + case ContextMenuSeparator: + { + // For API < 28, create a disabled item as a visual separator + var separator = _contextMenu.Menu.Add("────────"); + separator?.SetEnabled(false); + break; + } + + // Normal Items + case ContextMenuItem contextItem: + { + if (string.IsNullOrEmpty(contextItem.Text)) + { + Logger.Error("ContextMenuItem text should not be empty!"); + break; + } + + var title = new SpannableString(contextItem.Text); + if (contextItem.IsDestructive) + { + title.SetSpan(new ForegroundColorSpan(AColor.Red), 0, title.Length(), 0); + } + + var contextAction = _contextMenu.Menu.Add(title); + if (contextAction is null) + { + Logger.Error("We couldn't create IMenuItem with title {0}", contextItem.Text); + break; + } + + contextAction.SetEnabled(contextItem.IsEnabled); + if (contextItem.Icon != null && !string.IsNullOrWhiteSpace(contextItem.Icon.File)) + { + string name = Path.GetFileNameWithoutExtension(contextItem.Icon.File); + int id = Context?.GetDrawableId(name) ?? 0; + if (id == 0) + { + break; + } + + var drawable = Context?.GetDrawable(id); + if (drawable is not null) + { + var wrapper = new DrawableWrapperX(drawable, 0); + if (contextItem.IsDestructive) + { + wrapper.SetTint(AColor.Red); + } + + contextAction.SetIcon(wrapper); + } + } + + break; + } } + } - var contextAction = _contextMenu.Menu.Add(title); - if (contextAction == null) + private void FillMenuItems() + { + // ReSharper disable once RedundantTypeCheckInPattern + if (Element is ContextMenuContainer {MenuItems.Count: > 0} element) + { + if (Android.OS.Build.VERSION.SdkInt >= Android.OS.BuildVersionCodes.P) + { + // Use group-based separators for API 28+ + int currentGroupId = 0; + + foreach (var item in element.MenuItems) + { + if (item is ContextMenuSeparator) + { + // Move to next group - this creates the visual separator + currentGroupId++; + } + else if (item is ContextMenuItem contextItem) + { + AddMenuItemToGroup(contextItem, currentGroupId); + } + } + } + else + { + // Fallback for API < 28 + foreach (var item in element.MenuItems) + { + AddMenuItem(item); + } + } + } + } + + private void AddMenuItemToGroup(ContextMenuItem contextItem, int groupId) + { + if (_contextMenu is null) { - Logger.Error("We couldn't create IMenuItem with title {0}", item.Text); return; } - contextAction.SetEnabled(item.IsEnabled); - + if (string.IsNullOrEmpty(contextItem.Text)) + { + Logger.Error("ContextMenuItem text should not be empty!"); + return; + } + + var title = new SpannableString(contextItem.Text); + if (contextItem.IsDestructive) + { + title.SetSpan(new ForegroundColorSpan(AColor.Red), 0, title.Length(), 0); + } + + // Add item to specific group + var contextAction = _contextMenu.Menu.Add(groupId, Menu.None, Menu.None, title); + if (contextAction is null) + { + Logger.Error("We couldn't create IMenuItem with title {0}", contextItem.Text); + return; + } - if (item.Icon != null) + contextAction.SetEnabled(contextItem.IsEnabled); + if (contextItem.Icon != null && !string.IsNullOrWhiteSpace(contextItem.Icon.File)) { - string name = Path.GetFileNameWithoutExtension(item.Icon.File); + string name = Path.GetFileNameWithoutExtension(contextItem.Icon.File); int id = Context?.GetDrawableId(name) ?? 0; if (id == 0) { return; } - Drawable? drawable = Context?.GetDrawable(id); - if (drawable != null) + + var drawable = Context?.GetDrawable(id); + if (drawable is not null) { - var wrapper = new DrawableWrapperX(drawable,0); - if (item.IsDestructive) + var wrapper = new DrawableWrapperX(drawable, 0); + if (contextItem.IsDestructive) { wrapper.SetTint(AColor.Red); } @@ -313,18 +425,6 @@ private void AddMenuItem(ContextMenuItem item) } } - private void FillMenuItems() - { - // ReSharper disable once RedundantTypeCheckInPattern - if (Element is ContextMenuContainer {MenuItems.Count: > 0} element) - { - foreach (var item in element.MenuItems) - { - AddMenuItem(item); - } - } - } - #pragma warning disable SA1137 private PopupMenu? GetContextMenu() #pragma warning restore SA1137 @@ -345,17 +445,17 @@ private void FillMenuItems() private void ContextMenu_MenuItemClick(object? sender, PopupMenu.MenuItemClickEventArgs e) { // ReSharper disable once RedundantCast - var item = ((ContextMenuContainer?)Element)?.MenuItems?.FirstOrDefault( - x => x.Text == e.Item.TitleFormatted?.ToString()); + var item = ((ContextMenuContainer?)Element)?.MenuItems? + .Where(x => x is ContextMenuItem) + .Select(x => (ContextMenuItem)x) + .FirstOrDefault(x => x.Text == e.Item?.TitleFormatted?.ToString()); + item?.OnItemTapped(); } - - public bool NeedToRefillMenu { get; set; } = false; - + public bool NeedToRefillMenu { get; set; } = false; } - private class MyTimer { private readonly TimeSpan _timespan; diff --git a/src/Mac/ContextContainerNativeView.cs b/src/Mac/ContextContainerNativeView.cs index 7f6804e..6099a23 100644 --- a/src/Mac/ContextContainerNativeView.cs +++ b/src/Mac/ContextContainerNativeView.cs @@ -22,7 +22,7 @@ public ContextContainerNativeView(IVisualElementRenderer childRenderer, ContextM } _menuItems = contextMenuItems; - if (_menuItems != null) + if (_menuItems is not null) { _menuItems.CollectionChanged += MenuItems_CollectionChanged; } @@ -33,17 +33,14 @@ public ContextContainerNativeView(IVisualElementRenderer childRenderer, ContextM public override void RightMouseDown(NSEvent theEvent) { HandleContextActions(theEvent); - base.RightMouseDown(theEvent); } - private void MenuItems_CollectionChanged( - object? sender, - System.Collections.Specialized.NotifyCollectionChangedEventArgs e) => RefillMenuItems(); + private void MenuItems_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) => RefillMenuItems(); private NSMenu? GetContextMenu() { - if (_contextMenu != null && _menuItems != null) + if (_contextMenu is not null && _menuItems is not null) { if (_menuItems.Count != _contextMenu.Count) { @@ -68,7 +65,7 @@ private void MenuItems_CollectionChanged( private void HandleContextActions(NSEvent theEvent) { - if (_menuItems == null) + if (_menuItems is null) { return; } @@ -79,7 +76,7 @@ private void HandleContextActions(NSEvent theEvent) return; } - if (GetContextMenu() == null) + if (GetContextMenu() is null) { ConstructNativeMenu(); FillMenuItems(); @@ -105,7 +102,7 @@ private void FillMenuItems() private void RefillMenuItems() { - if (_contextMenu == null) + if (_contextMenu is null) { return; } @@ -116,34 +113,54 @@ private void RefillMenuItems() } #pragma warning disable SA1202 - // ReSharper disable once InconsistentNaming - public NSMenuItem ToNSMenuItem(int i, ContextMenuItem menuItem) + public NSMenuItem ToNSMenuItem(int i, BaseContextMenuItem menuItem) #pragma warning restore SA1202 { - NSMenuItem nsMenuItem = new NSMenuItem(); - nsMenuItem.AttributedTitle = new NSAttributedString( - menuItem.Text, - foregroundColor: menuItem.IsDestructive ? NSColor.Red : null); - nsMenuItem.Tag = i; - nsMenuItem.Enabled = menuItem.IsEnabled; - nsMenuItem.Activated += NsMenuItem_Activated; - nsMenuItem.ValidateMenuItem = (t) => t.Enabled; - var nativeIcon = menuItem.Icon?.ToNative(); - if (nativeIcon != null) + switch (menuItem) { - var elementColor = menuItem.IsDestructive ? NSColor.Red : - NSAppearance.CurrentAppearance.Name == NSAppearance.NameDarkAqua ? NSColor.White : NSColor.Black; - nsMenuItem.Image = ImageHandler.ImageTintedWithColor(nativeIcon, elementColor, new CGSize(25, 25)); - } + // Separator Items + case ContextMenuSeparator separatorItem: + { + return NSMenuItem.SeparatorItem; + } + + // Normal Items + case ContextMenuItem contextItem: + { + if (string.IsNullOrEmpty(contextItem.Text)) + { + Logger.Error("ContextMenuItem text should not be empty!"); + return new NSMenuItem(); // Return empty item as fallback + } + + NSMenuItem nsMenuItem = new NSMenuItem(); + nsMenuItem.AttributedTitle = new NSAttributedString(contextItem.Text, foregroundColor: contextItem.IsDestructive ? NSColor.Red : null); + nsMenuItem.Tag = i; + nsMenuItem.Enabled = contextItem.IsEnabled; + nsMenuItem.Activated += NsMenuItem_Activated; + nsMenuItem.ValidateMenuItem = (t) => t.Enabled; + + var nativeIcon = contextItem.Icon?.ToNative(); + if (nativeIcon is not null) + { + var elementColor = contextItem.IsDestructive + ? NSColor.Red : + NSAppearance.CurrentAppearance.Name == NSAppearance.NameDarkAqua ? NSColor.White : NSColor.Black; + nsMenuItem.Image = ImageHandler.ImageTintedWithColor(nativeIcon, elementColor, new CGSize(25, 25)); + } - return nsMenuItem; + return nsMenuItem; + } + + default: + return new NSMenuItem(); + } } private void NsMenuItem_Activated(object sender, EventArgs e) { - var nsMenuItem = sender as NSMenuItem; - if (nsMenuItem == null) + if (sender is not NSMenuItem nsMenuItem) { Logger.Error("Couldn't cast sender to NSMenuItem"); return; diff --git a/src/Mac/ContextMenuContainerRenderer.cs b/src/Mac/ContextMenuContainerRenderer.cs index 5abcd67..f207f5a 100644 --- a/src/Mac/ContextMenuContainerRenderer.cs +++ b/src/Mac/ContextMenuContainerRenderer.cs @@ -17,7 +17,7 @@ internal class ContextMenuContainerRenderer : ViewRenderer e) { base.OnElementChanged(e); - if (e.NewElement == null || e.NewElement.Content == null) + if (e.NewElement is null || e.NewElement.Content is null) { return; } diff --git a/src/Shared/BaseContextMenuItem.cs b/src/Shared/BaseContextMenuItem.cs new file mode 100644 index 0000000..3541f38 --- /dev/null +++ b/src/Shared/BaseContextMenuItem.cs @@ -0,0 +1,10 @@ +// MIT License +// Copyright (c) 2021 Pavel Anpin + +using Microsoft.Maui.Controls; + +namespace APES.MAUI; + +public class BaseContextMenuItem : Element +{ +} diff --git a/src/Shared/ContextMenuContainer.cs b/src/Shared/ContextMenuContainer.cs index dbd7c04..58a01a0 100644 --- a/src/Shared/ContextMenuContainer.cs +++ b/src/Shared/ContextMenuContainer.cs @@ -25,7 +25,7 @@ public ContextMenuItems? MenuItems protected override void OnBindingContextChanged() { base.OnBindingContextChanged(); - if (MenuItems != null) + if (MenuItems is not null) { SetBindingContextForItems(MenuItems); } @@ -36,17 +36,17 @@ private static object DefaultMenuItemsCreator(BindableObject bindableObject) var menuItems = new ContextMenuItems(); menuItems.CollectionChanged += (_, e) => { - if (e.OldItems != null) + if (e.OldItems is not null) { - foreach (ContextMenuItem item in e.OldItems) + foreach (BindableObject item in e.OldItems) { item.RemoveBinding(BindingContextProperty); } } - if (e.NewItems != null) + if (e.NewItems is not null) { - foreach (ContextMenuItem item in e.NewItems) + foreach (BindableObject item in e.NewItems) { SetInheritedBindingContext(item, bindableObject.BindingContext); } @@ -59,7 +59,7 @@ private static void OnMenuItemsChanged(BindableObject bindableObject, object new { if (oldValue is ContextMenuItems oldItems) { - foreach (ContextMenuItem item in oldItems) + foreach (var item in oldItems) { item.RemoveBinding(BindingContextProperty); } @@ -69,14 +69,14 @@ private static void OnMenuItemsChanged(BindableObject bindableObject, object new if (newValue is ContextMenuItems newItems) { - foreach (ContextMenuItem item in newItems) + foreach (var item in newItems) { SetInheritedBindingContext(item, bindableObject.BindingContext); } } } - private void SetBindingContextForItems(IList items) + private void SetBindingContextForItems(IList items) { for (int i = 0; i < items.Count; i++) { @@ -84,5 +84,5 @@ private void SetBindingContextForItems(IList items) } } - private void SetBindingContextForItem(ContextMenuItem item) => SetInheritedBindingContext(item, BindingContext); + private void SetBindingContextForItem(BaseContextMenuItem item) => SetInheritedBindingContext(item, BindingContext); } diff --git a/src/Shared/ContextMenuItem.cs b/src/Shared/ContextMenuItem.cs index eedd612..43b4ce0 100644 --- a/src/Shared/ContextMenuItem.cs +++ b/src/Shared/ContextMenuItem.cs @@ -2,14 +2,13 @@ // Copyright (c) 2021 Pavel Anpin using System.Windows.Input; - using Microsoft.Maui.Controls; namespace APES.MAUI; public delegate void ItemTapped(ContextMenuItem item); -public class ContextMenuItem : Element +public class ContextMenuItem : BaseContextMenuItem { public static readonly BindableProperty TextProperty = BindableProperty.Create(nameof(Text), typeof(string), typeof(ContextMenuItem)); diff --git a/src/Shared/ContextMenuItems.cs b/src/Shared/ContextMenuItems.cs index 8dc3a74..5204aeb 100644 --- a/src/Shared/ContextMenuItems.cs +++ b/src/Shared/ContextMenuItems.cs @@ -6,23 +6,19 @@ namespace APES.MAUI { - public class ContextMenuItems : ObservableCollection + public class ContextMenuItems : ObservableCollection { public ContextMenuItem this[string text] => FindTextIndex(text); private ContextMenuItem FindTextIndex(string text) { - for (int j = 0; j < Items.Count; j++) + foreach (var item in Items) { - if (Items[j].Text == text) - { - return Items[j]; - } + if (item is ContextMenuItem contextMenuItem && contextMenuItem.Text == text) + return contextMenuItem; } - throw new ArgumentOutOfRangeException( - nameof(text), - $"Item with text {text} was not present"); + throw new ArgumentOutOfRangeException(nameof(text), $"Item with text {text} was not present"); } } } diff --git a/src/Shared/ContextMenuSeparator.cs b/src/Shared/ContextMenuSeparator.cs new file mode 100644 index 0000000..07a848b --- /dev/null +++ b/src/Shared/ContextMenuSeparator.cs @@ -0,0 +1,8 @@ +// MIT License +// Copyright (c) 2021 Pavel Anpin + +namespace APES.MAUI; + +public class ContextMenuSeparator : BaseContextMenuItem +{ +} diff --git a/src/UWP/ContextMenuContainerRenderer.cs b/src/UWP/ContextMenuContainerRenderer.cs index fa275c3..f3925e8 100644 --- a/src/UWP/ContextMenuContainerRenderer.cs +++ b/src/UWP/ContextMenuContainerRenderer.cs @@ -129,55 +129,78 @@ private void SetupMenuItems(MenuFlyout menu) } } - private void AddMenuItem(MenuFlyout contextMenu, ContextMenuItem item) + private void AddMenuItem(MenuFlyout contextMenu, BaseContextMenuItem item) { - var nativeItem = new MenuFlyoutItem(); - nativeItem.SetBinding( - MenuFlyoutItem.TextProperty, - new WBinding() { Path = new PropertyPath(nameof(ContextMenuItem.Text)) }); - - // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract - if (ImageConverter != null) + if (contextMenu.Items is null) { - nativeItem.SetBinding( - MenuFlyoutItem.IconProperty, - new WBinding() { Path = new PropertyPath(nameof(ContextMenuItem.Icon)), Converter = ImageConverter }); + return; } - nativeItem.SetBinding( - FrameworkElement.StyleProperty, - new WBinding() - { - Path = new PropertyPath(nameof(ContextMenuItem.IsDestructive)), - Converter = BoolToStyleConverter, - }); - nativeItem.SetBinding( - WControl.IsEnabledProperty, - new WBinding() { Path = new PropertyPath(nameof(ContextMenuItem.IsEnabled)) }); - nativeItem.Click += NativeItem_Click; - nativeItem.DataContext = item; - if (contextMenu.Items != null) + switch (item) { - contextMenu.Items.Add(nativeItem); + // Separator Items + case ContextMenuSeparator separatorItem: + { + var separator = new MenuFlyoutSeparator(); + contextMenu.Items.Add(separator); + break; + } + + // Normal Items + case ContextMenuItem contextItem: + { + if (string.IsNullOrEmpty(contextItem.Text)) + { + Logger.Error("ContextMenuItem text should not be empty!"); + break; + } + + var nativeItem = new MenuFlyoutItem(); + nativeItem.SetBinding( + MenuFlyoutItem.TextProperty, + new WBinding() { Path = new PropertyPath(nameof(ContextMenuItem.Text)) }); + + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (ImageConverter is not null) + { + nativeItem.SetBinding( + MenuFlyoutItem.IconProperty, + new WBinding() { Path = new PropertyPath(nameof(ContextMenuItem.Icon)), Converter = ImageConverter }); + } + + nativeItem.SetBinding( + FrameworkElement.StyleProperty, + new WBinding() + { + Path = new PropertyPath(nameof(ContextMenuItem.IsDestructive)), + Converter = BoolToStyleConverter, + }); + nativeItem.SetBinding( + WControl.IsEnabledProperty, + new WBinding() { Path = new PropertyPath(nameof(ContextMenuItem.IsEnabled)) }); + nativeItem.Click += NativeItem_Click; + nativeItem.DataContext = contextItem; + contextMenu.Items.Add(nativeItem); + break; + } } } private void NativeItem_Click(object sender, RoutedEventArgs e) { - var item = sender as MenuFlyoutItem; - if (item == null) + if (sender is not MenuFlyoutItem item) { - Logger.Error("Couldn't cast to MenuFlyoutItem"); + Logger.Error("Couldn't cast to MenuFlyoutItem."); return; } - if (item.DataContext is not ContextMenuItem context) + if (item.DataContext is not ContextMenuItem contextItem) { - Logger.Error("Couldn't cast MenuFlyoutItem.DataContext to ContextMenuItem"); + Logger.Error("Couldn't cast MenuFlyoutItem.DataContext to ContextMenuItem."); return; } - context.OnItemTapped(); + contextItem.OnItemTapped(); } #pragma warning disable SA1201 diff --git a/src/iOS/ContextMenuContainerRenderer.cs b/src/iOS/ContextMenuContainerRenderer.cs index b0a3121..dc43eb7 100644 --- a/src/iOS/ContextMenuContainerRenderer.cs +++ b/src/iOS/ContextMenuContainerRenderer.cs @@ -25,7 +25,7 @@ public override void SetVirtualView(IView view) if (VirtualView is ContextMenuContainer old) { old.BindingContextChanged -= Element_BindingContextChanged; - if (old.MenuItems != null) + if (old.MenuItems is not null) { old.MenuItems.CollectionChanged -= MenuItems_CollectionChanged; } @@ -37,7 +37,7 @@ public override void SetVirtualView(IView view) if (VirtualView is ContextMenuContainer newElement) { newElement.BindingContextChanged += Element_BindingContextChanged; - if (newElement.MenuItems != null) + if (newElement.MenuItems is not null) { newElement.MenuItems.CollectionChanged += MenuItems_CollectionChanged; } @@ -55,9 +55,7 @@ private void RefillMenuItems() } } - private void MenuItems_CollectionChanged( - object? sender, - NotifyCollectionChangedEventArgs e) => RefillMenuItems(); + private void MenuItems_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) => RefillMenuItems(); private void Element_BindingContextChanged(object? sender, EventArgs e) => RefillMenuItems(); @@ -69,7 +67,7 @@ private void MenuItems_CollectionChanged( private void DeconstructInteraction() { // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract - if (Control != null && _contextMenu != null) + if (Control is not null && _contextMenu is not null) { Control.RemoveInteraction(_contextMenu); } @@ -80,8 +78,7 @@ private void ConstructInteraction(ContextMenuContainer container) DeconstructInteraction(); if (container.MenuItems?.Count > 0) { - _contextMenuDelegate = - new ContextMenuDelegate(container.MenuItems, () => TraitCollection.UserInterfaceStyle); + _contextMenuDelegate = new ContextMenuDelegate(container.MenuItems, () => TraitCollection.UserInterfaceStyle); _contextMenu = new UIContextMenuInteraction(_contextMenuDelegate); Control.AddInteraction(_contextMenu); } diff --git a/src/iOS/ContextMenuDelegate.cs b/src/iOS/ContextMenuDelegate.cs index 8a81304..7d9dbab 100644 --- a/src/iOS/ContextMenuDelegate.cs +++ b/src/iOS/ContextMenuDelegate.cs @@ -39,37 +39,79 @@ public ContextMenuDelegate(ContextMenuItems items, Func ge public override UIContextMenuConfiguration GetConfigurationForMenu(UIContextMenuInteraction interaction, CGPoint location) => UIContextMenuConfiguration.Create(_identifier, _preview != null ? PreviewDelegate! : null, ConstructMenuFromItems); - private IEnumerable ToNativeActions(IEnumerable sharedDefinitions) + private IEnumerable ToNativeActions(IEnumerable sharedDefinitions) { var iconColor = _getCurrentTheme() == UIUserInterfaceStyle.Dark ? UIColor.White : UIColor.Black; - foreach (var item in sharedDefinitions) + var items = sharedDefinitions.ToList(); + var groups = new List>(); + var currentGroup = new List(); + + // Group items by separators + foreach (var item in items) { - if (!string.IsNullOrEmpty(item.Text)) + switch (item) { - UIImage? nativeImage = null; - if (item.Icon != null && !string.IsNullOrWhiteSpace(item.Icon.File)) - { - nativeImage = new UIImage(item.Icon.File); - nativeImage = nativeImage.ImageWithRenderingMode(UIImageRenderingMode.AlwaysTemplate); - nativeImage.ApplyTintColor(item.IsDestructive ? UIColor.Red : iconColor); - } + case ContextMenuSeparator: + if (currentGroup.Count > 0) + { + groups.Add(currentGroup); + currentGroup = new List(); + } + + break; + + case ContextMenuItem contextItem: + currentGroup.Add(contextItem); + break; + } + } + + // Add the last group if it has items + if (currentGroup.Count > 0) + { + groups.Add(currentGroup); + } - var nativeItem = UIAction.Create(item.Text, nativeImage, item.Text, ActionDelegate); - if (!item.IsEnabled) + // Convert each group to a UIMenu with DisplayInline option + foreach (var group in groups) + { + var groupActions = new List(); + + foreach (var contextItem in group) + { + if (!string.IsNullOrEmpty(contextItem.Text)) { - nativeItem.Attributes |= UIMenuElementAttributes.Disabled; - } + UIImage? nativeImage = null; + if (contextItem.Icon is not null && !string.IsNullOrWhiteSpace(contextItem.Icon.File)) + { + nativeImage = UIImage.FromBundle(contextItem.Icon.File); + nativeImage = nativeImage?.ImageWithRenderingMode(UIImageRenderingMode.AlwaysTemplate); + nativeImage?.ApplyTintColor(contextItem.IsDestructive ? UIColor.Red : iconColor); + } + + var nativeItem = UIAction.Create(contextItem.Text, nativeImage, contextItem.Text, ActionDelegate); + if (!contextItem.IsEnabled) + { + nativeItem.Attributes |= UIMenuElementAttributes.Disabled; + } - if (item.IsDestructive) + if (contextItem.IsDestructive) + { + nativeItem.Attributes |= UIMenuElementAttributes.Destructive; + } + + groupActions.Add(nativeItem); + } + else { - nativeItem.Attributes |= UIMenuElementAttributes.Destructive; + Logger.Error("ContextMenuItem text should not be empty!"); } - - yield return nativeItem; } - else + + if (groupActions.Count > 0) { - Logger.Error("ContextMenuItem text should not be empty!"); + yield return UIMenu.Create(string.Empty, null, UIMenuIdentifier.None, UIMenuOptions.DisplayInline, + groupActions.ToArray()); } } } @@ -78,9 +120,10 @@ private IEnumerable ToNativeActions(IEnumerable private UIMenu ConstructMenuFromItems(UIMenuElement[] suggestedActions) { - _nativeMenu = _nativeMenu == null ? + _nativeMenu = _nativeMenu is null ? UIMenu.Create(ToNativeActions(_menuItems).ToArray()) : _nativeMenu.GetMenuByReplacingChildren(ToNativeActions(_menuItems).ToArray()); + return _nativeMenu; } diff --git a/tests/ui/android/AppiumSetup.cs b/tests/ui/android/AppiumSetup.cs index 7ea22f8..36bb8f6 100644 --- a/tests/ui/android/AppiumSetup.cs +++ b/tests/ui/android/AppiumSetup.cs @@ -23,7 +23,7 @@ public class AppiumSetup "sample", "bin", "Release", - "net9.0-android", + "net10.0-android", "publish", $"{AndroidApplication}-Signed.apk")); private static AppiumDriver? driver; diff --git a/tests/ui/android/UITests.Android.csproj b/tests/ui/android/UITests.Android.csproj index 3c8d01a..41ee717 100644 --- a/tests/ui/android/UITests.Android.csproj +++ b/tests/ui/android/UITests.Android.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable enable true diff --git a/tests/ui/android/test.sh b/tests/ui/android/test.sh index ec6fbd9..a750e14 100755 --- a/tests/ui/android/test.sh +++ b/tests/ui/android/test.sh @@ -1,8 +1,8 @@ #!/bin/sh set -e -dotnet publish ../../../sample -f:net9.0-android -c Release \ +dotnet publish ../../../sample -f:net10.0-android -c Release \ -p:RunAOTCompilation=true \ -p:AndroidPackageFormat=apk -adb install ../../../sample/bin/Release/net9.0-android/publish/com.apes.maui.sample-Signed.apk +adb install ../../../sample/bin/Release/net10.0-android/publish/com.apes.maui.sample-Signed.apk dotnet test diff --git a/tests/ui/ios/AppiumSetup.cs b/tests/ui/ios/AppiumSetup.cs index 1d8e129..4964d79 100644 --- a/tests/ui/ios/AppiumSetup.cs +++ b/tests/ui/ios/AppiumSetup.cs @@ -21,7 +21,7 @@ public class AppiumSetup "sample", "bin", "Release", - "net9.0-ios", + "net10.0-ios", "iossimulator-x64", $"{iosApplication}.app")); private static AppiumDriver? driver; diff --git a/tests/ui/ios/UITests.iOS.csproj b/tests/ui/ios/UITests.iOS.csproj index 3c8d01a..41ee717 100644 --- a/tests/ui/ios/UITests.iOS.csproj +++ b/tests/ui/ios/UITests.iOS.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable enable true diff --git a/tests/ui/shared/UITests.Shared.csproj b/tests/ui/shared/UITests.Shared.csproj index f87559d..4fb44b0 100644 --- a/tests/ui/shared/UITests.Shared.csproj +++ b/tests/ui/shared/UITests.Shared.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable enable false diff --git a/tests/ui/windows/AppiumSetup.cs b/tests/ui/windows/AppiumSetup.cs index e5b1b22..f28d98c 100644 --- a/tests/ui/windows/AppiumSetup.cs +++ b/tests/ui/windows/AppiumSetup.cs @@ -26,7 +26,7 @@ public class AppiumSetup "sample", "bin", "Release", - "net9.0-windows10.0.19041.0", + "net10.0-windows10.0.19041.0", "win-x64", "publish", $"{windowsApplication}.exe")); diff --git a/tests/ui/windows/UITests.Windows.csproj b/tests/ui/windows/UITests.Windows.csproj index e3db802..9714e40 100644 --- a/tests/ui/windows/UITests.Windows.csproj +++ b/tests/ui/windows/UITests.Windows.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable enable true diff --git a/tests/unit/unit.csproj b/tests/unit/unit.csproj index 5345320..12891c8 100644 --- a/tests/unit/unit.csproj +++ b/tests/unit/unit.csproj @@ -1,12 +1,12 @@ - net9.0 + net10.0 false APES.MAUI.Tests - + $(DefineConstants);MAUI @@ -19,7 +19,7 @@ Always - +