How to show NSPopover in SwiftUI lifecycle?
Asked Answered
Q

3

8

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)
    }
}
Quarantine answered 6/8, 2021 at 23:12 Comment(2)
Does this answer your question https://mcmap.net/q/1262357/-swift-ui-macos-popover-on-the-button-or-on-an-image?Brittaneybrittani
@Brittaneybrittani Unfortunately not. SwiftUI popover (not NSPopover) can't be detachable and there is still no way to override a close request yet. So I assume NSPopover is only way to goQuarantine
B
12

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
        }
    }
}
Brittaneybrittani answered 7/8, 2021 at 12:46 Comment(0)
H
4

Updated Asperi's answer with adding support for content changes

struct PopoverView<T: View>: NSViewRepresentable {
    @Binding private var isVisible: Bool
    private let content: () -> T

    init(isVisible: Binding<Bool>, @ViewBuilder content: @escaping () -> T) {
        self._isVisible = isVisible
        self.content = content
    }

    func makeNSView(context: Context) -> NSView {
        .init()
    }

    func updateNSView(_ nsView: NSView, context: Context) {
        let coordinator = context.coordinator
        
        (coordinator.popover.contentViewController as? NSHostingController<T>)?.rootView = content()
        coordinator.visibilityDidChange(isVisible, in: nsView)
    }

    func makeCoordinator() -> Coordinator {
        let coordinator = Coordinator(popover: .init(), isVisible: $isVisible)
        coordinator.popover.contentViewController = NSHostingController(rootView: content())
        return coordinator
    }

    @MainActor
    final class Coordinator: NSObject, NSPopoverDelegate {
        fileprivate let popover: NSPopover = .init()
        private let isVisible: Binding<Bool>

        fileprivate init(popover: NSPopover, isVisible: Binding<Bool>) {
            self.isVisible = isVisible
            super.init()

            popover.delegate = self
            popover.behavior = .transient
        }

        fileprivate func visibilityDidChange(_ isVisible: Bool, in view: NSView) {
            if isVisible {
                if !popover.isShown {
                    popover.show(relativeTo: view.bounds, of: view, preferredEdge: .maxX)
                }
            } else {
                if popover.isShown {
                    popover.close()
                }
            }
        }

        func popoverDidClose(_ notification: Notification) {
            isVisible.wrappedValue = false
        }
    }
}
Hales answered 18/3, 2023 at 11:53 Comment(0)
H
0

Now macOS 15.0 has popover View modifier. https://developer.apple.com/documentation/swiftui/view/popover(ispresented:attachmentanchor:content:)?changes=latest_minor

struct PopoverExample: View {
    @State private var isShowingPopover = false


    var body: some View {
        Button("Show Popover") {
            self.isShowingPopover = true
        }
        .popover(
            isPresented: $isShowingPopover
        ) {
            Text("Popover Content")
                .padding()
        }
    }
}
Hales answered 8/7 at 3:7 Comment(2)
Yes, but it still not detachableQuarantine
Right, you cannot configure behavior or detached options.Hales

© 2022 - 2024 — McMap. All rights reserved.