Build A SwiftUI Document App With WritableDocument
SwiftUI’s new document APIs move document saving toward a snapshot-based model. Instead of asking your document object to synchronously turn itself into a FileWrapper, SwiftUI can ask the main-actor document for a sendable snapshot, then hand that snapshot to a writer that does the expensive disk work asynchronously.
That is the important mental shift:
- Your document object owns editable UI state.
snapshot(contentType:)freezes the current document into a value.- A
DocumentWriterwrites that value to disk. - The writer can compare the new snapshot with the previous snapshot.
- Export formats, like PNG or PDF, become additional writable content types.
The API is new in the WWDC26 beta cycle, so treat exact labels as beta-shaped. The broad design is the part to internalize: main-actor state becomes a sendable snapshot, and disk work moves into an async writer.
One availability caveat: as of the June 2026 beta docs, the new document read/write protocols and creation-source types are 27.0 beta APIs across iOS, iPadOS, Mac Catalyst, macOS, and visionOS. DocumentGroupLaunchScene is documented for iOS, iPadOS, Mac Catalyst, and visionOS. Check the Xcode 27 beta SDK you build against before shipping this flow.
In this post, we will build the core of a small sketch and sticker board document app. The app saves its native documents as a custom package format and can export a flattened PNG or PDF.
Start with the document scene
A document-based SwiftUI app usually starts with a DocumentGroup. The WWDC26 additions let you customize the launch scene with document creation sources, so users can start from different entry points.
For our board app, we will support two creation sources:
- A blank board.
- A board that immediately asks the user to pick a background photo.
import SwiftUI
@main
struct BoardStudioApp: App {
var body: some Scene {
DocumentGroupLaunchScene("Create a Board") {
NewDocumentButton("Blank Board", source: .blankBoard)
NewDocumentButton("Board from Photo", source: .photoBoard)
}
DocumentGroup { document in
BoardEditorView(document: document)
} makeDocument: { configuration, context in
BoardDocument(configuration: configuration, context: context)
}
}
}
extension DocumentCreationSource {
static let blankBoard = Self(id: "blank-board")
static let photoBoard = Self(id: "photo-board")
}
The important part is that SwiftUI passes both a configuration and a context into your document creation path. The context tells you which DocumentCreationSource the user chose, so the new document can choose its initial state.
I would keep that context handling small:
import Observation
import SwiftUI
@Observable
@MainActor
final class BoardDocument {
var board = Board()
var shouldPresentPhotoImporter = false
init() {}
init(configuration: URLDocumentConfiguration, context: DocumentCreationContext) {
if context.creationSource == .photoBoard {
shouldPresentPhotoImporter = true
}
}
}
That initializer is intentionally light. The launch scene decides what the user asked for, but the document still owns its own editable state. The WWDC transcript and the June 2026 DocC pages disagree on a few beta labels, including older-looking names such as writableDocumentTypes and write(snapshot:...); when they differ, prefer the protocol requirements in the Xcode beta SDK you are building against.
Define a native package type
Our native format will be a package. On disk it can look like this:
Weekend.boardpkg/
manifest.json
background.png
stickers/
8B3E1F0A.png
A573B5D1.png
The manifest stores the lightweight model: canvas size, stroke points, sticker placement, transforms, and asset IDs. Binary image data lives next to it. The snapshot we create later can carry image bytes for saving and export, but the package manifest writer strips those bytes before encoding manifest.json.
import UniformTypeIdentifiers
extension UTType {
static let boardPackage = UTType(
exportedAs: "com.example.board-package",
conformingTo: .package
)
}
For a real app, also add the exported type to your app’s document type declarations so Finder, Files, and system open panels understand the extension and icon. The Swift UTType declaration is the code-side piece; the bundle metadata is the system integration piece.
Model the editable document
Keep the editable document state convenient for your views. It can be observable, reference-based, and main-actor isolated.
import CoreGraphics
import Foundation
struct Board: Equatable {
var canvasSize = CodableSize(width: 1600, height: 1200)
var backgroundPNG: Data?
var strokes: [InkStroke] = []
var stickers: [PlacedSticker] = []
func makeSnapshot() -> BoardSnapshot {
BoardSnapshot(
schemaVersion: 1,
canvasSize: canvasSize,
backgroundPNG: backgroundPNG,
strokes: strokes,
stickers: stickers
)
}
init(snapshot: BoardSnapshot) {
canvasSize = snapshot.canvasSize
backgroundPNG = snapshot.backgroundPNG
strokes = snapshot.strokes
stickers = snapshot.stickers
}
init() {}
}
struct BoardSnapshot: Codable, Equatable, Sendable {
var schemaVersion: Int
var canvasSize: CodableSize
var backgroundPNG: Data?
var strokes: [InkStroke]
var stickers: [PlacedSticker]
}
struct InkStroke: Codable, Equatable, Sendable, Identifiable {
var id: UUID
var color: RGBAColor
var width: Double
var points: [CodablePoint]
}
struct PlacedSticker: Codable, Equatable, Sendable, Identifiable {
var id: UUID
var imagePNG: Data
var position: CodablePoint
var scale: Double
var rotationRadians: Double
}
struct RGBAColor: Codable, Equatable, Sendable {
var red: Double
var green: Double
var blue: Double
var alpha: Double
}
struct CodablePoint: Codable, Equatable, Sendable {
var x: Double
var y: Double
}
struct CodableSize: Codable, Equatable, Sendable {
var width: Double
var height: Double
var cgSize: CGSize {
CGSize(width: width, height: height)
}
}
A good snapshot is boring in the best way. Prefer value types. Make it Sendable. Include only the data needed to write the requested content type. Avoid storing SwiftUI Image, view state, undo managers, selection state, or file handles in the snapshot.
Add WritableDocument
WritableDocument asks your document to declare the formats it can write, create snapshots, and provide a writer.
import SwiftUI
import UniformTypeIdentifiers
extension BoardDocument: WritableDocument {
static let writableContentTypes: [UTType] = [
.boardPackage,
.png,
.pdf
]
func snapshot(contentType: UTType) async throws -> sending BoardSnapshot {
board.makeSnapshot()
}
nonisolated func writer(
configuration: sending WriteConfiguration
) -> sending BoardWriter {
BoardWriter(contentType: configuration.contentType)
}
}
There are three pieces here.
writableContentTypes tells SwiftUI which formats this document can write. The native package is the editable format. PNG and PDF are export formats. Some WWDC26 code snippets use closely related naming while the API is settling, so check the protocol requirement in your installed SDK.
snapshot(contentType:) runs against the document state. This is where you copy main-actor state into a sendable value.
writer(configuration:) returns a writer for the requested content type. WriteConfiguration is where the writer learns whether this save is for the native package, PNG, PDF, or another supported type.
Write with DocumentWriter
The writer performs the disk work. It is intentionally separate from the observable document object.
import Foundation
import SwiftUI
import UniformTypeIdentifiers
struct BoardWriter: DocumentWriter {
typealias Snapshot = BoardSnapshot
let contentType: UTType
nonisolated func write(
content: sending BoardSnapshot,
to destination: URL,
previous: sending BoardSnapshot?,
progress: consuming Subprogress
) async throws {
let snapshot = content
if contentType.conforms(to: .boardPackage) {
try await BoardPackageStore.write(
snapshot: snapshot,
to: destination,
previous: previous,
progress: progress
)
} else if contentType.conforms(to: .png) {
let data = try BoardRenderer.pngData(from: snapshot)
try data.write(to: destination, options: .atomic)
} else if contentType.conforms(to: .pdf) {
let data = try BoardRenderer.pdfData(from: snapshot)
try data.write(to: destination, options: .atomic)
} else {
throw CocoaError(.fileWriteUnknown)
}
}
}
The WWDC session showed the writer parameter as snapshot, while the current DocC shape uses content. That is exactly the kind of beta mismatch worth checking against the protocol requirement in your local SDK.
Notice the previous snapshot. That is what makes this model especially useful for package documents. If the user moves one sticker, you do not need to rewrite every image asset. You can compare the current snapshot with the previous one and update only the changed files.
Also notice Subprogress. It belongs to the current write operation. Do not store it on your document. In real code, split it across the expensive phases of your write using the spelling in your SDK: manifest encoding, background image write, sticker asset sync, and export rendering are natural units.
Write the package incrementally
Here is a compact package writer. The helpers own serialization details; the document does not.
import Foundation
enum BoardPackageStore {
private static let manifestName = "manifest.json"
private static let backgroundName = "background.png"
private static let stickersDirectoryName = "stickers"
static func write(
snapshot: BoardSnapshot,
to destination: URL,
previous: BoardSnapshot?,
progress: consuming Subprogress
) async throws {
let fileManager = FileManager.default
try fileManager.createDirectory(
at: destination,
withIntermediateDirectories: true
)
let stickersDirectory = destination
.appendingPathComponent(stickersDirectoryName)
try fileManager.createDirectory(
at: stickersDirectory,
withIntermediateDirectories: true
)
try writeManifest(snapshot, to: destination)
try syncOptionalData(
snapshot.backgroundPNG,
previous: previous?.backgroundPNG,
to: destination.appendingPathComponent(backgroundName)
)
try syncStickerAssets(
snapshot.stickers,
previous: previous?.stickers ?? [],
in: stickersDirectory
)
// Report progress here using the Subprogress API in your SDK.
}
static func read(from source: URL) async throws -> BoardSnapshot {
let manifestURL = source.appendingPathComponent(manifestName)
let manifestData = try Data(contentsOf: manifestURL)
var snapshot = try JSONDecoder().decode(BoardSnapshot.self, from: manifestData)
let backgroundURL = source.appendingPathComponent(backgroundName)
snapshot.backgroundPNG = try? Data(contentsOf: backgroundURL)
let stickersDirectory = source.appendingPathComponent(stickersDirectoryName)
snapshot.stickers = try snapshot.stickers.map { sticker in
let imageURL = stickersDirectory
.appendingPathComponent(sticker.id.uuidString)
.appendingPathExtension("png")
var sticker = sticker
sticker.imagePNG = try Data(contentsOf: imageURL)
return sticker
}
return snapshot
}
private static func writeManifest(
_ snapshot: BoardSnapshot,
to packageURL: URL
) throws {
var manifest = snapshot
manifest.backgroundPNG = nil
manifest.stickers = manifest.stickers.map { sticker in
var sticker = sticker
sticker.imagePNG = Data()
return sticker
}
let data = try JSONEncoder().encode(manifest)
try data.write(
to: packageURL.appendingPathComponent(manifestName),
options: .atomic
)
}
private static func syncOptionalData(
_ data: Data?,
previous: Data?,
to url: URL
) throws {
let fileManager = FileManager.default
if let data {
guard data != previous || !fileManager.fileExists(atPath: url.path) else {
return
}
try data.write(to: url, options: .atomic)
} else if fileManager.fileExists(atPath: url.path) {
try fileManager.removeItem(at: url)
}
}
private static func syncStickerAssets(
_ stickers: [PlacedSticker],
previous: [PlacedSticker],
in directory: URL
) throws {
let fileManager = FileManager.default
let currentIDs = Set(stickers.map(\.id))
let previousIDs = Set(previous.map(\.id))
for removedID in previousIDs.subtracting(currentIDs) {
let url = directory
.appendingPathComponent(removedID.uuidString)
.appendingPathExtension("png")
if fileManager.fileExists(atPath: url.path) {
try fileManager.removeItem(at: url)
}
}
let previousByID = Dictionary(
uniqueKeysWithValues: previous.map { ($0.id, $0) }
)
for sticker in stickers {
let url = directory
.appendingPathComponent(sticker.id.uuidString)
.appendingPathExtension("png")
let fileExists = fileManager.fileExists(atPath: url.path)
guard sticker.imagePNG != previousByID[sticker.id]?.imagePNG || !fileExists else {
continue
}
try sticker.imagePNG.write(to: url, options: .atomic)
}
let expectedFilenames = Set(currentIDs.map { "\($0.uuidString).png" })
let existingFiles = try fileManager.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: nil
)
for fileURL in existingFiles
where fileURL.pathExtension == "png" &&
!expectedFilenames.contains(fileURL.lastPathComponent) {
try fileManager.removeItem(at: fileURL)
}
}
}
This is still a simplified example, but it shows the production shape:
- Store structured metadata separately from binary assets.
- Avoid rewriting large assets when they did not change.
- Remove stale files when assets are deleted.
- Use atomic item writes where possible.
- Keep package layout knowledge in a storage helper.
One subtle point: previous is a snapshot, not a promise that files on disk are exactly in that state. Your writer should still be defensive. If a file is missing, rewrite it. If a package directory needs to be created, create it. The previous snapshot is an optimization input, not your only source of truth.
Add ReadableDocument
Writing is only half of a document app. ReadableDocument is the companion side: it teaches SwiftUI how to load your native format and apply it to the document.
The exact reader labels are beta-sensitive, but the shape mirrors the writer side:
extension BoardDocument: ReadableDocument {
static let readableContentTypes: [UTType] = [
.boardPackage
]
func apply(
snapshot: sending BoardSnapshot,
previous: sending BoardSnapshot?
) async throws {
board = Board(snapshot: snapshot)
}
nonisolated func reader(
configuration: sending ReadConfiguration
) -> sending BoardReader {
BoardReader(contentType: configuration.contentType)
}
}
struct BoardReader: DocumentReader {
typealias Snapshot = BoardSnapshot
let contentType: UTType
nonisolated func read(
from source: URL,
progress: consuming Subprogress
) async throws -> sending BoardSnapshot {
guard contentType.conforms(to: .boardPackage) else {
throw CocoaError(.fileReadUnknown)
}
return try await BoardPackageStore.read(from: source)
}
}
The same separation applies:
- The reader knows about disk.
- The document knows how to apply a loaded snapshot to editable state.
- The UI observes the document, not the package directory.
That keeps your editor testable. You can test BoardPackageStore with temporary directories, test Board(snapshot:) as pure model code, and test the SwiftUI editor without touching disk.
Render PNG and PDF exports
The WWDC26 example adds .png to the writable formats and branches in the writer with contentType.conforms(to:). PDF can follow the same writer-routing pattern if your app advertises and implements that export type.
Keep export rendering separate from package serialization:
import CoreGraphics
import Foundation
import UniformTypeIdentifiers
enum BoardRenderer {
static func pngData(from snapshot: BoardSnapshot) throws -> Data {
// Render the background, strokes, and stickers into a bitmap.
// On iOS and iPadOS, this helper might use UIGraphicsImageRenderer.
// On macOS, it might use CGContext, CGImageDestination, or NSImage.
throw CocoaError(.featureUnsupported)
}
static func pdfData(from snapshot: BoardSnapshot) throws -> Data {
let pageRect = CGRect(
origin: .zero,
size: snapshot.canvasSize.cgSize
)
let data = NSMutableData()
guard let consumer = CGDataConsumer(data: data),
let context = CGContext(consumer: consumer, mediaBox: nil, nil) else {
throw CocoaError(.fileWriteUnknown)
}
context.beginPDFPage([
kCGPDFContextMediaBox as String: pageRect
] as CFDictionary)
draw(snapshot, in: context)
context.endPDFPage()
context.closePDF()
return data as Data
}
private static func draw(_ snapshot: BoardSnapshot, in context: CGContext) {
// Draw background image data, strokes, and sticker transforms here.
// This function owns rendering decisions such as scale,
// interpolation, color conversion, and editor-only overlays.
}
}
For production exports, make a few choices explicit:
- Does PNG export at document points, display scale, or a user-selected pixel size?
- Does PDF preserve vector strokes, or does it embed a flattened bitmap?
- Are transparent backgrounds allowed?
- Should hidden layers, selections, guides, or crop marks appear?
- How do you handle wide-gamut colors?
The writer should just route the requested type. The renderer should own those export policies.
Why snapshots help
The snapshot model has three practical benefits.
First, it gives SwiftUI a clear boundary between UI state and disk work. Your document can remain main-actor isolated and observable. The writer can be nonisolated and async.
Second, it makes package saves cheaper. If the new snapshot and previous snapshot agree that the background image did not change, the writer can skip that file. If one sticker image changed, rewrite one asset.
Third, it makes exports feel like first-class writes. PNG and PDF can reuse the document writer path, though you still need UI and product policy for choosing export type, size, destination, and share or save behavior. Add the exported UTType, branch in the writer, and render from the same snapshot used by native saves.
The tradeoff is that you need to design the snapshot carefully. If you put too much in it, every save gets heavy. If you put too little in it, your writer needs to reach back into live document state, which defeats the model.
Migration from FileDocument
If you already have a FileDocument, the new model will feel familiar but more separated.
A typical FileDocument has:
struct OldBoardDocument: FileDocument {
static var readableContentTypes: [UTType] { [.boardPackage] }
init(configuration: ReadConfiguration) throws {
// Read from configuration.file.
}
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
// Build the whole file or package here.
}
}
With WritableDocument, move in stages:
- Keep your
UTTypedeclarations. - Extract a
BoardSnapshotfrom your existing model. - Move the old
fileWrapper(configuration:)logic into aDocumentWriter. - Replace whole-package rewrites with URL-based package writes where it is worth it.
- Use
previoussnapshots to skip unchanged assets. - Move read logic into the
ReadableDocumentside and a reader/storage helper. - Add export types, like
.pngand.pdf, only after the native format is solid.
You do not need to migrate every document app immediately. If your app saves one small JSON file and the current FileDocument code is simple, it may be fine. The new APIs are most compelling when documents are large, package-based, export-heavy, or expensive to serialize from live UI state.
Practical checklist
Before shipping a document format built this way, check the boring details. They are where document apps earn trust.
Make the snapshot Sendable and keep it independent of SwiftUI views.
Version your manifest from day one. Even schemaVersion: 1 is enough to give future you a place to stand.
Use stable asset IDs. Do not derive filenames from display names.
Write package items atomically where possible. If a large export can fail, fail before replacing a good file.
Treat previous as an optimization, not as proof.
Test round trips: document state to snapshot, snapshot to package, package to snapshot, snapshot back to document state.
Test exports from loaded files, not only from freshly created documents.
Keep format readers tolerant and writers strict. Readers often need to open old files; writers should produce the clean current format.
Closing thought
WritableDocument is not just a new way to spell FileDocument. It is a better boundary.
Your SwiftUI document stays focused on editing. A snapshot captures the state worth saving. A writer handles disk, diffs, progress, package layout, and export formats. That separation makes document apps easier to reason about, especially as they grow from “save a file” into “manage a real user-owned document.”
Further reading: