Rick
Rick

Reputation: 3877

macOS SwiftUI dismiss app-modal NSHostingController presented via segue from menu?

I'm working on a macOS SwiftUI app. It has a "File->Open Location…" menu command that uses a Segue in IB to modally present an NSWindowController that contains an NSHostingController subclass. My subclass looks like this:

class
OpenLocationController: NSHostingController<OpenLocationView>
{
    @objc
    required
    dynamic
    init?(coder: NSCoder)
    {
        super.init(coder: coder, rootView: OpenLocationView())
    }
}

and my view looks like this:

struct
OpenLocationView : View
{
    @State private var location: String = ""

    var body: some View
    {
        VStack
        {
            HStack
            {
                Text("Movie Location:")
                TextField("https://", text: $location)
            }

            HStack
            {
                Spacer()
                Button("Cancel") { /* dismiss window */ }
                Button("Open") { }
            }
        }
        .padding()
        .frame(minWidth: 500.0)
    }
}

I tried adding a property @Environment(\.presentationMode) var presentationMode and calling self.presentationMode.wrappedValue.dismiss() in the button action, but it has no visible effect.

How do I dismiss this window when the user clicks Cancel?

Upvotes: 2

Views: 1110

Answers (2)

Alexey Martemyanov
Alexey Martemyanov

Reputation: 162

Upd: just checked on pre-macOS 14 and the default dismiss action doesn‘t call performClose: there, I‘ve updated my answer to support pre-macOS 14 deployment.

It seems the approach presenting NSHostingController as a sheet is incorrect.

Instead, custom NSWindow subclass should be used with contentView set to NSHostingView and performClose: method overridden to dismiss the sheet using the default dismiss() action:

struct MySwiftUIView: View {
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        VStack {
            Text("This is a SwiftUI View")
            Button("OK") {
                dismiss()
            }.keyboardShortcut(.defaultAction)
            Button("Cancel") {
                dismiss()
            }.keyboardShortcut(.cancelAction)
        }
        .padding()
        .fixedSize()
    }
}

final class MyWindow<Content: View>: NSWindow {

    init(rootView: Content) {
        super.init(contentRect: .zero, styleMask: [.titled, .closable, .docModalWindow], backing: .buffered, defer: false)
        self.contentView = NSHostingView(rootView: rootView
            // remove this if pre-macOS 14 support is not needed
            .legacyOnDismiss { [weak self] in
                self?.performClose(nil)
            }
        )
    }

    override func performClose(_ sender: Any?) {
        guard let sheetParent else {
            super.performClose(sender)
            return
        }
        sheetParent.endSheet(self, returnCode: .alertFirstButtonReturn)
    }

}

func show(completion: (() -> Void)? = nil) {
    let myWindow = MyWindow(rootView: MySwiftUIView())

    NSApp.mainWindow!.beginSheet(myWindow, completionHandler: completion.map { completion in
        { _ in
            completion()
        }
    })
}

pre-macOS 14 support:

extension View {

    @available(macOS, obsoleted: 14.0, message: "This needs to be removed.")
    @ViewBuilder
    func legacyOnDismiss(_ onDismiss: @escaping () -> Void) -> some View {
        if #unavailable(macOS 14.0), let presentationModeKey = \EnvironmentValues.presentationMode as? WritableKeyPath {
            // downcast a (non-writable) \.presentationMode KeyPath to a WritableKeyPath
            self.environment(presentationModeKey, Binding<PresentationMode>(isPresented: true, onDismiss: onDismiss))
        } else {
            self
        }
    }
}

@available(macOS, obsoleted: 14.0, message: "This needs to be removed.")
private extension Binding where Value == PresentationMode {

    init(isPresented: Bool, onDismiss: @escaping () -> Void) {
        // PresentationMode is a struct with a single isPresented property and a (statically dispatched) mutating function
        // This technically makes it equal to a Bool variable (MemoryLayout<PresentationMode>.size == MemoryLayout<Bool>.size == 1)
        var isPresented = isPresented
        self.init {
            // just return the Bool as a PresentationMode
            unsafeBitCast(isPresented, to: PresentationMode.self)
        } set: { newValue in
            // set it back
            isPresented = newValue.isPresented
            // and call the dismiss callback
            if !isPresented {
                onDismiss()
            }
        }
    }

}

pre-macOS 12 support:

@available(macOS, obsoleted: 12.0, message: "This needs to be removed.")
struct DismissAction {
    let dismiss: () -> Void
    public func callAsFunction() {
        dismiss()
    }
}

extension EnvironmentValues {
    @available(macOS, obsoleted: 12.0, message: "This needs to be removed.")
    var dismiss: DismissAction {
        DismissAction {
            presentationMode.wrappedValue.dismiss()
        }
    }
}

Upvotes: 0

Asperi
Asperi

Reputation: 258345

Here is possible approach. Tested with Xcode 11.2 / macOS 15.0.

class
OpenLocationController: NSHostingController<OpenLocationView>
{
    @objc
    required
    dynamic
    init?(coder: NSCoder)
    {
        weak var parent: NSViewController? = nil // avoid reference cycling
        super.init(coder: coder, rootView:
            OpenLocationView(parent: Binding(
                get: { parent },
                set: { parent = $0 })
            )
        )

        parent = self // self usage not allowed till super.init
    }
}

struct
OpenLocationView : View
{
    @Binding var parent: NSViewController?
    @State private var location: String = ""

    var body: some View
    {
        VStack
        {
            HStack
            {
                Text("Movie Location:")
                TextField("https://", text: $location)
            }

            HStack
            {
                Spacer()
                Button("Cancel") {
                    self.parent?.dismiss(nil) // if shown via NSViewController.present
                    // self.parent?.view.window?.performClose(nil) // << alternate
                }
                Button("Open") { }
            }
        }
        .padding()
        .frame(minWidth: 500.0)
    }
}

Upvotes: 2

Related Questions