Opening multiple files in a single SwiftUI document on macOS
By: Brian Webster |
I’ve recently been dipping my toes into SwiftUI for the first time, with my first project being a document based app using the SwiftUI lifecycle. This post assumes you’re already familiar with the basics of writing a document based SwiftUI app.
My app lets you drag multiple photos into a document and has a basic photo browser for viewing the photos and various control for doing stuff with them. It has its own document type which is saved as JSON data, and that JSON contains URLs that reference the photo files that you dragged into the document. That all is relatively straightforward, and here is the basic code for my document.
struct MyDocument: FileDocument {
var documentData: DocumentData
static var readableContentTypes: [UTType] { [.myDocumentType] }
init(configuration: ReadConfiguration) throws {
guard let data = configuration.file.regularFileContents else {
throw CocoaError(.fileReadCorruptFile)
}
let jsonDecoder = JSONDecoder()
self.documentData = try jsonDecoder.decode(DocumentData.self, from: data)
}
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
let encoder = JSONEncoder()
let data = try encoder.encode(self.documentData)
return .init(regularFileWithContents: data)
}
}
And the relevant part of the data model:
struct DocumentData: Codable {
var photos: [Photo]
}
struct Photo: Codable {
let url: URL
}
Where I hit a road bump was that I wanted the user to be able to drag multiple photo files from the Finder and drop them onto my dock icon, and have that open a new, untitled document that’s already populated with those photos. SwiftUI isn’t really set up for that scenario, so I had to try a few things before finding one that worked.
First, to get the dock icon to accept dropped image files, I added a new document type in the Info pane for my app target in Xcode, specifying “public.image” so it will accept any image formats, and a role of “Viewer”, since I’m not actually editing the photos themselves. Then, if I added .image to the list of readableContentTypes in my document, that would result in SwiftUI trying to open a separate document for each photo that was dragged to the app icon. That’s not what I want though, I want all the photos to go into a single document.
My next attempt was to see if I could handle the dropped files in my app delegate that I have set using SwiftUI’s @NSApplicationDelegateAdaptor property wrapper on my App instance. I tried implementing the application(_:openURLs:) method, and after removing the .image type from my document (so that SwiftUI would no longer route the dropped images there), it did call my app delegate. Yay!
Now I needed to actually create the new document. You can’t do this by directly calling SwiftUI, but all the SwiftUI stuff is handled using the underlying NSDocument architecture in AppKit, so this was my first stab at it:
func application(_ application: NSApplication, openURLs: [URL]) {
do {
let photos = urls.map { Photo(url: $0) }
let documentData = DocumentData(photos: photos)
let encoder = JSONEncoder()
let encodedData = try encoder.encode(documentData)
let fileWrapper = FileWrapper(regularFileWithContents: encodedData)
let newDocument = try NSDocumentController.shared.openUntitledDocumentAndDisplay(true)
try newDocument.read(from: fileWrapper, ofType: UTType.myDocumentType.identifier)
} catch let error as NSError {
NSApplication.shared.presentError(error)
}
I ran this, but soon discovered that even though the delegate method accepts multiple URLs as input, and in a normal AppKit app a single drop of multiple files would all be passed at once, SwiftUI was still doing its thing behind the scenes and calling the delegate method multiple times with one URL passed each time. This resulted in multiple documents opening up, each with a single photo.
So, like so many things in SwiftUI, we need to do a dirty little hack to collect all the URLs we want before opening the document, like so.
var urlsBeingOpened: [URL]? = nil
func application(_ application: NSApplication, open urls: [URL]) {
//We start collecting URLs and set up a timer to fire the next time through the run
//loop after we stop receiving URLs.
if urlsBeingOpened == nil {
self.urlsBeingOpened = []
Timer.scheduledTimer(withTimeInterval: 0.1, repeats: false) {_ in
self.createDocumentFromOpenedURLs()
}
}
self.urlsBeingOpened?.append(contentsOf: urls)
}
func createDocumentFromOpenedURLs() {
do {
guard let urls = self.urlsBeingOpened else { return }
self.urlsBeingOpened = nil //reset the collection for the next drop
let photos = urls.map { Photo(url: $0) }
let documentData = DocumentData(photos: photos)
let encoder = JSONEncoder()
let encodedData = try encoder.encode(documentData)
let fileWrapper = FileWrapper(regularFileWithContents: encodedData)
let newDocument = try NSDocumentController.shared.openUntitledDocumentAndDisplay(true)
try newDocument.read(from: fileWrapper, ofType: UTType.myDocumentType.identifier)
} catch let error as NSError {
NSApplication.shared.presentError(error)
}}
Since SwiftUI calls our delegate method multiple times in a tight loop, we use an instance variable to collect all the URLs into an array, and fire off a one-shot timer that triggers the next time through the run loop after SwiftUI is done giving us all the URLs. That lets us take all the URLs and create a single document from them there. Note the use of a FileWrapper to pass the encoded document contents to the document for reading. I initially tried just passing a plain Data object to NSDocument to read, but apparently SwiftUI doesn’t implement that reading method, but putting it in a FileWrapper did the trick.
So, it’s not pretty, but that does get the job done. Now if you’ll excuse me, I’ve got about 17 radars feedbacks to file…