swiftuicontextmenuappkit

NSHostingController menu not activated


I'm attempting to write a macOS version of https://stackoverflow.com/a/74935849/2178159.

From my understanding, I should be able to set the menu property of an NSResponder and it will automatically show on right click.

I've tried a couple things:

A: set menu on an NSHostingController's view - when I do this and right or ctrl click, nothing happens. B: set menu on NSHostingController directly - when I do this I get a crash Abstract method -[NSResponder setMenu:] called from class _TtGC7SwiftUI19NSHostingControllerGVS_21_ViewModifier_...__. Subclasses must override C: manually call NSMenu.popup in a custom subclasses of NSHostingController or NSView's rightMouseDown method - nothing happens.

extension View {
    func contextMenu(menu: NSMenu) -> some View {
        modifier(ContextMenuViewModifier(menu: menu))
    }
}

struct ContextMenuViewModifier: ViewModifier {
    let menu: NSMenu

    func body(content: Content) -> some View {
        Interaction_UI(
            view: { content },
            menu: menu
        )
            .fixedSize()
    }
}

private struct Interaction_UI<Content: View>: NSViewRepresentable {
    typealias NSViewType = NSView

    @ViewBuilder var view: Content
    let menu: NSMenu

    func makeNSView(context: Context) -> NSView {
        let v = NSHostingController(rootView: view)

        // option A - no effect
        v.view.menu = menu

        // option B - crash
        v.menu = menu

        return v.view
    }

    func updateNSView(_ nsView: NSViewType, context: Context) {
        // part of option A
        nsView.menu = menu
    }
}

Solution

  • The view class for NSHostingController might have overridden menu(for:) or the menu property, or it might have set menu to something else (to implement SwiftUI .contextMenu { ... } for example) after you have set it. After all, unlike in UIKit where context menus are added to the interactions array, there is only one single menu property to set on NSView.

    I suggest subclassing NSHostingView and overriding menu(for:) to return your own NSMenu property.

    class MyHost<Content: View>: NSHostingView<Content> {
        var myMenu: NSMenu?
        
        override func menu(for event: NSEvent) -> NSMenu? {
            myMenu
        }
    }
    

    Then the NSViewRepresentable can be written like this:

    struct ViewWithMenu<Content: View>: NSViewRepresentable {
    
        @ViewBuilder var view: Content
        let menu: NSMenu
    
        func makeNSView(context: Context) -> NSView {
            let v = MyHost(rootView: view)
            v.myMenu = menu
            return v
        }
    
        func updateNSView(_ nsView: NSView, context: Context) {
            nsView.menu = menu
        }
    }
    

    Here is a minimum example that produces a menu on right click:

    struct ContextMenuViewModifier: ViewModifier {
        let menu: NSMenu
    
        func body(content: Content) -> some View {
            ViewWithMenu(
                view: { content },
                menu: menu
            )
            .fixedSize()
        }
    }
    
    extension View {
        func contextMenu(menu: NSMenu) -> some View {
            modifier(ContextMenuViewModifier(menu: menu))
        }
    }
    
    struct ContentView: View {
        var body: some View {
            let m = {
                let temp = NSMenu()
                let item = NSMenuItem(title: "Foo", action: nil, keyEquivalent: "")
                temp.items = [item]
                return temp
            }()
            Text("Foo")
                .contextMenu(menu: m)
        }
    }
    

    In reality, you would probably hold the NSMenu in a @Observable/ObservableObject that can respond to the menu items' actions. That class can also be the menu's delegate.