
macOS File System
The file system in macOS underlies every app — and the FileManager
class has a lot to do with that. Your app is stored in the Applications folder, users store documents in their Documents folder, and preferences and support files are stored in the users Library folder.
With files and data spread all over the filesystem, how can your app find out about files and folders, work with file and folder paths, and even read and write data to a file?
The FileManager
class — that’s how!
In this tutorial, you’ll learn how to manage directory paths, work with URLs, use common file and folder dialogs, display file and folder information, and much more!
Getting Started
For this tutorial, you’ll start with a playground and move on to an app later once you’ve learned the basics.
macOS uses a hierarchical file system: files & folders inside folders, inside folders. This means that finding a particular file can be complicated. Each file has its own address; the structure that defines this address is named URL
.
Open Xcode and click Get started with a playground in the Welcome to Xcode window, or choose File/New/Playground… Set the name of the playground to Files, make sure the Platform is set to macOS and click Next.
Select your Desktop and click Create to save the playground.
Once the starting playground is open, delete all the lines except for import Cocoa
.
Add the following line to your playground, but don’t worry about changing the username for now:
let completePath = "/Users/sarah/Desktop/Files.playground" |
completePath
now contains the address, or path, of this playground file. Since macOS is Unix-based, this is how Unix (and all its variants) describe file paths. The first slash indicates the root directory, which in this case is your startup disk. After that, every slash delimits a new folder or file. So Files.playground is in the Desktop folder in the sarah folder in the Users folder on the startup drive.
While this string describes the full path to this file, it isn’t the best way to handle addresses. Instead, you are going to convert the address into a URL
by adding:
let completeUrl = URL(fileURLWithPath: completePath) |
In the results panel of the playground, you now see: file:///Users/sarah/Desktop/Files.playground
“Wait a minute!” you cry. “I thought a URL
was a web address like https://www.raywenderlich.com
, not a directory path!”
Well, yes…and yes!
URL
stands for Uniform Resource Locator — which also can point to local files and folders. Instead of https://
, local file URLs start with file://
. In the results panel, it looks like there are 3 slashes, but that is because the path itself starts with a slash.
FileManager Class
You’ve used a String
to specify a file path and converted it to a URL
. But while this is a valid URL
, it won’t work — unless your user name also happens to be sarah. Therefore, the next step is to create a URL
that works on anyone’s computer.
To do this, you’ll use the FileManager
class, which provides methods to handle most file-related actions in macOS.
The first task is to identify your Home folder and replace sarah with your own user name.
Add the following line to your playground:
let home = FileManager.default.homeDirectoryForCurrentUser |
default
returns the FileManager
class singleton instance, and homeDirectoryForCurrentUser
contains the URL
for the home folder of the current user.
Now that you have a URL
pointing to your home folder, you can derive the path to the playground by adding the following code:
let playgroundPath = "Desktop/Files.playground" let playgroundUrl = home.appendingPathComponent(playgroundPath) |
The results panel should show you the URL
for your own home folder.
Add these lines to the playground to query various URL
properties:
playgroundUrl.path playgroundUrl.absoluteString playgroundUrl.absoluteURL playgroundUrl.baseURL playgroundUrl.pathComponents playgroundUrl.lastPathComponent playgroundUrl.pathExtension playgroundUrl.isFileURL playgroundUrl.hasDirectoryPath |
The pathComponents
property is interesting, as it separates out all folder and file names into an array. The lastPathComponent
and pathExtension
properties are both quite useful in practice.
Here’s what you should have in your playground:
Note: Note that the playground file has the property hasDirectoryPath
set to URL
as representing a directory. But why is the playground file marked as a directory?
That’s because .playground files are folder bundles, just like .app files. Right-click on the playground file and select Show Package Contents to see what’s inside.
The URL
class makes it easy to edit URLs
.
Add the following code to your playground:
var urlForEditing = home urlForEditing.path urlForEditing.appendPathComponent("Desktop") urlForEditing.path urlForEditing.appendPathComponent("Test file") urlForEditing.path urlForEditing.appendPathExtension("txt") urlForEditing.path urlForEditing.deletePathExtension() urlForEditing.path urlForEditing.deleteLastPathComponent() urlForEditing.path |
Note how you show the path
property each time so it’s easy to see what’s changed.
While those commands edited the URL
in place, you can also create a new URL
from an existing one.
To see how to do this, insert the following commands into your playground:
let fileUrl = home .appendingPathComponent("Desktop") .appendingPathComponent("Test file") .appendingPathExtension("txt") fileUrl.path let desktopUrl = fileUrl.deletingLastPathComponent() desktopUrl.path |
These methods return new URLs
, so chaining them into a sequence works well.
The three appending
methods could have been shortened to just one, but I’ve broken them out here to make the individual steps clear to you.
Here’s what the playground should look like:
Checking for Files and Folders
NSString
has a lot of file path manipulation methods, but Swift’s String
struct doesn’t. Instead, you should use URLs
when working with file paths. Working with paths in this manner will become even more important as Apple transitions to the new Apple File System (APFS).
However, there is one case where you still have to use a string representation of a file URL
: checking to see if a file or folder exists. The best way to get a string version of a URL
is through the path
property.
Add the following code to your playground:
let fileManager = FileManager.default fileManager.fileExists(atPath: playgroundUrl.path) let missingFile = URL(fileURLWithPath: "this_file_does_not_exist.missing") fileManager.fileExists(atPath: missingFile.path) |
Checking whether a folder exists is slightly more obscure, as you have to check if the URL
points to a valid resource that is also a folder.
This requires what I consider a very un-Swifty mechanism of using an inout Objective-C version of a Bool. Add the following:
var isDirectory: ObjCBool = false fileManager.fileExists(atPath: playgroundUrl.path, isDirectory: &isDirectory) isDirectory.boolValue |
Your playground should look something like this:
A fully-annotated version of the playground can be downloaded here.
Now that you understand how to use URL
to identify files and folders, close the playground. It’s time to build an app!
File Spy
In this part of the tutorial, you’re going to build the File Spy app, which lets you select a folder and view a listing of every file or folder inside. Selecting any item will give you more details about it.
Download the starter app project, open it in Xcode and click the Play button in the toolbar, or press Command-R to build and run. The UI is already set up, but you’ll need to add the file management bits.
Your first task is to let the user select a folder and then list its contents. You’ll add some code behind the Select Folder button and use the NSOpenPanel
class to select a folder.
In ViewController.swift, find selectFolderClicked
in the Actions section and insert the following:
// 1 guard let window = view.window else { return } // 2 let panel = NSOpenPanel() panel.canChooseFiles = false panel.canChooseDirectories = true panel.allowsMultipleSelection = false // 3 panel.beginSheetModal(for: window) { (result) in if result == NSFileHandlingPanelOKButton { // 4 self.selectedFolder = panel.urls[0] print(self.selectedFolder) } } |
Here’s what’s going on in the code above:
- Check that you can get a reference to the window, since that’s where the
NSOpenPanel
will be displayed. - Create a new
NSOpenPanel
and set some properties to only permit a single selection which must be a folder. - Display the
NSOpenPanel
modally in the window and use a closure to wait for the result. - If the result shows that the user clicked the OK button (the displayed button will have a different label depending on your locale), get the selected
URL
and set a specificViewController
property. For a quick temporary test, you print the selectedURL
to the console. Ignore the warning on this line for now.
Build and run, click the Select Folder button and choose a folder. Confirm that the URL
for the selected folder prints in the console.
Click the button again to open the dialog,but this time click Cancel. This will not print a selected URL
.
Quit the app and delete the temporary print
statement.
Folder Contents
Now that you can select a folder, your next job is to find the contents of that folder and display it.
The previous section of code populated a property named selectedFolder
. Scroll to the top of the ViewController
definition and check out the selectedFolder
property. It’s using a didSet
property observer to run code whenever its value is set.
The key line here is the one that calls contentsOf(folder:)
. Scroll down to the stub of this method, which is currently returning an empty array. Replace the entire function with the following:
func contentsOf(folder: URL) -> [URL] { // 1 let fileManager = FileManager.default // 2 do { // 3 let contents = try fileManager.contentsOfDirectory(atPath: folder.path) // 4 let urls = contents.map { return folder.appendingPathComponent($0) } return urls } catch { // 5 return [] } } |
Stepping through what the code does:
- Get the
FileManager
class singleton, just as before. - Since the
FileManager
method can throw errors, you use ado...catch
block. - Try to find the contents of the folder
contentsOfDirectory(atPath:)
and return an array of file and folder names inside. - Process the returned array using
map
to convert each name into a completeURL
with its parent folder. Then return the array. - Return an empty array if
contentsOfDirectory(atPath:)
throws an error.
The selectedFolder
property sets the filesList
property to the contents of the selected folder, but since you use a table view to show the contents, you need to define how to display each item.
Scroll down to the NSTableViewDataSource
extension. Note that numberOfRows
already returns the number of URLs
in the filesList
array. Now scroll to NSTableViewDelegate
and note that tableView(_:viewFor:row:)
returns nil
. You need to change that before anything will appear in the table.
Replace the method with:
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { // 1 let item = filesList[row] // 2 let fileIcon = NSWorkspace.shared().icon(forFile: item.path) // 3 if let cell = tableView.make(withIdentifier: "FileCell", owner: nil) as? NSTableCellView { // 4 cell.textField?.stringValue = item.lastPathComponent cell.imageView?.image = fileIcon return cell } // 5 return nil } |
Here’s what you do in this code:
- Get the
URL
matching the row number. - Get the icon for this
URL
.NSWorkspace
is another useful singleton; this method returns the Finder icon for anyURL
. - Get a reference to the cell for this table. The FileCell identifier was set in the Storyboard.
- If the cell exists, set its text field to show the file name and its image view to show the file icon.
- If no cell exists, return
nil
.
Now build and run, select a folder and you should see a list of files and folders appear — hurray!
But clicking on a file or folder gives no useful information yet, so on to the next step.
Getting File Information
Open up the Finder and press Command-I to open a window with information about the file: creation date, modification date, size, permissions and so on. All that information, and more, is available to you through the FileManager
class.
Back in the app, still in ViewController.swift, look for tableViewSelectionDidChange
. This sets the property of the ViewController
: selectedItem
.
Scroll back to the top and look at where selectedItem
is defined. As with selectedFolder
, a didSet
observer is watching for changes to this property. When the property changes, and if the new value is not nil
, the observer calls infoAbout(url:)
. This is where you will retrieve the information for display.
Find infoAbout
, which currently returns a boring static string, and replace it with the following:
func infoAbout(url: URL) -> String { // 1 let fileManager = FileManager.default // 2 do { // 3 let attributes = try fileManager.attributesOfItem(atPath: url.path) var report: [String] = ["\(url.path)", ""] // 4 for (key, value) in attributes { // ignore NSFileExtendedAttributes as it is a messy dictionary if key.rawValue == "NSFileExtendedAttributes" { continue } report.append("\(key.rawValue):\t \(value)") } // 5 return report.joined(separator: "\n") } catch { // 6 return "No information available for \(url.path)" } } |
There are a few different things happening here, so take them one at a time:
- As usual, get a reference to the
FileManager
shared instance. - Use
do...catch
to trap any errors. - Use the FileManager Class’
attributesOfItem(atPath:)
method to try to get the file information. If this succeeds, it returns a dictionary of type[FileAttributeKey: Any]
FileAttributeKeys
, which are members of a struct with a StringrawValue
. - Assemble the key names & values into an array of tab-delimited strings. Ignore the
NSFileExtendedAttributes
key as it contains a messy dictionary that isn’t really useful. - Join these array entries into a single string & return it.
- If the
try
throws an error, return a default report.
Build and run again, select a folder as before, then click on any file or folder in the list:
You now get a lot of useful details about the file or folder. But there’s still more you can do!
More Features
The app is getting better, but it’s still missing a few things:
- Clicking on Show Invisible Files doesn’t change anything.
- Double-clicking on a folder should drill into its contents.
- The Move Up button needs to move back up the folder hierarchy.
- Save Info should record the selected file’s details to a file.
You’ll tackle these next.
Handling Invisible Files
In Unix systems, files and folders whose name starts with a period are invisible. You’ll add code to handle this case.
Go to contentsOf(folder:)
and replace the line containing map
with the following:
let urls = contents .filter { return showInvisibles ? true : $0.characters.first != "." } .map { return folder.appendingPathComponent($0) } |
The above adds a filter
that rejects hidden items if the showInvisibles
property is not true
. Otherwise the filter
returns every item, including hidden items.
Find the toggleShowInvisibles
method of ViewController
and insert this into the function:
// 1 showInvisibles = (sender.state == NSOnState) // 2 if let selectedFolder = selectedFolder { filesList = contentsOf(folder: selectedFolder) selectedItem = nil tableView.reloadData() } |
Here is what this code does:
- Sets the
showInvisibles
property based on the sender’s state. Since the sender is anNSButton
, it has eitherNSOnState
orNSOffState
. Because this is a checkbox button,NSOnState
means checked. - If there is a currently
selectedFolder
, regenerate thefilesList
and update the UI.
Build and run, select a folder and check and un-check the Show Invisible Files button. Depending on the folder you’re viewing, you may see files starting with a period when Show Invisible Files is checked.
Handling Double-Clicking on a Folder
In the storyboard, the table view has been assigned a doubleAction
that calls tableViewDoubleClicked
. Find tableViewDoubleClicked
and replace it with the following:
@IBAction func tableViewDoubleClicked(_ sender: Any) { // 1 if tableView.selectedRow < 0 { return } // 2 let selectedItem = filesList[tableView.selectedRow] // 3 if selectedItem.hasDirectoryPath { selectedFolder = selectedItem } } |
Taking the above code comment-by-comment:
- Check to see whether the double-click occurred on a populated row. Clicking in a blank part of the table sets the
tableView's
selectedRow to -1. - Get the matching
URL
fromfilesList
. - If the
URL
is a folder, set theViewController's
selectedFolder
property. Just like when you select a folder using the Select Folder button, setting this property triggers the property observer to read the contents of the folder and update the UI. If theURL
is not a folder, nothing happens.
Build and run, select a folder containing other folders, and then double-click a folder in the list to drill down into it.
Handle the Move Up Button
Once you have implemented double-click to drill down, the next obvious step is to move back up the tree.
Find the empty moveUpClicked
method and replace it with the following:
@IBAction func moveUpClicked(_ sender: Any) { if selectedFolder?.path == "/" { return } selectedFolder = selectedFolder?.deletingLastPathComponent() } |
This first checks to see whether the selectedFolder
is the root folder. If so, you can’t go any higher. If not, use a URL
method to strip the last segment off the URL. Editing selectedFolder
will trigger the update as before.
Build and run again; confirm that you can select a folder, double-click to move down into a sub-folder and click Move Up to go back up the folder hierarchy. You can move up even before double-clicking a folder, as long as you are not already at the root level.
didSet
) can be incredibly useful. All the code for updating the display is in an observer, so no matter what method or UI element changes an observed property, the update happens with no need to do anything else. Sweet!Saving Information
There are two main ways to save data: user-initiated saves and automatic saves. For user-initiated saves, your app should prompt the user for a location to save the data, then write the data to that location. For automatic saves, the app has to figure out where to save the data.
In this section, you are going to handle the case when the user clicks the Save Info button to initiate a save.
You used NSOpenPanel
to prompt the user to select a folder. This time, you are going to use NSSavePanel
. Both NSOpenPanel
and NSSavePanel
are subclasses of NSPanel
, so they have a lot in common.
Replace the empty saveInfoClicked
method with the following:
@IBAction func saveInfoClicked(_ sender: Any) { // 1 guard let window = view.window else { return } guard let selectedItem = selectedItem else { return } // 2 let panel = NSSavePanel() // 3 panel.directoryURL = FileManager.default.homeDirectoryForCurrentUser // 4 panel.nameFieldStringValue = selectedItem .deletingPathExtension() .appendingPathExtension("fs.txt") .lastPathComponent // 5 panel.beginSheetModal(for: window) { (result) in if result == NSFileHandlingPanelOKButton, let url = panel.url { // 6 do { let infoAsText = self.infoAbout(url: selectedItem) try infoAsText.write(to: url, atomically: true, encoding: .utf8) } catch { self.showErrorDialogIn(window: window, title: "Unable to save file", message: error.localizedDescription) } } } } |
Taking each numbered comment in turn:
- Confirm that everything you need is available: a window for displaying the panel and the
URL
whose info you are going to save. - Create an
NSSavePanel
. - Set the
directoryURL
property which dictates the initial folder shown in the panel. - Set the
nameFieldStringValue
property to supply a default name of the file. - Show the panel and wait in a closure for the user to finish.
- If the user selects a valid path for the data file (a valid
URL
) and clicks the OK button, get the file information and write it to the selected file. If there is an error, show a dialog. Note that if the user clicks Cancel on the save dialog, you simply ignore the operation.
write(to:atomically:encoding)
is a String method that writes the string to the provided URL
. The atomically
option means that the string will be written to a temporary file and then renamed, ensuring that you won’t end up with a corrupt file — even if the system crashes during the write. The encoding for the text in this file is set to UTF8, which is a commonly used standard.
Build and run, select a file or folder from the table and click Save Info. Select a save location, and click Save. You will end up with a text file that looks similar to the following:
NSSavePanel
is that if you try to overwrite a file that already exists, your app will automatically display a confirmation dialog asking if you want to replace that file.That closes off the list of features for this app, but there is one more feature I think would be a nice addition: recording the selected folder and item so that when the app restarts, the last selected folder is re-displayed.
Saving App State
Normally, I would store app-state data in UserDefaults
, which is saved automatically for you in the Preferences folder. But that doesn’t allow you to do anything fancy with the file system. Instead, you will save this data to a dedicated app folder inside the Application Support folder.
Scroll down to the end of ViewController.swift and you’ll see an extension dedicated to saving and restoring the user’s selections.
I’ve provided the functions that do the actual writing and reading. Writing uses the same write(to:atomically:encoding)
method used when saving the info file. Reading uses a String
initializer to create a String
from a URL
.
The really interesting thing here is how to decide where to save the data. You’ll do that in urlForDataStorage
, which is returning nil
at the moment.
Replace urlForDataStorage
with the following:
private func urlForDataStorage() -> URL? { // 1 let fileManager = FileManager.default // 2 guard let folder = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return nil } // 3 let appFolder = folder.appendingPathComponent("FileSpy") var isDirectory: ObjCBool = false let folderExists = fileManager.fileExists(atPath: appFolder.path, isDirectory: &isDirectory) if !folderExists || !isDirectory.boolValue { do { // 4 try fileManager.createDirectory(at: appFolder, withIntermediateDirectories: true, attributes: nil) } catch { return nil } } // 5 let dataFileUrl = appFolder.appendingPathComponent("StoredState.txt") return dataFileUrl } |
What is all this code doing?
- It’s your old friend
FileManager
class to the rescue again. :] - The
FileManager
class has a method for returning a list of appropriateURLs
for specific uses. In this case, you are looking for theapplicationSupportDirectory
in the current user’s directory. It is unlikely to return more than one URL, but you only want to take the first one. You can use this method with different parameters to locate many different folders. - As you did in the playground, append a path component to create an app-specific folder
URL
and check to see if it exists. - If the folder does not exist, try to create it and any intermediate folders along the path, returning
nil
if this fails. - Append another path component to create the full
URL
for the data file and return that.
.applicationSupportDirectory
is a short way to say FileManager.SearchPathDirectory.applicationSupportDirectory
. .userDomainMask
refers to FileManager.SearchPathDomainMask.userDomainMask
. While the shorthand is much easier to type and read, it is useful to know where these come from, so you can find them in the documentation if you ever need to look them up.
Build and run, select a folder, then click on a folder or file. Use the Quit menu item or Command-Q to close the app. Don’t quit via Xcode, or the lifecycle methods won’t trigger a save. Run the app again and notice it opens up to the file or folder you were viewing when you quit.
Where to Go From Here?
You can download the final sample project here.
In this FileManager
class tutorial:
- You learned how
URLs
can represent local files and folders and can show many properties available to you about a file or folder. - You learned how you can add and remove path components to a
URL
. - You explored the
FileManager
class which gives you access properties likehomeDirectoryForCurrentUser
, theapplicationSupportDirectory
and evenattributesOfItem
with detailed information about a file or folder. - You learned how to save information to a file.
- You learned how to check if a file or folder exists.
For more information, check out Apple’s FileManager API Reference Documentation which shows many more of the available methods in the FileManager
class.
You are now ready to begin incorporating the use of files and folders in your own apps.
If you have any questions or comments please join the forum discussion below!
The post FileManager Class Tutorial for macOS: Getting Started with the File System appeared first on Ray Wenderlich.