Episode #543

SwiftUI on the Mac - Document Apps

Series: SwiftUI on the Mac

11 minutes
Published on January 6, 2023

This video is only available to subscribers. Get access to this video and 572 others.

We start looking at SwiftUI on the Mac. We'll go over how document-based apps work, and see how much functionality and standard behavior we can get with just a few lines of code. This will be the basis for a new app we'll make called Memeify.

In this episode we we’re going to start to take a look at SwiftUI on the Mac. SwiftUI feels great in comparison with AppKit (given that it’s nearly 30 years old), but there are some rough edges which means that you may run into cases where things won’t work they way you want, or you’ll have to integrate some AppKit code to complete it.

To start, let’s make a simple macOS app called “Memeify” that will allow you to drag in an image and write some text on it. We’ll keep it pretty basic for now.

Create a new Document-based macOS app in Xcode. Make sure you pick SwiftUI for the Interface option.

Xcode new app window

We’re using a Document-based app because we want to allow multiple windows, each of it will be a document that the user can create, save to a file, etc. This will also allow the user to work on multiple documents at the same time.

We’re given some starter code which includes some basic behavior. Let's first take a look at what it includes.

Our Document type

Xcode creates a struct called MemeifyDocument which implements the FileDocument protocol. A FileDocument must be able to read and write to a file and be interpreted with a given Uniform Type which is represented by UTType.

You’ll notice that we have an extension that provides our custom file type:

extension UTType {
    static var exampleText: UTType {
        UTType(importedAs: "com.example.plain-text")
    }
}

This will provide some metadata to the file so that it can be interpreted by other apps as well (Finder or Preview, for instance). It’s up to you what you want this content type to be. If you’re building a text editor, then plain text is a good idea. If you’re building an image editor, maybe you want to save your documents as an image type.

The document currently looks like this:

struct MemeifyDocument: FileDocument {
    var text: String

    init(text: String = "Hello, world!") {
        self.text = text
    }

    static var readableContentTypes: [UTType] { [.exampleText] }

    init(configuration: ReadConfiguration) throws {
        guard let data = configuration.file.regularFileContents,
              let string = String(data: data, encoding: .utf8)
        else {
            throw CocoaError(.fileReadCorruptFile)
        }
        text = string
    }

    func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
        let data = text.data(using: .utf8)!
        return .init(regularFileWithContents: data)
    }
}

For now we can leave this alone. We’ll revisit this when we get to saving and loading documents.

Document Group

Next let’s turn our attention to the main app entry point, which is located here in MemeifyApp.swift. This uses the @main attribute, which tells macOS that this SwiftUI App is the entry point for the application.

The App protocol in SwiftUI requires us to provide some Scene. a Scene is yet another protocol. Typically you'll see WindowGroup used as the scene. For document-based apps, we instead have a DocumentGroup. This will give us the ability to have one window per document that we are working on.

@main
struct MemeifyApp: App {
    var body: some Scene {
        DocumentGroup(newDocument: MemeifyDocument()) { file in
            ContentView(document: file.$document)
        }
    }
}

The DocumentGroup initializer takes a newDocument argument. This is what the user will receive when they choose New Document in your application. You can also use DocumentGroup(viewing: ...) if your app is simply a viewer for a document and does not need a binding.

Speaking of bindings, the last thing you’ll notice in this example is we are given a file argument, and with this we have access to a binding to our document instance.

Binding to a Document

SwiftUI on the Mac provides a facility for using Bindings directly on a document type. This means we can have our UI manipulate data in the document directly. The starter project gives us this code, which uses the builtin TextEditor view and passes in a Binding<String> to edit.

struct ContentView: View {
    @Binding var document: Memify2Document

    var body: some View {
        TextEditor(text: $document.text)
    }
}

Running the app

Let’s finally run the app. When we run it we’re presented with the file picker. If we choose a supported file (one that matches the UTType we specified), then we'll get a document referencing that file. If we create a new document, the value we passed to our DocumentGroup is used and the window and content view are displayed.

image

Take a moment to think about how much built-in functionality this gives you in a mere ~20 lines of code. We have all of the standard macOS facilities already implemented: a menu, save, rename, document proxy icons, all of the text editing behavior you would expect on a mac app, window resizing, Full Screen support, and more!

We even have the ability to right click on any text file, choose Open With and see our new app in there. And our app supports drag & drop of text documents onto the app icon in the dock to open it up.

Next up, images!

Next time we will change our app from a text editor to an extremely basic image editor. Stay tuned!

This episode uses Xcode 14.2, Swift 5.7.