# Document-Based Apps Apps where users create, open, and save discrete files (like TextEdit, Pages, Xcode). 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 ```swift 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") } } ``` ```swift 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 } extension FocusedValues { var document: Binding? { get { self[DocumentFocusedValueKey.self] } set { self[DocumentFocusedValueKey.self] = newValue } } } ``` ```swift 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) } } } ``` For documents referencing external files: ```swift 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) } } ``` Configure document types in Info.plist: ```xml CFBundleDocumentTypes CFBundleTypeName My Document CFBundleTypeRole Editor LSHandlerRank Owner LSItemContentTypes com.yourcompany.myapp.document UTExportedTypeDeclarations UTTypeIdentifier com.yourcompany.myapp.document UTTypeDescription My Document UTTypeConformsTo public.data public.content UTTypeTagSpecification public.filename-extension mydoc ``` For more control, use NSDocument: ```swift 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) } } ``` ```swift 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 } } ``` ```swift 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) } } ``` For documents containing multiple files (like .pages): ```swift 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) } } ``` ```swift // 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 } } ``` ```swift 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() } } ```