SwiftUI DocumentGroups Are Terribly Limited

For InfiniteCanvas, I’ve started the app as a single-window application on Mac. As it got useful, I switched it to become a document-based app.

SwiftUI has some facilities for this. This is how little you need to get started:

struct MyApp: App {
    var body: some Scene {
        DocumentGroup(newDocument: { MyDocument() }) { file in
            MyDocumentView(document: file.$document)
        }
    }
}

And that produces:

The default launch experience

The default look is boring as heck, and the typography is meh. Large font with app name? Ugh.

What the system does is provide a launch scene for you when you only declare a DocumentGroup in your SwiftUI.App.body. You can customize this by making the launch scene yourself. WWDC24 “Evolve Your Document Launch Experience” contains examples that at least offer to style what’s above the document picker. Hat tip to Cihat Gündüz for the write-up there!

I did my best to make it suck less with a DocumentGroupLaunchScene, but I’m still not a fan of that first impression. Ideally, I’d like people to start drawing immediately, without the document picker.

Instead of a white rectangle with rounded corners and boring font and a button, we have a bit of color and an overlay graphic, but meh

In that screenshot, you notice a “Do not start drawing” button. (Or blue text, rather; there’s no button styling for all but the first NewDocumentButton.) That’s not in the application, that’s for testing: because you can customize what the NewDocumentButtons do a bit, and I wanted to see what that looks like.


What’s a NewDocumentButton?

You can use the NewDocumentButton with a label and it’ll do the rest for you. To do this, it relies on a DocumentGroup scene next to its launch scene to figure out the kind of document to create.

Seriously, if you don’t have a DocumentGroup (for which you need to specify the document type), you get an error in the console:

DocumentGroupLaunchScene should be used only with
DocumentGroup scenes in the App declaration.
Add a DocumentGroup scene
or use DocumentLaunchView instead.

It doesn’t matter that you can tell the NewDocumentButton which kind of FileDocument-conforming type it will produce because the DocumentGroupLaunchScene itself doesn’t contain any of your views. Your actual app’s views need to be hosted by a DocumentGroup for this to work.

This makes sense if you realize that DocumentGroupLaunchScene is just a special variant of the document browser introduced to UIKit, offering the UIDocumentPickerViewController to select files.

The docs don’t tell, and the sample is limited to a custom scene – which is valid, but not enough to use in an App.body, as it needs a DocumentGroup, too:

var body: some Scene {
    DocumentGroupLaunchScene("My Documents") {
        NewDocumentButton("Start Writing…")
        NewDocumentButton("Choose a Template", for: MyDocument.self) {
            try await withCheckedThrowingContinuation { continuation in
                documentCreationContinuation = continuation
                isTemplatePickerPresented = true
            }
        }
        .fullScreenCover(isPresented: $isTemplatePickerPresented) {
            TemplatePicker(continuation: $documentCreationContinuation)
        }
    }
}

You see one interesting variation here, though. A closure-based NewDocumentButton, where you get an async chance for a throwing process to figure out what to do. That’s the only way to customize this.

So what if you want to prevent a document from being created?

For example to limit the amount of documents a user can create unless they purchase the premium tier of your app?

You can throw.

struct PurchaseLicensePlz: Error {}
NewDocumentButton("Do Not Start Drawing", for: MyDocument.self) {
    throw PurchaseLicensePlz()
}

Throwing a PurchaseLicensePlz error provides no feedback except for a console message. I pressed it multiple times without any visual feedback (except the button highlight), like you would press any broken buttons in unresponsive UI. The console captured:

Failed to create a document from template: PurchaseLicensePlz()
Failed to create a document from template: PurchaseLicensePlz()
Failed to create a document from template: PurchaseLicensePlz()
Failed to create a document from template: PurchaseLicensePlz()
Failed to create a document from template: PurchaseLicensePlz()

If you double-click with the mouse in the simulator, or tap the button twice in short succession, like an impatient uncle eventually would if the computer refuses to work again, this alert appears:

The error message after four taps on the button

Now I believe needing to press this button repeatedly to make an alert appear is actually intended behavior. Here’s why.

The example code for new document buttons I reprinted above contains an async path. It uses a continuation to display a template picker. Since that’s in your code, not SwiftUI, it amounts to either waiting for nil, which means “create an empty document”, or a non-nil value, which means “create a document with this initial state”, or throw. There’s no way to cancel the process any other way.

Here’s a more fleshed-out example from the docs for NewDocumentButton.init(_:for:contenttype:preparedocument:):

struct ChooseDocumentTemplateButton: View {
    @State private var showTemplatePicker = false
    @State private var documentCreationContinuation:
           CheckedContinuation<TextDocument?, any Error>?

    var body: some View {
        NewDocumentButton(for: TextDocument.self) {
            try await withCheckedThrowingContinuation { continuation in
                documentCreationContinuation = continuation
                showTemplatePicker = true
            }
        }
        .fullScreenCover(isPresented: $showTemplatePicker) {
            TemplatePicker($documentCreationContinuation)
        }
    }
}

struct TemplatePicker: View {
    @Binding var documentCreationContinuation:
           CheckedContinuation<TextDocument?, any Error>?

    ...

    func present(document: TextDocument) {
        documentCreationContinuation.resume(returning: document)
        documentCreationContinuation = nil
    }
}

struct TextDocument: FileDocument { ... }

In the TemplatePicker that is presented as a full-screen cover, a TextDocument value is passed on from present(document:), completing the continuation.

What if users change their minds and don’t want a template anymore?

Imagine a modal scene where you can pick from a template, but also cancel; what should happen there?

You cannot cancel the continuation without throwing.

The implication is this: if throwing an error would immediately show an alert, which sounds like a sensible request at first to handle errors, you could not cancel a UI like the template picker. Without throwing, there’d only be a way forward, towards document creation, not backward. Users couldn’t explore their options without consequence and back out of modal dialogs. That’s be awful.

So I believe they settled for: throw, and we ignore it; but throw immediately on button press, which means throw twice real quick, then you get an alert, because something’s broken.

With Swift Concurrency, we got a CancellationError in the standard library that is used cancel a Task. If you throw that, you get the same error alert, though. There’s no special treatment that would make CancellationError represent user-originated cancellation of the process.

We’re not blessed with a way to handle errors ourselves, so if a user opens the template picker once, backs out, then opens it again and backs out, they’d get the same alert saying: “Unable to import document. The operation was cancelled.” The last part may be accurate, but “import document” may not be what the user had in mind.

There’s no way to customize this. I tried throwing a couple of system NSErrors, but they also don’t get any special treatment in the built-in catch block in SwiftUI, it seems.

So if you present a modal scene and throw to cancel, you physically cannot do this so quickly that the alert would show. This strategy seems to be legit.


DocumentGroupLaunchScene offers a way to customize the default SwiftUI document-based app launch experience a bit.

Scenes cannot contain conditionals, the SceneBuilder does not support this. That means there’s no way to have an app offer different scenes depending on whether or not in-app purchases have been made.

With document-based apps, you can intercept the creation of new documents by throwing errors from the NewDocumentButton provider closure.

If you paid attention, that special button’s name will have sticked out: new document button.

New.

You cannot prevent users from trying to open existing documents. And the document browser in the bottom half will always offer to ‘Create Document’ in the “Browse” tab at the very least.

Users can always create local documents

That’s powered by the DocumentGroup itself:

struct MyApp: App {
    var body: some Scene {
        DocumentGroup(newDocument: { MyDocument() }) { file in
            MyDocumentView(document: file.$document)
        }
    }
}

So if you want to make a document-based app where people can create a limited amount of documents before they need to purchase the app, you need to make the editor closure of DocumentGroup not return a view that allows editing. You cannot prevent the user from trying to open a file, though. Either you lock the document’s view with a read-only mode of sorts, or you display a different view that’s basically an advert for the in-app purchase.

On iOS, showing a different view that just displays an advert is possible and not all that disrupting in most cases because you’re in full-screen mode with only one document anyway. But on macOS, you have multiple windows by default and that gets a bit weird if users hit Cmd+N to create a new one. (On macOS you also don’t get to use the DocumentGroupLaunchScene, so all you have is custom launch windows/nag screens that you can show by default, and to lock document windows.)