How to Create a NSSavePanel accessoryView in Swift to Pick File Formats

null

NSDocument’s save panel comes prepared with a file format picker that contains the app’s registered document types. This is a very nice convenience, but when you run into trouble with the default picker, as I did, you wonder if writing your own from scratch would be easier.

It kind of is pretty easy, but tying into the ways NSDocument uses save panels, this is also a bit ugly.

Bring your own accessoryView

You can remove the default accessory view by overriding NSDocument.shouldRunSavePanelWithAccessoryView and return false there. That’s it, no more default accessory view for you.

Your NSDocument subclass is now skipping quite a lot of work in the default implementation of prepareSavePanel(_:). That means you have to do all the heavy lifting in an override-extension yourself.

class TableDocument: NSDocument, NSSavePanelFileTypeDelegate {
    // Tell the system that you bring your own accessory view
    override var shouldRunSavePanelWithAccessoryView: Bool {
        return false
    }
    
    /// Store a reference of the save panel for later use in `changeSaveType(_:)`.
    /// Can be weak because the callback is supposed to be used while the panel
    /// is visible.
    fileprivate weak var currentSavePanel: NSSavePanel?

    override func prepareSavePanel(_ savePanel: NSSavePanel) -> Bool {
        self.currentSavePanel = savePanel
        
        guard super.prepareSavePanel(savePanel) else { return false }

        // Add file format selector:
        let popupButton = NSPopUpButton(
            frame: NSRect(x: 0, y: 0, width: 300, height: 22), 
            pullsDown: false)
        popupButton.addItems(withTitles: [
            "Markdown",
            "CSV"
        ])
        popupButton.action = #selector(changeFileFormat(_:))
        popupButton.target = self

        // Set initial selection:
        popupButton.selectItem(at: 0)
        savePanel.allowedFileTypes = self.writableTypes(for: .saveToOperation)
        
        // (You'll also want to add a label like "File Format:", 
        //  and center everything horizontally.)
    
        let accessoryView = NSView()
        accessoryView.addSubview(popupButton)
        savePanel.accessoryView = accessoryView

        return true
    }
    
    @IBAction func changeFileFormat(_ sender: Any?) {
        guard let popupButton = sender as? NSPopUpButton else { return }

        // You need a property to get the `NSSavePanel` instance here;
        // there's no other convenient way, i.e. via the `sender` :(
        guard let currentSavePanel = self.currentSavePanel else { return }

        // (implementation follows right below, please read on)
    }
}

The view layout is pretty simple. I don’t use Auto Layout in this sample code and thus rely on translatedAutoresizingMaskIntoConstraints being true by default so the selector takes up some minimum space in the panel. You can play with this to add a label, position stuff properly, and maybe even transform everything to Auto Layout on your own, I assume.

When you change document types, the save panel will not update the file extension in the file name text field for you though.

To have the file extension change e.g. from .csv to .md when you select another document type, you need to implement this in changeFileType(_:). I observed in the debugger at which point the NSSavePanel.nameFieldStringValue is changed in the default accessoryView callbacks.

— Thanks to Daniel Kennett for pointing out that even though symbolic breakpoints don’t seem to work, maybe because of Sandboxing restrictions where the NSSavePanel runs in another process, KVO on the property still works!

The change to the visible file extension in a save panel’s text field happens when you change NSSavePanel.allowedFileTypes: as a side effect, the panel will update the file extension, unless the user changed it manually. You can read as much in the docs:

If no extension is given by the user, the first item in the allowedFileTypes array will be used as the extension for the save panel. If the user specifies a type not in the array, and allowsOtherFileTypes is YES, they will be presented with another dialog when prompted to save.

Here is an implementation that makes use of this “free” side effect, probably much like the default implementation does:

@IBAction func changeFileType(_ sender: Any?) {
    guard let popupButton = sender as? NSPopUpButton else { return }
    guard let currentSavePanel = self.currentSavePanel else { return }

    switch popupButton.indexOfSelectedItem {
    case 0: // Markdown
        currentSavePanel.allowedFileTypes = ["net.daringfireball.markdown"]
    case 1: // CSV
        currentSavePanel.allowedFileTypes = ["public.csv"] 
    default:
        break
    }
}

This is a very shortened implementation: I leave out how I store the file types in the app. Here, they are just two hard coded cases, tied to the indexes of the popup button’s items. Please apply proper abstractions at home to avoid bugs due to inconsistencies.

And that’s the minimum you need to do to implement your own file format picker for an NSSavePanel.

This wasn’t of any help in my own journey, though: I was struggling with the app changing the file extension from .md to .txt, pulling the rug under its own feet, so to speak, and producing file access errors in its Sandboxed environment. Writing my own accessoryView didn’t help with that.