269 lines · 12.4 KB · Text
RawDownload
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>; };