Photo by Andrew Pons on Unsplash

Detecting changes to a folder in iOS using Swift

Daniel Galasko
Over Engineering
Published in
7 min readFeb 4, 2020

--

In this post, you will learn how you can observe a specific folder for new/deleted/updated files. Some of this code is adapted from Apple sample code that is no longer available online. The final result will look like the GIF below:

🌄 Background

Most of us are familiar with FileManager and contentsOfDirectory. In most cases, that is good enough when peering into the contents of a folder. Sometimes though, we come across situations where we want to know precisely when the contents of a directory/folder have changed.

At Over, we allow users to create project documents that live inside a folder. As the user makes edits to a project, we need to update its thumbnail and, in the case of a new project, add it to the project feed. The coordination required to manage this manually could be highly error-prone. Since cache invalidation is one of the hardest programming challenges, we need a solution that gives us the closest source of truth.

We could use Timer to monitor a folder by polling for changes but, that would incur a lot of unnecessary file system reads and overall feels wasteful.

Enter DispatchSourceFileSystemObject. This nifty GCD object monitors a given file descriptor for events. One can consult FileSystemEvent for the list of events we can monitor, but in this instance, we care about FileSystemEvent.write.

🏃‍♀️Getting Started

We begin with a demo project that displays files in a folder. We will have functionality to add and remove a file.

Our first step is to start a new SwiftUI project, and in the ContentView, add support for showing a list of files.

struct ContentView: View {
@ObservedObject var folder = Folder()

var body: some View {
NavigationView {
List(folder.files) { file in
Text(file.lastPathComponent)
}
.navigationBarTitle("Folder Monitor")
.navigationBarItems(leading: createFileItem, trailing: deleteFileItem)
}
}
}

Above, we have the definition of a view that will show a list of files and update the list on any changes. We can create and delete files and expect the UI to be updated. Ignore what Folder does, for now, that will be explained later. Just know it returns a list of files and publishes changes whenever that list changes.

📹 Monitoring a Folder

When monitoring a folder, two things are essential:

  1. A file descriptor pointing at the folder we want to observe.
  2. A DispatchSourceFileSystemObject that will use the descriptor to report changes. We can construct one using the DispatchSource.makeFileSystemObjectSource API.

Using them together would look like:

let folderToObserve: URL = //a folder somewhere on device
let descriptor = open(folder.path, O_EVTONLY)
let folderObserver = DispatchSource.makeFileSystemObjectSource(fileDescriptor: monitoredDirectoryFileDescriptor, eventMask: .write, queue: someQueue)
directoryMonitorSource?.setEventHandler { in
print("Something changed in our folder")
}

In the above, we use eventMask: .write so that we only get events for file writes to that folder. This mask will give us updates that include new files, deletions, and updates (ie, an existing file has been updated).

When it comes to GCD APIs like this, it’s good practice to add a higher-level abstraction. This enables an API that is easier to consume and more customizable for your purposes.

Our abstraction will be called FolderMonitor. Before I share the code, there are some essential concepts to consider when using folder observers. Namely, we need to manage the lifetime of our objects so that we do not keep monitoring files when we no longer need to. We will cater for this by adding functionality to release the folder monitor as required.

With that in mind, this is what our monitor API should look like 👇

class FolderMonitor {
let url: URL
var folderDidChange: (() -> Void)?
init(url: URL)
func startMonitoring()
func stopMonitoring()
}

A simple enough API to consume and understand. Now, let’s expand upon it.

First, we need references to the two essentials mentioned earlier:

class FolderMonitor {    
/// A file descriptor for the monitored directory.
private var monitoredFolderFileDescriptor: CInt = -1
/// A dispatch source to monitor a file descriptor created from
the directory.
private var folderMonitorSource: DispatchSourceFileSystemObject?
/// A dispatch queue used for sending file changes in the
directory.
private let folderMonitorQueue = DispatchQueue(label: "FolderMonitorQueue", attributes: .concurrent)
let url: URL
var folderDidChange: (() -> Void)?
init(url: URL)
func startMonitoring()
func stopMonitoring()
}

Now we can implement our startMonitoring function:

func startMonitoring() {
guard folderMonitorSource == nil && monitoredFolderFileDescriptor == -1 else {
return
}
// Open the folder referenced by URL for monitoring only.
monitoredFolderFileDescriptor = open(url.path, O_EVTONLY)

// Define a dispatch source monitoring the folder for additions, deletions, and renamings.
folderMonitorSource = DispatchSource.makeFileSystemObjectSource(fileDescriptor: monitoredDirectoryFileDescriptor, eventMask: .write, queue: directoryMonitorQueue)

// Define the block to call when a file change is detected.
folderMonitorSource?.setEventHandler { [weak self] in
self?.folderDidChange?()
}

// Define a cancel handler to ensure the directory is closed when the source is cancelled.
folderMonitorSource?.setCancelHandler { [weak self] in
guard let self = self else { return }
close(self.monitoredFolderFileDescriptor)
self.monitoredFolderFileDescriptor = -1
self.folderMonitorSource = nil
}

// Start monitoring the directory via the source.
folderMonitorSource?.resume()
}

Above, you can see the usage of the monitor source to handle both file changes and also cancellation. Isn’t it neat that it will send events for any file that is updated inside the folder!

Closing the loop is easy, to stop observing folder changes:

func stopMonitoring() {
folderMonitorSource?.cancel()
}

👟 Tying it up to our UI

At this point, we have a capable folder observer. To prepare it for SwiftUI, we can wrap it into a UI-friendly ObservableObject. In this example, all I am doing is observing the files in the Temporary Directory:

class Folder: ObservableObject {
@Published var files: [URL] = []

let url = URL(fileURLWithPath: NSTemporaryDirectory())
private lazy var folderMonitor = FolderMonitor(url: self.url)

init() {
folderMonitor.folderDidChange = { [weak self] in
self?.handleChanges()
}
folderMonitor.startMonitoring()
self.handleChanges()
}

func handleChanges() {
let files = (try? FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil, options: .producesRelativePathURLs)) ?? []
DispatchQueue.main.async {
self.files = files
}
}
}

This gives us an observable that uses a directory monitor and publishes the full list of URLs whenever they change.

Since our UI will be displaying these URLs in a list, we need to make them Identifiable.

extension URL: Identifiable {
public var id: String { return lastPathComponent }
}

Note to reader: Be careful when extending foundation types like this as it can have unintended consequences in a large application. I am doing this only for illustrative purposes.

Finally, we can finish our UI Demo:

struct ContentView: View {
@ObservedObject var folder = Folder()

var body: some View {
NavigationView {
List(folder.files) { file in
Text(file.lastPathComponent)
}
.navigationBarTitle("Folder Monitor")
.navigationBarItems(leading: rightNavItem)
.navigationBarItems(leading: rightNavItem, trailing: deleteFileNavItem)
}
}

var deleteFileNavItem: some View {
Group {
if folder.files.count > 0 {
Button(action: deleteFile) {
Image(systemName: "trash")
}
}
}
}

var rightNavItem: some View {
Button(action: createFile) {
Image(systemName: "plus.square")
}
}

func createFile() {
let file = UUID().uuidString
try? file.write(to: folder.url.appendingPathComponent(file), atomically: true, encoding: .utf8)
}

func deleteFile() {
try? FileManager.default.removeItem(at: folderObservable.files.first!)
}
}

🙇‍♀️ Conclusion

You will notice there is still a lot of room in FolderMonitor for expansion. We could design it so that it returns a diff of the files inside a folder. We could even have it report changes to specific files with a specific extension. Please, feel free to take your monitor on its own journey. The purpose of this post was to shine some light on often overlooked APIs.

Thank you for taking the time to read this. Hopefully it made a difference to your iOS journey this day 💖.

Want to hear more? Be sure to follow us on Medium and Twitter @EngineeringOver

💼 Full Code

The full code for our FolderMonitor looks as follows:

class FolderMonitor {
// MARK: Properties

/// A file descriptor for the monitored directory.
private var monitoredFolderFileDescriptor: CInt = -1
/// A dispatch queue used for sending file changes in the directory.
private let folderMonitorQueue = DispatchQueue(label: "FolderMonitorQueue", attributes: .concurrent)
/// A dispatch source to monitor a file descriptor created from the directory.
private var folderMonitorSource: DispatchSourceFileSystemObject?
/// URL for the directory being monitored.
let url: Foundation.URL

var folderDidChange: (() -> Void)?
// MARK: Initializersinit(url: Foundation.URL) {
self.url = url
}
// MARK: Monitoring/// Listen for changes to the directory (if we are not already).
func startMonitoring() {
guard folderMonitorSource == nil && monitoredFolderFileDescriptor == -1 else {
return

}
// Open the directory referenced by URL for monitoring only.
monitoredFolderFileDescriptor = open(url.path, O_EVTONLY)
// Define a dispatch source monitoring the directory for additions, deletions, and renamings.
folderMonitorSource = DispatchSource.makeFileSystemObjectSource(fileDescriptor: monitoredFolderFileDescriptor, eventMask: .write, queue: folderMonitorQueue)
// Define the block to call when a file change is detected.
folderMonitorSource?.setEventHandler { [weak self] in
self?.folderDidChange?()
}
// Define a cancel handler to ensure the directory is closed when the source is cancelled.
folderMonitorSource?.setCancelHandler { [weak self] in
guard let strongSelf = self else { return }
close(strongSelf.monitoredFolderFileDescriptor)
strongSelf.monitoredFolderFileDescriptor = -1strongSelf.folderMonitorSource = nil
}
// Start monitoring the directory via the source.
folderMonitorSource?.resume()
}
/// Stop listening for changes to the directory, if the source has been created.
func stopMonitoring() {
folderMonitorSource?.cancel()
}
}

--

--

Daniel Galasko
Over Engineering

"If you only do what you can do, you'll never be better than what you are" - Kung Fu Panda 3. Currently iOS @GoDaddy