Blog Post

Using the Document Browser in SwiftUI to Open a PDF

Illustration: Using the Document Browser in SwiftUI to Open a PDF

With iOS 14, Apple made it incredibly easy to integrate the document browser into SwiftUI-powered apps. There’s a powerful new API that abstracts the underlying UIDocumentBrowserViewController and UIDocument classes, but there’s currently very little documentation for it.

In this article, we’ll build a sample app that opens and edits PDF documents. This will help us understand the current features and limitations of this new API.

New in iOS 14 and macOS 11 Big Sur: DocumentGroup

SwiftUI features the new DocumentGroup API, which takes over showing the UIDocumentBrowserViewController and then displaying your own view to show the selected file. It’s meant to be used in combination with the new Scene management of SwiftUI:

@main
struct DocumentBrowserApp: App {
    var body: some Scene {
        DocumentGroup(newDocument: PDFDocument()) { file
           PDFDocumentView(document: file.$document)
        }
    }
}

The DocumentGroup takes a lambda that calls us with a FileDocumentConfiguration, which includes a mapping to a FileDocument protocol, the fileURL, and an indicator that says whether or not the file is editable. Underneath, the logic is backed by UIDocument, which takes over saving whenever the document binding changes.

Notice the new @main declaration, which was added in Swift 5.3 as SE-0281 to define the entry point for the app, replacing the older @UIApplicationMain. Thanks to this addition, the required code here is minimal and uses a modern, declarative approach.

Note that you can use init() as the appropriate entry point to set up application-specific code, such as the license key for PSPDFKit:

@main
struct DocumentBrowserApp: App {
    init() {
        // Visit https://pspdfkit.com for a license key.
        SDK.setLicenseKey("YOUR_LICENSE_KEY_GOES_HERE")
    }
    // Remaining code …

What about iOS 13?

If you have to support iOS 13, you’ll need to manually present UIDocumentBrowserViewController and take care of saving and file coordination — possibly using UIDocument as well. This is absolutely possible and not much different to showing the document browser with UIKit.

We wrote about the iOS document browser view controller and its problems in the past; luckily Apple is constantly improving the component and it’s pretty good now!

Implementing FileDocument

FileDocument is a protocol that expects value semantics (so, use a struct), and it defines both what data types it supports and how reading and writing work.

Since PSPDFKit can open and annotate both PDF and image files, we support multiple file types in this example:

struct PDFDocument: FileDocument {
    /// Access the PSPDFKit document data model.
    var referenceDocument: Document

    static var readableContentTypes: [UTType] { [.pdf, .jpeg, .png] }

    init(data: Data = Data(), contentType: UTType = .pdf) {
        referenceDocument = PDFDocument.createDocument(data: data, contentType: contentType)
    }

    init(configuration: ReadConfiguration) throws {
        guard let fileData = configuration.file.regularFileContents else {
            throw CocoaError(.fileReadCorruptFile)
        }
        referenceDocument = PDFDocument.createDocument(data: fileData, contentType: configuration.contentType)
    }

    /// Create a document subclass based on the content type.
    private static func createDocument(data: Data = Data(), contentType: UTType) -> Document {
        switch contentType {
        case .pdf:
            return Document(dataProviders: [DataContainerProvider(data: data)])
        case .jpeg, .png:
            return ImageDocument(imageDataProvider: DataContainerProvider(data: data))
        default:
            return Document() // Invalid document.
        }
    }

    func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
        return .init(regularFileWithContents: pdfDocument.data!)
    }
}

⚠️ Warning: The sample code here is simplified and lacks some recommended error handling. The full example app can be downloaded for free as part of our PDF SDK example code.

Triggering a Save

To ensure SwiftUI detects a change on the document and saves the data, the PDFDocument struct must be modified before the view is removed. If the struct is modified after the presentation is gone, it’ll trigger an autosave after a default delay.

This particular behavior isn’t documented and took me several hours to understand — at some point I even wrote a hack to reduce the autosave delay of the underlying UIDocument to zero to make the system save the data immediately. Please don’t use this hack, and work with the system instead.

In this example, we use the annotation change publisher of the document — every time a user edits an annotation, the document is marked dirty and requires saving. The easiest way to mark the document as changed is via setting referenceDocument to itself:

struct PDFDocumentView: View {
    private var cancelSet = Set<AnyCancellable>()
    @Binding var document: PDFDocument

    init(document: Binding<PDFDocument>) {
        _document = document

        // Install a listener to trigger the binding to indicate that we need to save data.
        let referenceDocument = document.wrappedValue.referenceDocument
        referenceDocument.annotationChangePublisher.sink { _ in
            // Set a property to mark the document as needing a write to disk whenever annotations change.
            document.wrappedValue.referenceDocument = referenceDocument
        }.store(in: &cancelSet)
    }

    var body: some View {
       PDFView(document: ObservedObject(initialValue: document.referenceDocument))
            .useParentNavigationBar(true)
            .edgesIgnoringSafeArea(.all)
    }
}

The document browser comes with its own file update logic, so changes on a PDF or image file will automatically update the thumbnail. Voilà — we just built a simple PDF Viewer. (We offer a full-featured one for free!)

Bonus: Detecting Memory Mapped Data

If you study the code, you’ll see a big red flag here: The file is loaded as a data object! However, testing the example with a 2 GB PDF file is still fast and opens within seconds. This implies the data here must be memory mapped. Can we verify that?

By looking at stack traces, we can observe that the document logic below is backed by UIDocument, which supports memory-mapped data. Let’s put a breakpoint into the document initializer of our PDFDocument struct and print the data:

(lldb) po fileData
▿ 2235543124 bytes
  - count : 2235543124
  ▿ pointer : 0x00000002a0000000
    - pointerValue : 11274289152

(lldb) po fileData._representation
Printing description of fileData._representation:
▿ _Representation
  ▿ large : LargeSlice
    ▿ slice : <RangeReference: 0x281e194e0>
    ▿ storage : <__DataStorage: 0x283d04230>

So far so good — this is a bit over 2.2 GB in size and starts at 0x00000002a0000000. We can use malloc_info to check whether or not this pointer was created with malloc. If malloc wasn’t used, we know that the file is memory mapped:

(lldb) command script import lldb.macosx.heap

# Verify the command works by using the Swift `DataStorage` object.
(lldb) malloc_info 0x283d04230
0x0000000283d04230: malloc(    80) -> 0x283d04230 _TtC10Foundation13__DataStorage._TtCs12_SwiftObject.isa

# Check the pointer of the `fileData` object to verify that it was not created via `malloc`:
(lldb) malloc_info 0x00000002a0000000
# no output

We could successfully verify that the memory location of our data object is memory mapped. This is great, so even large files aren’t an issue with this setup!

Shoutout to Johannes Weiss for sharing this trick!

Conclusion

In this article, we built a complete example to show, annotate, and save PDF files using the new document browser API in iOS 14 and macOS Big Sur. It has never been this easy to build a full-featured PDF viewer!

Explore related topics

Related products
Share post
Free trial Ready to get started?
Free trial