diff --git a/ios/NewArch/MenuView.mm b/ios/NewArch/MenuView.mmindex a54e619eb39e3402fa63f2ee4f734063d4f8fc58..066b01259a3c8ad9532688606be3d29a94a8f7d4 100644--- a/ios/NewArch/MenuView.mm+++ b/ios/NewArch/MenuView.mm@@ -105,6 +105,27 @@ - (void)onOpenMenu { NSMutableArray<NSDictionary *> *subactionsArray = [NSMutableArray arrayWithCapacity:actions.size()]; if (action.subactions.size() > 0) { for (const MenuViewActionsSubactionsStruct &subaction : action.subactions) {+ NSMutableArray<NSDictionary *> *subSubactionsArray =+ [NSMutableArray arrayWithCapacity:subaction.subactions.size()];+ for (const MenuViewActionsSubactionsSubactionsStruct &subSubaction : subaction.subactions) {+ NSDictionary *subSubactionDict = @{+ @"id": [NSString stringWithUTF8String:subSubaction.id.c_str()],+ @"title": [NSString stringWithUTF8String:subSubaction.title.c_str()],+ @"titleColor": @(subSubaction.titleColor),+ @"subtitle": [NSString stringWithUTF8String:subSubaction.subtitle.c_str()],+ @"state": [NSString stringWithUTF8String:subSubaction.state.c_str()],+ @"image": [NSString stringWithUTF8String:subSubaction.image.c_str()],+ @"imageColor": @(subSubaction.imageColor),+ @"displayInline": @(subSubaction.displayInline),+ @"attributes": @{+ @"destructive": @(subSubaction.attributes.destructive),+ @"disabled": @(subSubaction.attributes.disabled),+ @"hidden": @(subSubaction.attributes.hidden),+ @"keepsMenuPresented": @(subSubaction.attributes.keepsMenuPresented),+ },+ };+ [subSubactionsArray addObject:subSubactionDict];+ } NSDictionary *subactionDict = @{ @"id": [NSString stringWithUTF8String:subaction.id.c_str()], @"title": [NSString stringWithUTF8String:subaction.title.c_str()],@@ -118,7 +139,9 @@ - (void)onOpenMenu { @"destructive": @(subaction.attributes.destructive), @"disabled": @(subaction.attributes.disabled), @"hidden": @(subaction.attributes.hidden),+ @"keepsMenuPresented": @(subaction.attributes.keepsMenuPresented), },+ @"subactions": subSubactionsArray, }; [subactionsArray addObject:subactionDict]; }@@ -138,6 +161,7 @@ - (void)onOpenMenu { @"destructive": @(action.attributes.destructive), @"disabled": @(action.attributes.disabled), @"hidden": @(action.attributes.hidden),+ @"keepsMenuPresented": @(action.attributes.keepsMenuPresented), }, @"subactions": subactionsArray, };diff --git a/ios/Shared/MenuViewImplementation.swift b/ios/Shared/MenuViewImplementation.swiftindex 5c4e0da4292b15d3a27b5ea1555f11452a470815..db134864676ed83dbcd895d7a1bde38e8037a005 100644--- a/ios/Shared/MenuViewImplementation.swift+++ b/ios/Shared/MenuViewImplementation.swift@@ -59,18 +59,43 @@ public class MenuViewImplementation: UIButton { self.setup() } + // Presentation is tracked from the two delegate methods the class already+ // overrode. Overriding willDisplayMenuFor as well (even for bookkeeping)+ // shadows UIButton's own implementation and degrades the button-anchored+ // presentation into generic context-menu chrome — an empty header row with+ // a dismiss chevron appears above the actions. public override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {+ // Flush updates deferred by the presented-guard before the action+ // provider snapshots self.menu (covers a stuck flag from an+ // interaction that never ended cleanly).+ if pendingMenu != nil {+ pendingMenu = nil+ isMenuPresented = false+ self.setup()+ }+ isMenuPresented = true sendMenuOpen() return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { [weak self] _ in guard let self = self else { return nil } return self.menu } }- + public override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willEndFor configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) { sendMenuClose()+ isMenuPresented = false+ if pendingMenu != nil {+ // Re-run the full assignment now that presentation is over, so+ // any props deferred by the presented-guard (menu contents, press+ // mode) land on the button.+ pendingMenu = nil+ self.setup()+ } } + private var isMenuPresented = false+ private var pendingMenu: UIMenu?+ func setup () { let menu = UIMenu(title: _title, identifier: nil,@@ -86,8 +111,98 @@ public class MenuViewImplementation: UIButton { } } + if isMenuPresented {+ // An action fired with keepsMenuPresented leaves the menu on+ // screen, and reassigning self.menu while it is presented makes+ // UIKit dismiss it — the update triggered by a selection would+ // defeat the keep-presented attribute. Update the visible copy in+ // place instead and defer the button assignment to dismissal.+ pendingMenu = menu+ self.refreshPresentedMenu(menu)+ return+ }+ self.menu = menu self.showsMenuAsPrimaryAction = !shouldOpenOnLongPress+ // In long-press mode the button must not intercept touches: as a+ // full-bounds contentView it sits IN FRONT of the React children of+ // the hosting component view, which would swallow their taps (e.g. a+ // row Pressable wrapped by MenuView). The context-menu interaction is+ // hosted by the superview instead (see updateLongPressInteraction) so+ // long-press and pointer secondary-click still present the menu, with+ // the wrapped content as the zoom preview.+ self.isUserInteractionEnabled = !shouldOpenOnLongPress+ self.updateLongPressInteraction()+ }++ private func refreshPresentedMenu(_ menu: UIMenu) {+ // Tap mode presents through the button's built-in interaction,+ // long-press mode through the superview-hosted one; cover both (plus+ // the interaction added in init) and dedupe by identity.+ var candidates = self.interactions.compactMap { $0 as? UIContextMenuInteraction }+ if let builtin = self.contextMenuInteraction {+ candidates.append(builtin)+ }+ if let host = longPressInteraction {+ candidates.append(host)+ }+ var visited: Set<ObjectIdentifier> = []+ for interaction in candidates where visited.insert(ObjectIdentifier(interaction)).inserted {+ // The block receives whichever menu level is currently on screen —+ // the navigated submenu when the user picked inside one, not the+ // root. Swap in the matching node from the rebuilt tree (stable+ // identifiers from the JS action ids) so that level updates in+ // place; returning an unrelated menu instead makes UIKit render it+ // as navigation into a foreign menu, with a stale or blank+ // expanded-submenu header row above the actions. The root carries+ // an auto-generated identifier that never matches, so it falls+ // through to a children-only replacement.+ interaction.updateVisibleMenu { [weak self] visibleMenu in+ guard let self = self else { return visibleMenu }+ if let replacement = self.menuMatching(visibleMenu.identifier, in: menu) {+ return replacement+ }+ return visibleMenu.replacingChildren(menu.children)+ }+ }+ }++ private func menuMatching(_ identifier: UIMenu.Identifier, in menu: UIMenu) -> UIMenu? {+ if menu.identifier == identifier {+ return menu+ }+ for element in menu.children {+ if let submenu = element as? UIMenu, let match = menuMatching(identifier, in: submenu) {+ return match+ }+ }+ return nil+ }++ private var longPressInteraction: UIContextMenuInteraction?++ public override func didMoveToSuperview() {+ super.didMoveToSuperview()+ self.updateLongPressInteraction()+ }++ private func updateLongPressInteraction() {+ if shouldOpenOnLongPress, let host = self.superview {+ if let existing = longPressInteraction, existing.view === host {+ return+ }+ if let existing = longPressInteraction {+ existing.view?.removeInteraction(existing)+ }+ let interaction = UIContextMenuInteraction(delegate: self)+ host.addInteraction(interaction)+ longPressInteraction = interaction+ return+ }+ if let existing = longPressInteraction {+ existing.view?.removeInteraction(existing)+ longPressInteraction = nil+ } } public override func reactSetFrame(_ frame: CGRect) {diff --git a/ios/Shared/RCTMenuItem.swift b/ios/Shared/RCTMenuItem.swiftindex bb6bb2b7ad56135089f267587c974b166760539d..949b3f7ec49af7ba26d966a8923a51f619443d5a 100644--- a/ios/Shared/RCTMenuItem.swift+++ b/ios/Shared/RCTMenuItem.swift@@ -103,10 +103,18 @@ class RCTMenuAction { subMenuActions.append(subaction.createUIMenuElement(handler)) } var menu: UIMenu;+ // Stable identifiers let updateVisibleMenu match submenu nodes in+ // place, so a refresh while the menu is presented keeps the user's+ // current submenu level instead of popping back to the root.+ // Inline sections don't navigate, so they stay stock.+ let menuIdentifier = identifier.map { UIMenu.Identifier(rawValue: $0.rawValue) } if self.displayInline { menu = UIMenu(title: title, image: image, options: .displayInline, children: subMenuActions) } else {- menu = UIMenu(title: title, image: image, children: subMenuActions)+ menu = UIMenu(title: title, image: image, identifier: menuIdentifier, children: subMenuActions)+ }+ if #available(iOS 15.0, *) {+ menu.subtitle = subtitle } if #available(iOS 16.0, *) {diff --git a/src/NativeModuleSpecs/UIMenuNativeComponent.ts b/src/NativeModuleSpecs/UIMenuNativeComponent.tsindex e6509355275596c451f9d082223cc16bfe504e1a..c783cdaf73f5635cf9835824ca85fba3b46453b4 100644--- a/src/NativeModuleSpecs/UIMenuNativeComponent.ts+++ b/src/NativeModuleSpecs/UIMenuNativeComponent.ts@@ -13,6 +13,22 @@ import codegenNativeComponent from "react-native/Libraries/Utilities/codegenNati types here, to avoid issues while `pod install` takes place. */ +type SubSubAction = {+ id?: string;+ title: string;+ titleColor?: Int32;+ subtitle?: string;+ state?: string;+ image?: string;+ imageColor?: Int32;+ displayInline?: boolean;+ attributes?: {+ destructive?: boolean;+ disabled?: boolean;+ hidden?: boolean;+ keepsMenuPresented?: boolean;+ };+}; type SubAction = { id?: string; title: string;@@ -26,7 +42,11 @@ type SubAction = { destructive?: boolean; disabled?: boolean; hidden?: boolean;+ keepsMenuPresented?: boolean; };+ // One extra nesting level (menu → submenu → nested submenu leaves); the+ // codegen structs can't recurse, so depth is capped explicitly.+ subactions?: Array<SubSubAction>; }; type MenuAction = { id?: string;@@ -41,6 +61,7 @@ type MenuAction = { destructive?: boolean; disabled?: boolean; hidden?: boolean;+ keepsMenuPresented?: boolean; }; subactions?: Array<SubAction>; };
diff --git a/ios/NewArch/MenuView.mm b/ios/NewArch/MenuView.mm
index a54e619eb39e3402fa63f2ee4f734063d4f8fc58..066b01259a3c8ad9532688606be3d29a94a8f7d4 100644
--- a/ios/NewArch/MenuView.mm
+++ b/ios/NewArch/MenuView.mm
@@ -105,6 +105,27 @@ - (void)onOpenMenu {
NSMutableArray<NSDictionary *> *subactionsArray = [NSMutableArray arrayWithCapacity:actions.size()];
if (action.subactions.size() > 0) {
for (const MenuViewActionsSubactionsStruct &subaction : action.subactions) {
+ NSMutableArray<NSDictionary *> *subSubactionsArray =
+ [NSMutableArray arrayWithCapacity:subaction.subactions.size()];
+ for (const MenuViewActionsSubactionsSubactionsStruct &subSubaction : subaction.subactions) {
+ NSDictionary *subSubactionDict = @{
+ @"id": [NSString stringWithUTF8String:subSubaction.id.c_str()],
+ @"title": [NSString stringWithUTF8String:subSubaction.title.c_str()],
+ @"titleColor": @(subSubaction.titleColor),
+ @"subtitle": [NSString stringWithUTF8String:subSubaction.subtitle.c_str()],
+ @"state": [NSString stringWithUTF8String:subSubaction.state.c_str()],
+ @"image": [NSString stringWithUTF8String:subSubaction.image.c_str()],
+ @"imageColor": @(subSubaction.imageColor),
+ @"displayInline": @(subSubaction.displayInline),
+ @"attributes": @{
+ @"destructive": @(subSubaction.attributes.destructive),
+ @"disabled": @(subSubaction.attributes.disabled),
+ @"hidden": @(subSubaction.attributes.hidden),
+ @"keepsMenuPresented": @(subSubaction.attributes.keepsMenuPresented),
+ },
+ };
+ [subSubactionsArray addObject:subSubactionDict];
+ }
NSDictionary *subactionDict = @{
@"id": [NSString stringWithUTF8String:subaction.id.c_str()],
@"title": [NSString stringWithUTF8String:subaction.title.c_str()],
@@ -118,7 +139,9 @@ - (void)onOpenMenu {
@"destructive": @(subaction.attributes.destructive),
@"disabled": @(subaction.attributes.disabled),
@"hidden": @(subaction.attributes.hidden),
+ @"keepsMenuPresented": @(subaction.attributes.keepsMenuPresented),
},
+ @"subactions": subSubactionsArray,
};
[subactionsArray addObject:subactionDict];
}
@@ -138,6 +161,7 @@ - (void)onOpenMenu {
@"destructive": @(action.attributes.destructive),
@"disabled": @(action.attributes.disabled),
@"hidden": @(action.attributes.hidden),
+ @"keepsMenuPresented": @(action.attributes.keepsMenuPresented),
},
@"subactions": subactionsArray,
};
diff --git a/ios/Shared/MenuViewImplementation.swift b/ios/Shared/MenuViewImplementation.swift
index 5c4e0da4292b15d3a27b5ea1555f11452a470815..db134864676ed83dbcd895d7a1bde38e8037a005 100644
--- a/ios/Shared/MenuViewImplementation.swift
+++ b/ios/Shared/MenuViewImplementation.swift
@@ -59,18 +59,43 @@ public class MenuViewImplementation: UIButton {
self.setup()
}
+ // Presentation is tracked from the two delegate methods the class already
+ // overrode. Overriding willDisplayMenuFor as well (even for bookkeeping)
+ // shadows UIButton's own implementation and degrades the button-anchored
+ // presentation into generic context-menu chrome — an empty header row with
+ // a dismiss chevron appears above the actions.
public override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
+ // Flush updates deferred by the presented-guard before the action
+ // provider snapshots self.menu (covers a stuck flag from an
+ // interaction that never ended cleanly).
+ if pendingMenu != nil {
+ pendingMenu = nil
+ isMenuPresented = false
+ self.setup()
+ }
+ isMenuPresented = true
sendMenuOpen()
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { [weak self] _ in
guard let self = self else { return nil }
return self.menu
}
}
-
+
public override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willEndFor configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) {
sendMenuClose()
+ isMenuPresented = false
+ if pendingMenu != nil {
+ // Re-run the full assignment now that presentation is over, so
+ // any props deferred by the presented-guard (menu contents, press
+ // mode) land on the button.
+ pendingMenu = nil
+ self.setup()
+ }
}
+ private var isMenuPresented = false
+ private var pendingMenu: UIMenu?
+
func setup () {
let menu = UIMenu(title: _title,
identifier: nil,
@@ -86,8 +111,98 @@ public class MenuViewImplementation: UIButton {
}
}
+ if isMenuPresented {
+ // An action fired with keepsMenuPresented leaves the menu on
+ // screen, and reassigning self.menu while it is presented makes
+ // UIKit dismiss it — the update triggered by a selection would
+ // defeat the keep-presented attribute. Update the visible copy in
+ // place instead and defer the button assignment to dismissal.
+ pendingMenu = menu
+ self.refreshPresentedMenu(menu)
+ return
+ }
+
self.menu = menu
self.showsMenuAsPrimaryAction = !shouldOpenOnLongPress
+ // In long-press mode the button must not intercept touches: as a
+ // full-bounds contentView it sits IN FRONT of the React children of
+ // the hosting component view, which would swallow their taps (e.g. a
+ // row Pressable wrapped by MenuView). The context-menu interaction is
+ // hosted by the superview instead (see updateLongPressInteraction) so
+ // long-press and pointer secondary-click still present the menu, with
+ // the wrapped content as the zoom preview.
+ self.isUserInteractionEnabled = !shouldOpenOnLongPress
+ self.updateLongPressInteraction()
+ }
+
+ private func refreshPresentedMenu(_ menu: UIMenu) {
+ // Tap mode presents through the button's built-in interaction,
+ // long-press mode through the superview-hosted one; cover both (plus
+ // the interaction added in init) and dedupe by identity.
+ var candidates = self.interactions.compactMap { $0 as? UIContextMenuInteraction }
+ if let builtin = self.contextMenuInteraction {
+ candidates.append(builtin)
+ }
+ if let host = longPressInteraction {
+ candidates.append(host)
+ }
+ var visited: Set<ObjectIdentifier> = []
+ for interaction in candidates where visited.insert(ObjectIdentifier(interaction)).inserted {
+ // The block receives whichever menu level is currently on screen —
+ // the navigated submenu when the user picked inside one, not the
+ // root. Swap in the matching node from the rebuilt tree (stable
+ // identifiers from the JS action ids) so that level updates in
+ // place; returning an unrelated menu instead makes UIKit render it
+ // as navigation into a foreign menu, with a stale or blank
+ // expanded-submenu header row above the actions. The root carries
+ // an auto-generated identifier that never matches, so it falls
+ // through to a children-only replacement.
+ interaction.updateVisibleMenu { [weak self] visibleMenu in
+ guard let self = self else { return visibleMenu }
+ if let replacement = self.menuMatching(visibleMenu.identifier, in: menu) {
+ return replacement
+ }
+ return visibleMenu.replacingChildren(menu.children)
+ }
+ }
+ }
+
+ private func menuMatching(_ identifier: UIMenu.Identifier, in menu: UIMenu) -> UIMenu? {
+ if menu.identifier == identifier {
+ return menu
+ }
+ for element in menu.children {
+ if let submenu = element as? UIMenu, let match = menuMatching(identifier, in: submenu) {
+ return match
+ }
+ }
+ return nil
+ }
+
+ private var longPressInteraction: UIContextMenuInteraction?
+
+ public override func didMoveToSuperview() {
+ super.didMoveToSuperview()
+ self.updateLongPressInteraction()
+ }
+
+ private func updateLongPressInteraction() {
+ if shouldOpenOnLongPress, let host = self.superview {
+ if let existing = longPressInteraction, existing.view === host {
+ return
+ }
+ if let existing = longPressInteraction {
+ existing.view?.removeInteraction(existing)
+ }
+ let interaction = UIContextMenuInteraction(delegate: self)
+ host.addInteraction(interaction)
+ longPressInteraction = interaction
+ return
+ }
+ if let existing = longPressInteraction {
+ existing.view?.removeInteraction(existing)
+ longPressInteraction = nil
+ }
}
public override func reactSetFrame(_ frame: CGRect) {
diff --git a/ios/Shared/RCTMenuItem.swift b/ios/Shared/RCTMenuItem.swift
index bb6bb2b7ad56135089f267587c974b166760539d..949b3f7ec49af7ba26d966a8923a51f619443d5a 100644
--- a/ios/Shared/RCTMenuItem.swift
+++ b/ios/Shared/RCTMenuItem.swift
@@ -103,10 +103,18 @@ class RCTMenuAction {
subMenuActions.append(subaction.createUIMenuElement(handler))
}
var menu: UIMenu;
+ // Stable identifiers let updateVisibleMenu match submenu nodes in
+ // place, so a refresh while the menu is presented keeps the user's
+ // current submenu level instead of popping back to the root.
+ // Inline sections don't navigate, so they stay stock.
+ let menuIdentifier = identifier.map { UIMenu.Identifier(rawValue: $0.rawValue) }
if self.displayInline {
menu = UIMenu(title: title, image: image, options: .displayInline, children: subMenuActions)
} else {
- menu = UIMenu(title: title, image: image, children: subMenuActions)
+ menu = UIMenu(title: title, image: image, identifier: menuIdentifier, children: subMenuActions)
+ }
+ if #available(iOS 15.0, *) {
+ menu.subtitle = subtitle
}
if #available(iOS 16.0, *) {
diff --git a/src/NativeModuleSpecs/UIMenuNativeComponent.ts b/src/NativeModuleSpecs/UIMenuNativeComponent.ts
index e6509355275596c451f9d082223cc16bfe504e1a..c783cdaf73f5635cf9835824ca85fba3b46453b4 100644
--- a/src/NativeModuleSpecs/UIMenuNativeComponent.ts
+++ b/src/NativeModuleSpecs/UIMenuNativeComponent.ts
@@ -13,6 +13,22 @@ import codegenNativeComponent from "react-native/Libraries/Utilities/codegenNati
types here, to avoid issues while `pod install` takes place.
*/
+type SubSubAction = {
+ id?: string;
+ title: string;
+ titleColor?: Int32;
+ subtitle?: string;
+ state?: string;
+ image?: string;
+ imageColor?: Int32;
+ displayInline?: boolean;
+ attributes?: {
+ destructive?: boolean;
+ disabled?: boolean;
+ hidden?: boolean;
+ keepsMenuPresented?: boolean;
+ };
+};
type SubAction = {
id?: string;
title: string;
@@ -26,7 +42,11 @@ type SubAction = {
destructive?: boolean;
disabled?: boolean;
hidden?: boolean;
+ keepsMenuPresented?: boolean;
};
+ // One extra nesting level (menu → submenu → nested submenu leaves); the
+ // codegen structs can't recurse, so depth is capped explicitly.
+ subactions?: Array<SubSubAction>;
};
type MenuAction = {
id?: string;
@@ -41,6 +61,7 @@ type MenuAction = {
destructive?: boolean;
disabled?: boolean;
hidden?: boolean;
+ keepsMenuPresented?: boolean;
};
subactions?: Array<SubAction>;
};