SwiftUI on macOS: Documents

This article has a source code supplement. To read that in parallel, open this link in a separate window or tab so you can follow this account with its full source code.

In my previous articles on using SwiftUI in macOS (listed at the end), I have looked at the app life-cycle and application support features such as settings and Help books. This article turns to look at support for document-based apps, which are so distinctive of macOS.

Xcode helpfully creates a set of templates when you create a new document-based app using SwiftUI. Those are based on one of the two protocols, using a FileDocument with a struct; if you want instead to use a ReferenceFileDocument with a class, you’ll have to modify those files by hand. Other than the former being based on value types, and the latter on reference types, there’s little guidance offered as to which you should choose. As this article implements the same app using both options, the information here may give more clues.

Lightweight documents

SwiftUI’s document model is lightweight, and a marked contrast from the riches of AppKit’s NSDocument. My initial aim for this article was to show how to access file attributes without reading the data in the document file. That came to grief because neither option provides ready access to a document’s URL, one of the fundamental variables supported by NSDocument. Trying to obtain a document’s URL in SwiftUI took me down a rabbit hole discussed here, and illustrated in my first two code samples.

Document URLs are provided in a document’s configuration. As shown in the URL App and ContentView code, it’s straightforward to access the fileURL from the configuration, but not so simple to do so in the FileDocument struct, or ReferenceFileDocument class. After many hours of inconsequential fiddling, I abandoned that plan, and decided to implement a PDF viewer instead.

What I learned on the way was that the FileDocument option wasn’t always a good choice, as there can be serious problems with undefined behaviour if, for instance, a mutating method is used within the struct. In some circumstances, a ReferenceFileDocument class may be essential, although it isn’t clear exactly when those will be.

PDFView

Xcode’s template for SwiftUI document apps creates a basic text editor that’s fairly functional, and impresses, but as I have noted previously, this is an exception. SwiftUI doesn’t provide native support for the editing of other common file types such as Rich Text. My goal therefore was to implement a viewer relying on a content-specific view from one of the traditional ‘kits’, in this case PDFView from Quartz.

Implementation as a FileDocument-based app follows the template layout, as shown in the source code. The main App struct creates a new PDFViewerDocument and displays that in the ContentView. The Document code implements the initialisation of the FileDocument, reading the file’s data into a new PDFDocument, and writing that file data out to save the document. I had originally intended to limit this to viewing and not saving, but will return to that below. The ContentView binds to the document, and passes that to the PDFView that I have shown previously.

One disappointment was that I was unable to implement a PDFThumbnailView to accompany the main PDFView. No matter what I tried, Xcode wouldn’t accept that I could typealias it as an NSViewType, so I had to abandon providing thumbnails for the open PDF document. Code required to implement that in AppKit is brief and simple.

Perhaps that was just as well, as document-based apps pose a problem for the previewing of their views: because they’re set to display a document of the user’s choice, unless you modify your code to use an example file loaded from the app bundle instead, you’ll have to comment out the Preview and design the view blind.

Menus

It had been my intention to keep this as a basic document viewer that couldn’t write or save PDF files. To do that required at least disabling if not removing Save and Save As commands in the File menu. There would also be little point in the New command. To my shock, it appears the only feasible way to do this is by changing that menu in an AppDelegate, an AppKit feature that is discouraged in SwiftUI apps. There is a way to remove groups of menu commands, but the option to remove those for file saving also removes the Open Recent command, so wasn’t a good idea at all.

As far as I can tell, SwiftUI currently provides no means for code to modify standard menus in any useful way.

ReferenceFileDocument

Implementing this as a ReferenceFileDocument is almost identical, apart from the Document itself, shown in the source code.

A small change in bracketing is required when creating the new document in the App code, which gains a pair of {} to satisfy Xcode’s parser:

DocumentGroup(newDocument: { PDFViewerDocument() }) { file in
ContentView(pdfDocument: file.document)
}

In ContentView, var pdfDocument is declared as @ObservedObject rather than @Binding, as the ReferenceFileDocument is an ObservableObject protocol, unlike FileDocument.

More changes are needed in the document, which is no longer a struct, but a class, and needs to support document snapshots. Quite why Apple chose to confuse by giving these the same name as APFS volume snapshots is another of SwiftUI’s mysteries. They’re not APFS snapshots, but turn out to be traditional document versions, saved to the volume’s version database. Nowhere does Apple appear to document that.

Testing

Initial testing shows that both implementations appear to work as intended, although there are marked differences between them when it comes to saving documents. To allow you to experience these, I have built a notarized version of each, and you can download them from here: pdfviewers1

These require macOS Sonoma version 14.0 or later.

PDFViewer uses ReferenceFileDocument, and PDFViewer2 uses FileDocument. If you’re ever confused over which is which, open the About window and it tells you which protocol that app is using.

Although I’ve not encountered any bugs in these so far, I recommend that you only open copies of PDF files in them, as they will overwrite the original file.

In each, open a hefty PDF document, hold down the Option key, and use the Save As command in the File menu. PDFViewer, using ReferenceFileDocument, will take several seconds to complete the save, during which it becomes unresponsive, and the spinning beachball cursor may appear. It can also take several seconds to close a document window after saving. PDFViewer2, using FileDocument, performs saving in the background, and doesn’t block or spin the beachball.

SwiftUI document implementations

I have compiled a summary table showing the differences that I know of between SwiftUI’s two FileDocument protocols.

While Apple’s documentation claims that calls made to ReferenceFileDocument methods are run in a background thread, so can’t make changes to the user interface, the evidence from this implementation suggests that saves, at least, are actually run in the main thread. Given the choice of protocols, in this instance I would definitely prefer FileDocument because of that difference in performance.

Both protocols pale in comparison with NSDocument, though. For simple applications, this brings elegance, but increases the complexity of others, where for instance file data needs to be streamed, or non-data accessed. For general use in macOS apps, SwiftUI’s FileDocument protocols are still woefully underpowered.

Summary

SwiftUI’s API for menus is too limited to be of any use for macOS apps. The only current workaround is to use AppDelegate. Until that API is expanded to match that in AppKit, SwiftUI will remain a poor fit for many macOS apps.
Incorporating AppKit and other ‘kit’ views in SwiftUI apps mostly works, but remains a significant limit in some.
FileDocument and ReferenceFileDocument are currently too underpowered for use in many macOS apps.
ReferenceFileDocument doesn’t appear to perform operations in a background thread.
Implementing many document-based macOS apps using SwiftUI is like a fish riding a bicycle.

If you can spot the many errors in my code, please let me know so that I can correct them.

Previous articles

SwiftUI on macOS: Life Cycle and AppDelegate
SwiftUI on macOS: Life Cycle and App Delegate source code
SwiftUI on macOS: PDF Help book
SwiftUI on macOS: PDF Help book source code
SwiftUI on macOS: Settings, defaults and About
SwiftUI on macOS: Settings, defaults and About source code
SwiftUI on macOS: text, rich text, markdown, html and PDF views
SwiftUI on macOS: text, rich text, markdown, html and PDF views source code