How to Create a NSSavePanel accessoryView in Swift to Pick File Formats
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, andallowsOtherFileTypes
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.