Search code examples
macosswiftuiappdelegatenspopover

How to show NSPopover in SwiftUI lifecycle?


I'm trying to show a detachable NSPopover by clicking on a button but I'm stuck. I followed tuts how to show NSPopover but they all around Menubar apps.

My AppDelegate looks like this

final class AppDelegate: NSObject, NSApplicationDelegate {
    var popover: NSPopover!
    
    func applicationDidFinishLaunching(_ notification: Notification) {
        let popover = NSPopover()
        let popoverView = PopoverView()
        
        popover.contentSize = NSSize(width: 300, height: 200)
        popover.contentViewController = NSHostingController(rootView: popoverView)
        popover.behavior = .transient
        
        self.popover = popover
    }
    
     func togglePopover(_ sender: AnyObject?) {
        self.popover.show(relativeTo: (sender?.bounds)!, of: sender as! NSView, preferredEdge: NSRectEdge.minY)
    }
}

Solution

  • Here is a simple demo of possible approach - wrap control over native NSPopover into background view representable.

    Note: next wrapping of background into view modifier or/and making it more configurable is up to you.

    Prepared & tested with Xcode 13 / macOS 11.5.1

    demo

    struct ContentView: View {
        @State private var isVisible = false
        var body: some View {
            Button("Test") {
                isVisible.toggle()
            }
            .background(NSPopoverHolderView(isVisible: $isVisible) {
                Text("I'm in NSPopover")
                    .padding()
            })
        }
    }
    
    struct NSPopoverHolderView<T: View>: NSViewRepresentable {
        @Binding var isVisible: Bool
        var content: () -> T
    
        func makeNSView(context: Context) -> NSView {
            NSView()
        }
    
        func updateNSView(_ nsView: NSView, context: Context) {
            context.coordinator.setVisible(isVisible, in: nsView)
        }
    
        func makeCoordinator() -> Coordinator {
            Coordinator(state: _isVisible, content: content)
        }
    
        class Coordinator: NSObject, NSPopoverDelegate {
            private let popover: NSPopover
            private let state: Binding<Bool>
    
            init<V: View>(state: Binding<Bool>, content: @escaping () -> V) {
                self.popover = NSPopover()
                self.state = state
    
                super.init()
    
                popover.delegate = self
                popover.contentViewController = NSHostingController(rootView: content())
                popover.behavior = .transient
            }
    
            func setVisible(_ isVisible: Bool, in view: NSView) {
                if isVisible {
                    popover.show(relativeTo: view.bounds, of: view, preferredEdge: .minY)
                } else {
                    popover.close()
                }
            }
    
            func popoverDidClose(_ notification: Notification) {
                self.state.wrappedValue = false
            }
    
            func popoverShouldDetach(_ popover: NSPopover) -> Bool {
                true
            }
        }
    }