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)
    }
}
2
  • Does this answer your question stackoverflow.com/a/63862691/12299030? Commented Aug 7, 2021 at 6:39
  • @Asperi 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 go Commented Aug 7, 2021 at 10:42

3 Answers 3

13

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
        }
    }
}
Sign up to request clarification or add additional context in comments.

Comments

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
        }
    }
}

Comments

-1

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()
        }
    }
}

4 Comments

Yes, but it still not detachable
Right, you cannot configure behavior or detached options.
popover modifier was up from macos 10.15. And this is not custom popover =(
It is indeed the API that has been around since macOS 10.15. It appears that in July 2024, Apple's documentation mistakenly labeled it as a macOS 15.0+ API, which led to my misunderstanding. When you look at SwiftUI.swiftmodule/arm64e-apple-macos.swiftinterface, you can see that this API was rewritten starting from macOS 15.0, and it seems that Apple made an error because of that.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.