Save And Open Panels in macOS Apps

Among the most common tasks when developing macOS applications is to allow users to save data in files, and loading back from them. In order to preserve the familiar experience that users already have when using their Macs, developers usually employ system provided user interfaces that allow to choose where to save or load to and from in the disk. Those standard interfaces are known as save and open panels. In this post I will show you how to configure and present both of them in a storyboard based macOS app.

There is one thing to keep in mind before keep reading; both save and open panels are not meant to store or load data to and from files respectively. Their purpose is solely to return one or more URL objects when users choose directories and file names through a graphical interface.

That said, suppose that we have the following really simple text edit application:

Sample text edit application

It has three buttons; one for clearing the textview and start writing from scratch, and two more for saving the current text, and loading one by opening an existing file. At the time being none of these works, and that's what I'll demonstrate how to fix here.

The Save panel

Focusing on the save button initially, the goal here is to present the system's save panel in order to let users set a name and choose a target directory for the file that will store textview's text.

The first step towards that is to initialize a NSSavePanel object in the method that implements the save functionality:

@IBAction func saveText(_ sender: Any) {
    let savePanel = NSSavePanel()
}

There are several properties that we can configure in the savePanel object. The first and most important one is to specify the allowed file types, meaning the kind of files that users can create.

What you will actually set here depends totally on the application you are making and the kind of files it deals with. In this example I have a simple text editor, so I want to save content in plain text files with the txt extension. Here is how I indicate that:

savePanel.allowedFileTypes = ["txt"]

See that the provided value to the allowedFileTypes property is an array of string values. Each value must match to the file extension of the kind of files we want to support.

Sometimes declaring just the allowed file type is enough, without being necessary to specify explicit values to other properties. However, there are a few more properties that you will definitely find interesting. The next one indicates whether users can create new folders or not through the save panel:

savePanel.canCreateDirectories = true

By setting true to the above property, an additional button will appear in the save panel titled New Folder.

It's also possible to decide whether the file extension will be visible or not next to the file name. You might want it present if you have more than one allowed file types, as extension is not visible by default:

savePanel.isExtensionHidden = false

Users can type an extension along with the file name other than the expected one. However, such an action is prevented by default, but if you want to change it you can do so with the following property:

savePanel.allowsOtherFileTypes = true

Note once again that when allowsOtherFileTypes is true then users can type any file extension they want right next to the name. Be cautious with that, especially if you're going to give the capability of presenting an open panel for locating already stored files.

Depending on how we will present the save panel (more on that in a moment), it may have a title or not, and if optionally display a message to users. It's also possible to override the default Save title in the save button, and replace it with a custom prompt. Here's how we do all that:

savePanel.title = "Save your text"
savePanel.message = "Choose a folder and a name to store your text."
savePanel.prompt = "Save now"

Besides these, we can also customize the displayed text next to the file name textfield, as well as the default file name. If they are not provided, Save as: and Untitled are the default values respectively:

savePanel.nameFieldLabel = "File name:"
savePanel.nameFieldStringValue = "mytext"

The above are not the only properties that we can configure in a save panel. However, they are the most common ones, and rarely you'll ever need more than these. But if you do, Xcode auto-suggestion is your friend; it will list all properties and methods available to use.

Presenting a save panel

Presenting a save panel can be done in two ways; either as a modal window, or as a modal sheet. When presented as a window, the panel's title is visible to the window's bar. If presented as a sheet, then the title is not displayed.

Starting with the former, presenting a save panel modally as a window requires to call a method named runModal():

let response = savePanel.runModal()

The returned value from that method is a NSApplication.ModalResponse object. In the above snippet, it's stored in the response constant. It contains the button that user clicked on.

To have the save panel shown as a sheet is a bit different:

guard let window = self.view.window else { return }
savePanel.beginSheetModal(for: window) { response in

}

The beginSheetModal(for:completionHandler:) method expects as first argument the window that the panel will be presented as a sheet to. Getting the current window is done using the window property of the view controller's view. However, that returns an optional value, so unwrapping it before using it is the proper way to go. That ensures that if for some reason the window cannot be fetched, the app will not crash; the save panel simply will not be shown. The unwrapped window object is the given value as first argument eventually.

The second argument is a completion handler; a closure that has one property only. The same response object that contains the selected button by the user.

Regardless of how the save panel will be presented, the actual goal is the same; to get the selected URL, which is formed by the file name and the target directory the user selects. But first, we must make sure that the save button is clicked. Remember that the save panel does not perform any actual saving; it's there just to let users form a URL. The actual saving task is still up to us and depends on the kind of data that we need to persist.

I demonstrated earlier the response object that contains the user selected action. To determine if the save button was eventually clicked we can do the following:

guard response == .OK else { return }

Alternatively, you can use the traditional if statement.

Next, getting the target URL is pretty easy and it's done through the save panel instance. Note that it's an optional value, so it must be unwrapped before used:

guard let saveURL = savePanel.url else { return }

The above two guard statements can be combined into one:

guard response == .OK, let saveURL = savePanel.url else { return }

Finally, if the code execution continues normally without falling to the else case, then we can perform the actual writing to file. In the sample case demonstrated here, we'll do that as so:

try? self.textView.string.write(to: saveURL, atomically: true, encoding: .utf8)

Here's a save panel as a modal window:

Save panel window

The panel can be expanded or collapsed. When expanded, the additional button to create new folders is revealed, as well as the capability to navigate among folders.

Save panel window expanded

The save panel is presented as a sheet next:

Save panel as sheet

There are not many differences between the modal window and the sheet appearance. It's mainly the title that's missing.

Right next you can find the entire demo method that implements the save panel:

@IBAction func saveText(_ sender: Any) {
    let savePanel = NSSavePanel()
    savePanel.allowedFileTypes = ["txt"]
    savePanel.canCreateDirectories = true
    savePanel.isExtensionHidden = false
    savePanel.allowsOtherFileTypes = false
    savePanel.title = "Save your text"
    savePanel.message = "Choose a folder and a name to store your text."
    savePanel.prompt = "Save now"
    savePanel.nameFieldLabel = "File name:"
    savePanel.nameFieldStringValue = "mytext"
    
    // Present the save panel as a modal window.
    let response = savePanel.runModal()
    guard response == .OK, let saveURL = savePanel.url else { return }
    try? textView.string.write(to: saveURL, atomically: true, encoding: .utf8)
}

Important

Before being able to use the save panel, it's mandatory to assign write permissions to the app.

To assign permissions, open the Signing & Capabilities tab for your project target, and then under the App Sandbox select the Read/Write option for the User Selected File type.

Sandbox settings

The Open panel

Initializing, configuring, and finally presenting an open panel is similar to save panel. Some properties already shown above exist here too. There are also other properties specific to the open panel.

In order to use an open panel it's necessary to create an instance of it first. At the majority of the cases, you will need to specify the allowed file types that can be selected through the panel using the allowedFileTypes property that we met before:

@IBAction func openText(_ sender: Any) {
    let openPanel = NSOpenPanel()
    openPanel.allowedFileTypes = ["txt"]
}

Among the various NSOpenPanel properties, there are some that we specify more often than others. The first one indicates whether users are allowed to select multiple files or not. If so, then multiple URLs are returned by the panel, otherwise it's just a single URL object. The following command disables multiple file selection:

openPanel.allowsMultipleSelection = false

Besides that, we can also let users select entire directories, or not:

openPanel.canChooseDirectories = false

Similarly, we can specify if selecting files is allowed or not. This property can be combined with the previous one in order to allow selection of a specific kind of items only:

openPanel.canChooseFiles = true

We present an open panel similarly as the save panel. It can be shown either as a modal window, or as a modal sheet. Right below I'm presenting it as a window, and requesting for a single URL. Then, I load the file contents to the text view:

let response = openPanel.runModal()
guard response == .OK, let loadURL = openPanel.url else { return }
try? textView.string = String(contentsOf: loadURL)

If you allow multiple file selection and you want to get back all URLs selected by the user, then instead of the url property shown above, use the urls; it returns an array of URL objects:

guard let selectedURLs = openPanel.urls else { return }

The entire method implementation is this:

@IBAction func openText(_ sender: Any) {
    let openPanel = NSOpenPanel()
    openPanel.allowedFileTypes = ["txt"]
    openPanel.allowsMultipleSelection = false
    openPanel.canChooseDirectories = false
    openPanel.canChooseFiles = true
    let response = openPanel.runModal()
    guard response == .OK, let loadURL = openPanel.url else { return }
    try? textView.string = String(contentsOf: loadURL)
}

Load panel

Summary

Presenting save and open panels is an easy job that involves standard steps in the process. The most common properties one can set were presented in this post, but feel free to explore what other options also exist. Lastly, keep in mind that both panels provide an experience that users are familiar with, so don't hesitate to use them whenever it's appropriate. Thanks for reading!

Resources

Download the sample project