12 KiB
Document-Based Apps
Apps where users create, open, and save discrete files (like TextEdit, Pages, Xcode).
<when_to_use> Use document-based architecture when:
- Users explicitly create/open/save files
- Multiple documents open simultaneously
- Files shared with other apps
- Standard document behaviors expected (Recent Documents, autosave, versions)
Do NOT use when:
- Single internal database (use shoebox pattern)
- No user-facing files </when_to_use>
<swiftui_document_group> <basic_setup>
import SwiftUI
import UniformTypeIdentifiers
@main
struct MyDocumentApp: App {
var body: some Scene {
DocumentGroup(newDocument: MyDocument()) { file in
DocumentView(document: file.$document)
}
.commands {
DocumentCommands()
}
}
}
struct MyDocument: FileDocument {
// Supported types
static var readableContentTypes: [UTType] { [.myDocument] }
static var writableContentTypes: [UTType] { [.myDocument] }
// Document data
var content: DocumentContent
// New document
init() {
content = DocumentContent()
}
// Load from file
init(configuration: ReadConfiguration) throws {
guard let data = configuration.file.regularFileContents else {
throw CocoaError(.fileReadCorruptFile)
}
content = try JSONDecoder().decode(DocumentContent.self, from: data)
}
// Save to file
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
let data = try JSONEncoder().encode(content)
return FileWrapper(regularFileWithContents: data)
}
}
// Custom UTType
extension UTType {
static var myDocument: UTType {
UTType(exportedAs: "com.yourcompany.myapp.document")
}
}
</basic_setup>
<document_view>
struct DocumentView: View {
@Binding var document: MyDocument
@FocusedBinding(\.document) private var focusedDocument
var body: some View {
TextEditor(text: $document.content.text)
.focusedSceneValue(\.document, $document)
}
}
// Focused values for commands
struct DocumentFocusedValueKey: FocusedValueKey {
typealias Value = Binding<MyDocument>
}
extension FocusedValues {
var document: Binding<MyDocument>? {
get { self[DocumentFocusedValueKey.self] }
set { self[DocumentFocusedValueKey.self] = newValue }
}
}
</document_view>
<document_commands>
struct DocumentCommands: Commands {
@FocusedBinding(\.document) private var document
var body: some Commands {
CommandMenu("Format") {
Button("Bold") {
document?.wrappedValue.content.toggleBold()
}
.keyboardShortcut("b", modifiers: .command)
.disabled(document == nil)
Button("Italic") {
document?.wrappedValue.content.toggleItalic()
}
.keyboardShortcut("i", modifiers: .command)
.disabled(document == nil)
}
}
}
</document_commands>
<reference_file_document> For documents referencing external files:
struct ProjectDocument: ReferenceFileDocument {
static var readableContentTypes: [UTType] { [.myProject] }
var project: Project
init() {
project = Project()
}
init(configuration: ReadConfiguration) throws {
guard let data = configuration.file.regularFileContents else {
throw CocoaError(.fileReadCorruptFile)
}
project = try JSONDecoder().decode(Project.self, from: data)
}
func snapshot(contentType: UTType) throws -> Project {
project
}
func fileWrapper(snapshot: Project, configuration: WriteConfiguration) throws -> FileWrapper {
let data = try JSONEncoder().encode(snapshot)
return FileWrapper(regularFileWithContents: data)
}
}
</reference_file_document> </swiftui_document_group>
<info_plist_document_types> Configure document types in Info.plist:
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>My Document</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Owner</string>
<key>LSItemContentTypes</key>
<array>
<string>com.yourcompany.myapp.document</string>
</array>
</dict>
</array>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>com.yourcompany.myapp.document</string>
<key>UTTypeDescription</key>
<string>My Document</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
<string>public.content</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>mydoc</string>
</array>
</dict>
</dict>
</array>
</info_plist_document_types>
<nsdocument_appkit> For more control, use NSDocument:
<nsdocument_subclass>
import AppKit
class Document: NSDocument {
var content = DocumentContent()
override class var autosavesInPlace: Bool { true }
override func makeWindowControllers() {
let contentView = DocumentView(document: self)
let hostingController = NSHostingController(rootView: contentView)
let window = NSWindow(contentViewController: hostingController)
window.setContentSize(NSSize(width: 800, height: 600))
window.styleMask = [.titled, .closable, .miniaturizable, .resizable]
let windowController = NSWindowController(window: window)
addWindowController(windowController)
}
override func data(ofType typeName: String) throws -> Data {
try JSONEncoder().encode(content)
}
override func read(from data: Data, ofType typeName: String) throws {
content = try JSONDecoder().decode(DocumentContent.self, from: data)
}
}
</nsdocument_subclass>
<undo_support>
class Document: NSDocument {
var content = DocumentContent() {
didSet {
updateChangeCount(.changeDone)
}
}
func updateContent(_ newContent: DocumentContent) {
let oldContent = content
undoManager?.registerUndo(withTarget: self) { document in
document.updateContent(oldContent)
}
undoManager?.setActionName("Update Content")
content = newContent
}
}
</undo_support>
<nsdocument_lifecycle>
class Document: NSDocument {
// Called when document is first opened
override func windowControllerDidLoadNib(_ windowController: NSWindowController) {
super.windowControllerDidLoadNib(windowController)
// Setup UI
}
// Called before saving
override func prepareSavePanel(_ savePanel: NSSavePanel) -> Bool {
savePanel.allowedContentTypes = [.myDocument]
savePanel.allowsOtherFileTypes = false
return true
}
// Called after saving
override func save(to url: URL, ofType typeName: String, for saveOperation: NSDocument.SaveOperationType, completionHandler: @escaping (Error?) -> Void) {
super.save(to: url, ofType: typeName, for: saveOperation) { error in
if error == nil {
// Post-save actions
}
completionHandler(error)
}
}
// Handle close with unsaved changes
override func canClose(withDelegate delegate: Any, shouldClose shouldCloseSelector: Selector?, contextInfo: UnsafeMutableRawPointer?) {
// Custom save confirmation
super.canClose(withDelegate: delegate, shouldClose: shouldCloseSelector, contextInfo: contextInfo)
}
}
</nsdocument_lifecycle> </nsdocument_appkit>
<package_documents> For documents containing multiple files (like .pages):
struct PackageDocument: FileDocument {
static var readableContentTypes: [UTType] { [.myPackage] }
var mainContent: MainContent
var assets: [String: Data]
init(configuration: ReadConfiguration) throws {
guard let directory = configuration.file.fileWrappers else {
throw CocoaError(.fileReadCorruptFile)
}
// Read main content
guard let mainData = directory["content.json"]?.regularFileContents else {
throw CocoaError(.fileReadCorruptFile)
}
mainContent = try JSONDecoder().decode(MainContent.self, from: mainData)
// Read assets
assets = [:]
if let assetsDir = directory["Assets"]?.fileWrappers {
for (name, wrapper) in assetsDir {
if let data = wrapper.regularFileContents {
assets[name] = data
}
}
}
}
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
let directory = FileWrapper(directoryWithFileWrappers: [:])
// Write main content
let mainData = try JSONEncoder().encode(mainContent)
directory.addRegularFile(withContents: mainData, preferredFilename: "content.json")
// Write assets
let assetsDir = FileWrapper(directoryWithFileWrappers: [:])
for (name, data) in assets {
assetsDir.addRegularFile(withContents: data, preferredFilename: name)
}
directory.addFileWrapper(assetsDir)
assetsDir.preferredFilename = "Assets"
return directory
}
}
// UTType for package
extension UTType {
static var myPackage: UTType {
UTType(exportedAs: "com.yourcompany.myapp.package", conformingTo: .package)
}
}
</package_documents>
<recent_documents>
// NSDocumentController manages Recent Documents automatically
// Custom recent documents menu
struct AppCommands: Commands {
var body: some Commands {
CommandGroup(after: .newItem) {
Menu("Open Recent") {
ForEach(recentDocuments, id: \.self) { url in
Button(url.lastPathComponent) {
NSDocumentController.shared.openDocument(
withContentsOf: url,
display: true
) { _, _, _ in }
}
}
if !recentDocuments.isEmpty {
Divider()
Button("Clear Menu") {
NSDocumentController.shared.clearRecentDocuments(nil)
}
}
}
}
}
var recentDocuments: [URL] {
NSDocumentController.shared.recentDocumentURLs
}
}
</recent_documents>
<export_import>
struct DocumentView: View {
@Binding var document: MyDocument
@State private var showingExporter = false
@State private var showingImporter = false
var body: some View {
MainContent(document: $document)
.toolbar {
Button("Export") { showingExporter = true }
Button("Import") { showingImporter = true }
}
.fileExporter(
isPresented: $showingExporter,
document: document,
contentType: .pdf,
defaultFilename: "Export"
) { result in
switch result {
case .success(let url):
print("Exported to \(url)")
case .failure(let error):
print("Export failed: \(error)")
}
}
.fileImporter(
isPresented: $showingImporter,
allowedContentTypes: [.plainText, .json],
allowsMultipleSelection: false
) { result in
switch result {
case .success(let urls):
importFile(urls.first!)
case .failure(let error):
print("Import failed: \(error)")
}
}
}
}
// Export to different format
extension MyDocument {
func exportAsPDF() -> Data {
// Generate PDF from content
let renderer = ImageRenderer(content: ContentPreview(content: content))
return renderer.render { size, render in
var box = CGRect(origin: .zero, size: size)
guard let context = CGContext(consumer: CGDataConsumer(data: NSMutableData() as CFMutableData)!, mediaBox: &box, nil) else { return }
context.beginPDFPage(nil)
render(context)
context.endPDFPage()
context.closePDF()
} ?? Data()
}
}
</export_import>