Home > Mobile >  Displaying NSMenuItem in SwiftUI on it's own
Displaying NSMenuItem in SwiftUI on it's own

Time:12-09

I have been trying to display a custom NSMenuItem (for a preview page of a menu manager) inside a SwiftUI view. But I can't achieve it. I have figured it needs to wrapped inside a menu first, and thought that there might be a way to pop the menu pragmatically but sadly, those efforts have failed and the app crashes.

So far, my code looks like this:

import Foundation
import SwiftUI

struct NSMenuItemView: NSViewRepresentable {
    
    func makeCoordinator() -> Coordinator {
        return Coordinator(self)
    }
    
    func makeNSView(context: Context) -> NSView {
            let view = NSView()
            let menu = NSMenu()
            let item = menu.addItem(withTitle: "Do Action", action: #selector(Coordinator.valueChanged(_:)), keyEquivalent: "")
            item.target = context.coordinator
            view.menu = menu
            return view
    }

    func updateNSView(_ view: NSView, context: Context) {
        // App crashes here :/ 
        view.menu?.popUpMenuPositioningItem(
            positioning: view.menu?.item(at: 0),
            at: NSPoint(x: 0, y: 0),
            in: view
        )
    }
}

extension NSMenuItemView {
    final class Coordinator: NSObject {
        var parent: NSMenuItemView
    
        init(_ parent: NSMenuItemView) {
            self.parent = parent
        }

        @objc
        func valueChanged(_ sender: NSMenuItem) {
        }
    }
}

Am I missing something here? Is it even possible to just pragmatically display NSMenuItem?

The NSMenu comfors to NSViewRepresentable so I figured it might just workout, and have seen answers on StackOverflow (granted date a while back) showing similar code that should work.

Without the popUpMenuPositioningItem it works - in a way I guess - when I right click in the View, the MenuItem Appears. But I would like to be able to display the menu without the right click, just like that.

CodePudding user response:

The problem is that the menu is shown while the view are still rendering so that the crash happens. To avoid this you should call popUp(positioning:at:in) after the your view appears on the screen. The way to achieve it, we have to use publisher to trigger an event to show menu inside onAppear modifier and listen it inside Coordinator. Here is the sample for that solution.

struct ContentView: View {
    let menuPopUpTrigger = PassthroughSubject<Void, Never>()
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundColor(.accentColor)
            NSMenuItemView(menuPopUpTrigger)
            Text("Hello, world!")
        }
        .padding()
        .onAppear {
            /// trigger an event when `onAppear` is executed
            menuPopUpTrigger.send()
        }
    }
}

struct NSMenuItemView: NSViewRepresentable {
    let base = NSView()
    let menu = NSMenu()
    
    var menuPopUpTrigger: PassthroughSubject<Void, Never>
    
    init(_ menuPopUpTrigger: PassthroughSubject<Void, Never>) {
        self.menuPopUpTrigger = menuPopUpTrigger
    }
    
    func makeCoordinator() -> Coordinator {
        return Coordinator(self)
    }
    
    func makeNSView(context: Context) -> NSView {
        let item = menu.addItem(withTitle: "Do Action", action: #selector(Coordinator.valueChanged(_:)), keyEquivalent: "")
        item.target = context.coordinator
        base.menu = menu
        context.coordinator.bindTrigger(menuPopUpTrigger)
        return base
    }

    func updateNSView(_ view: NSView, context: Context) { }
}

extension NSMenuItemView {
    final class Coordinator: NSObject {
        var parent: NSMenuItemView
        var cancellable: AnyCancellable?
    
        init(_ parent: NSMenuItemView) {
            self.parent = parent
        }

        @objc func valueChanged(_ sender: NSMenuItem) { }
        
        /// bind trigger to listen an event
        func bindTrigger(_ trigger: PassthroughSubject<Void, Never>) {
            cancellable = trigger
                .delay(for: .seconds(0.1), scheduler: RunLoop.main)
                .sink { [weak self] in
                    self?.parent.menu.popUp(
                        positioning: self?.parent.menu.item(at: 0),
                        at: NSPoint(x: 0, y: 0),
                        in: self?.parent.base
                    )
                }
        }
    }
}

I hope it will help you to get what you want.

  • Related