Home > OS >  Weird behavior when using SwiftUI View as accessory view for NSSavePanel
Weird behavior when using SwiftUI View as accessory view for NSSavePanel

Time:07-18

I'm trying to use a view written in SwiftUI as an accessory view of my NSSavePanel but I struggled to get it working properly.

Here's the implementation for my SwiftUI view:

struct ExportAccessoryView: View {
    enum ExportFileType: String, Identifiable {
        // ... enum declaration
    }
    
    @State var selectedExportFileType: ExportFileType = .png
    @State var resolution = 256.0
    
    @Binding var selectedFileTypeBinding: ExportFileType
    @Binding var resolutionBinding: Double
    
    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Picker(selection: $selectedExportFileType, label: Text("Format:")) {
                Text("PDF").tag(ExportFileType.pdf)
                // ... other items
            }
            .frame(width: 170)
            .padding(.leading, 21)
            if [ExportFileType.png, ExportFileType.jpeg, ExportFileType.tiff].contains(selectedExportFileType) {
                HStack {
                    Slider(value: $resolution, in: 128...1024,
                           label: { Text("Resolution:") })
                        .frame(width: 200)
                    Text("\(Int(resolution))")
                        .frame(width: 40, alignment: .leading)
                        .padding(.leading, 5)
                }
            }
        }
        .padding(10)
        .onChange(of: selectedExportFileType) { newValue in
            self.selectedFileTypeBinding = newValue
        }
        .onChange(of: resolution) { newValue in
            self.resolutionBinding = newValue
        }
    }
}

Here's how I implemented my save panel:

class DocumentWindow: NSWindowController {
    
    var exportFileType: ExportAccessoryView.ExportFileType = .pdf
    var resolution = 256.0
    
    lazy var exportPanel: NSSavePanel = {
        let savePanel = NSSavePanel()
        savePanel.message = "Specify where and how you wish to export..."
        savePanel.nameFieldLabel = "Export As:"
        savePanel.canCreateDirectories = true
        savePanel.isExtensionHidden = false
        savePanel.showsTagField = true
        
        let fileTypeBinding = Binding {
            return self.exportFileType
        } set: { newValue in
            self.exportFileType = newValue
            // update file extension
            self.exportPanel.allowedContentTypes = [UTType(newValue.rawValue)!]
        }
        let resolutionBinding = Binding {
            return self.resolution
        } set: { newValue in
            self.resolution = newValue
        }

        let accessoryView = ExportAccessoryView(selectedFileTypeBinding: fileTypeBinding,
                                                resolutionBinding: resolutionBinding)
        let exportAccessoryView = NSHostingController(rootView: accessoryView)
        
        savePanel.accessoryView = exportAccessoryView.view
        
        savePanel.allowedContentTypes = [UTType(self.exportFileType.rawValue)!]
        
        return savePanel
    }()
}

The save panel is presented by invoking beginSheetModal(for:completionHandler:).

It has no problem displaying but the accessory view is exhibiting some bizarre behavior: it seems to be doing its own thing at random (I sought for patterns but I failed to do so).

Sometimes it works properly, sometimes it becomes unclickable (but the function is still accessible via switch control using TAB). The alignment is always different from the last time I expanded/collapsed or opened/closed the panel: sometimes it's left aligned, sometimes it's centered (even if I have explicitly opted for .leading for alignment).

I have absolutely no idea what's going on. I don't know if this is an issue with SwiftUI AppKit or is it that I'm doing it all wrong, which is highly likely since I'm a total newbie in SwiftUI. What should I do to get it working properly?

CodePudding user response:

I remembered from back in the days when I was using XIB for implementing an accessory view: I used to embed the controls within an NSView and then set up constraints to make it work. So I applied the same idea here of embedding the NSHostingView's view within a custom NSView and after tweaking it for a bit, I made it work:

lazy var exportPanel: NSSavePanel = {
    // ... setting up save panel

    // instantiate SwiftUI view and its hosting controller
    let accessoryView = ExportAccessoryView(selectedFileTypeBinding: fileTypeBinding,
                                            resolutionBinding: resolutionBinding)
    let exportAccessoryView = NSHostingController(rootView: accessoryView)

    // embed the SwiftUI in a custom view
    let customView = NSView(frame: NSRect(x: 0, y: 0, width: 300, height: 60))
    customView.addSubview(exportAccessoryView.view)

    // use my own constraints
    exportAccessoryView.view.translatesAutoresizingMaskIntoConstraints = false

    // top and bottom clipped to custom view
    exportAccessoryView.view.topAnchor.constraint(equalTo: customView.topAnchor).isActive = true
    exportAccessoryView.view.bottomAnchor.constraint(equalTo: customView.bottomAnchor).isActive = true

    // leading and trailing spaces can stretch as far as they need to be, hence ≥0
    exportAccessoryView.view.leadingAnchor.constraint(greaterThanOrEqualTo: customView.leadingAnchor).isActive = true
    exportAccessoryView.view.trailingAnchor.constraint(greaterThanOrEqualTo: customView.trailingAnchor).isActive = true

    // center the SwiftUI view horizontal within custom view
    exportAccessoryView.view.centerXAnchor.constraint(equalTo: customView.centerXAnchor).isActive = true

    // usually fixed width and height
    // can be flexible when SwiftUI view is dynamic
    exportAccessoryView.view.widthAnchor.constraint(equalToConstant: customView.frame.width).isActive = true
    exportAccessoryView.view.heightAnchor.constraint(greaterThanOrEqualToConstant: customView.frame.height).isActive = true

    savePanel.accessoryView = customView

    // ... additional setup

    return savePanel
}()

Now it works perfectly as expected. Don't know if this is the "proper way" to implement such integration.

  • Related