Handling advanced document scenarios
Extend your document-based app to support custom file formats, on-demand file access, and progress reporting.
Overview
After you’ve created a working document-based app, you can extend it to handle scenarios that go beyond basic reading and writing. Support file formats that the system doesn’t know about by default by using a custom UTType. Access files outside the normal read and write lifecycle using makeFileCoordinator(), for example, to open a specific subfile in a package on demand. Add progress reporting to give people feedback during long operations.
Declare a custom file format
The system knows about popular built-in formats and their identifiers, like utf8PlainText, jpeg, and markdown. For your own file formats, declare a custom UTType so the Finder, the document browser, and Spotlight understand them.
Every custom type needs a base type that tells the system what kind of file it is. Use public.data or conforming types as a base type for single-file documents, or com.apple.package for documents the system stores as a directory but presents as a single file.
Declare the content type in your app target’s information property list under UTExportedTypeDeclarations so the Finder, the document browser, and Spotlight all recognize it. Set UTTypeConformsTo to the matching base type and add a UTTypeTagSpecification that maps the type to your filename extension. The example below declares a notebook package; for a flat-file format, swap com.apple.package for public.data, like this:
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>com.example.notebook</string>
<key>UTTypeConformsTo</key>
<array>
<string>com.apple.package</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>examplenotebook</string>
</array>
</dict>
</dict>
</array>For convenience, mirror the declaration in code, as shown here:
extension UTType {
static let notebook = UTType(exportedAs: "com.example.notebook")
}Then reference your type from the document’s readableContentTypes and writableContentTypes, like this:
static let readableContentTypes: [UTType] = [.notebook]
static let writableContentTypes: [UTType] = [.notebook, .utf8PlainText]For more about declaring uniform type identifiers for proprietary formats, see Defining file and data types for your app.
Access files outside read and write
SwiftUI coordinates file access for read and write calls automatically. To access the file URL at other times — for example, when reading a specific subfile in a package — use the configuration’s file coordinator, as shown here:
let coordinator = document.configuration.makeFileCoordinator()
coordinator.coordinate(
readingItemAt: packageURL.appending(path: "metadata.json"),
options: []) { url in
do {
let data = try Data(contentsOf: url)
let metadata = try JSONDecoder().decode(NotebookMetadata.self, from: data)
// Process the metadata.
} catch {
// Handle errors.
}
}Report progress
Packages let you read and write incrementally; instead of loading or saving the whole document on every change, you can read only the files you need and write only the ones that changed. For notebook documents that consist of multiple files, custom image formats that store data in separate files, or projects with embedded media, this can mean the difference between a fast autosave and a slow one.
Both DocumentReader and DocumentWriter receive a Subprogress parameter via their read and write methods, respectively. Report progress through the parameter so SwiftUI can display the appropriate UI during long operations.
Create a ProgressReporter from the Subprogress by specifying a total unit count. Then call complete(count:) as the work finishes, like this:
@concurrent
func read(from source: URL, progress: consuming Subprogress) async throws -> sending ImageSnapshot {
let progressManager = progress.start(totalCount: 2)
let data = try Data(contentsOf: source)
progressManager.complete(count: 1)
let image = try decodeImage(from: data)
progressManager.complete(count: 1)
return ImageSnapshot(image: image)
}For package documents, you can treat each file as an equal chunk of work, as shown below. This approach gives granular feedback as the writer processes files one by one.
@concurrent
func write(
snapshot: sending NotebookSnapshot, to destination: URL,
previous: sending NotebookSnapshot?, progress: consuming Subprogress
) async throws {
let changedPages = snapshot.pages.filter { (identifier, content) in
previous?.pages[identifier] != content
}
// One unit for metadata, and one unit per changed page.
let totalUnits = 1 + changedPages.count
let progressManager = progress.start(totalCount: totalUnits)
// Write the metadata.
let metadataURL = destination.appending(path: "metadata.json")
let metadataData = try JSONEncoder().encode(snapshot.metadata)
try metadataData.write(to: metadataURL, options: .atomic)
progressManager.complete(count: 1)
// Write each changed page individually.
let fileManager = FileManager.default
let pagesDirectory = destination.appending(path: "pages")
// Create the pages subdirectory if it doesn't already exist.
try? fileManager.createDirectory(
at: pagesDirectory, withIntermediateDirectories: true
)
for (identifier, content) in changedPages {
let pageURL = pagesDirectory.appending(
path: "\(identifier.uuidString).txt"
)
let data = Data(content.utf8)
try data.write(to: pageURL, options: .atomic)
progressManager.complete(count: 1)
}
}If you use FileHandle or OutputStream to write files in chunks — for example, a large media file — report progress after each chunk rather than only once at the end. Each call to complete(count:) moves the progress bar forward, so spread the calls throughout the write.
The example below writes a large media file to disk in chunks. Set the total unit count to the file size in bytes, and call the reporter after each chunk so the progress bar updates smoothly.
@concurrent
func write(
snapshot: sending MediaSnapshot, to destination: URL,
previous: sending MediaSnapshot?, progress: consuming Subprogress
) async throws {
let payload = snapshot.payload
let totalBytes = payload.count
let progressManager = progress.start(totalCount: totalBytes)
try Data().write(to: destination)
let fileHandle = try FileHandle(forWritingTo: destination)
defer { try? fileHandle.close() }
// Aim for ~100 progress updates across the write, clamped so chunks
// stay large enough to amortize system call overhead and small enough
// to keep the progress bar moving on tiny payloads.
let targetUpdateCount = 100
let minimumChunkSize = 64 * 1024 // 64 KB
let maximumChunkSize = 4 * 1024 * 1024 // 4 MB
let chunkSize = min(
maximumChunkSize,
max(minimumChunkSize, totalBytes / targetUpdateCount)
)
var offset = 0
while offset < totalBytes {
let end = min(offset + chunkSize, totalBytes)
let chunk = payload[offset..<end]
try fileHandle.write(contentsOf: chunk)
progressManager.complete(count: end - offset)
offset = end
}
}The chunk size adapts to the file size, so large files don’t generate unnecessary system overhead. If you stream to an OutputStream instead, the same pattern applies: Open the stream, write chunks of the computed size, call complete(count:) with the bytes written, and call ProgresManager.complete(count:) when finished.