You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

SwiftUI macOS文档应用:如何自定义默认保存对话框?

在macOS SwiftUI文档应用中自定义保存对话框

要给保存对话框添加标签输入框,确实需要手动创建NSSavePanel并自定义保存流程——因为SwiftUI的DocumentGroup默认提供的保存对话框没有直接API支持添加自定义控件。下面提供两种可行的实现方案:

方案一:基于NSDocument集成系统保存流程

这种方案通过继承NSDocument(macOS原生文档类)重写保存逻辑,能更好地适配系统默认的菜单(比如Cmd+S)和保存流程:

import SwiftUI
import AppKit

class CustomDocument: NSDocument, FileDocument {
    static var readableContentTypes: [UTType] { [.plainText] }
    
    var content: String = ""
    var tags: String = "" // 存储标签的属性
    
    init() {
        super.init()
    }
    
    required init(configuration: ReadConfiguration) throws {
        guard let data = configuration.file.regularFileContents,
              let string = String(data: data, encoding: .utf8) else {
            throw CocoaError(.fileReadCorruptFile)
        }
        content = string
        // 从文件扩展属性读取已保存的标签
        if let tagData = try configuration.file.resourceValues(forKeys: [.extendedAttributesKey]).extendedAttributes?["com.yourapp.tags"],
           let tagString = String(data: tagData, encoding: .utf8) {
            tags = tagString
        }
    }
    
    func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
        let data = content.data(using: .utf8)!
        let fileWrapper = FileWrapper(regularFileWithContents: data)
        // 将标签写入文件扩展属性
        if !tags.isEmpty {
            try fileWrapper.setExtendedAttribute(data: tags.data(using: .utf8)!, name: "com.yourapp.tags")
        }
        return fileWrapper
    }
    
    override func save(_ sender: Any?) {
        let savePanel = NSSavePanel()
        savePanel.allowedContentTypes = [.plainText]
        savePanel.title = "保存文档"
        
        // 创建自定义标签输入控件
        let tagLabel = NSTextField(labelWithString: "标签:")
        let tagTextField = NSTextField(frame: NSRect(x: 0, y: 0, width: 300, height: 22))
        tagTextField.stringValue = tags
        tagTextField.placeholderString = "输入标签"
        
        // 组装辅助视图并设置布局
        let accessoryView = NSView(frame: NSRect(x: 0, y: 0, width: 300, height: 40))
        accessoryView.addSubview(tagLabel)
        accessoryView.addSubview(tagTextField)
        
        tagLabel.translatesAutoresizingMaskIntoConstraints = false
        tagTextField.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            tagLabel.topAnchor.constraint(equalTo: accessoryView.topAnchor, constant: 8),
            tagLabel.leadingAnchor.constraint(equalTo: accessoryView.leadingAnchor, constant: 8),
            tagTextField.topAnchor.constraint(equalTo: tagLabel.bottomAnchor, constant: 4),
            tagTextField.leadingAnchor.constraint(equalTo: accessoryView.leadingAnchor, constant: 8),
            tagTextField.trailingAnchor.constraint(equalTo: accessoryView.trailingAnchor, constant: -8),
            accessoryView.heightAnchor.constraint(equalToConstant: 40)
        ])
        
        savePanel.accessoryView = accessoryView
        
        // 弹出对话框并处理保存逻辑
        savePanel.beginSheetModal(for: NSApp.keyWindow!) { [weak self] response in
            guard let self = self, response == .OK else { return }
            guard let url = savePanel.url else { return }
            
            self.tags = tagTextField.stringValue
            self.save(to: url, ofType: savePanel.allowedContentTypes.first!.identifier.rawValue, completionHandler: { error in
                if let error = error {
                    NSAlert(error: error).runModal()
                }
            })
        }
    }
}

@main
struct CustomDocumentApp: App {
    var body: some Scene {
        DocumentGroup(newDocument: CustomDocument()) { file in
            TextEditor(text: file.$content)
        }
    }
}

关键说明

  • 继承NSDocument后,重写save(_:)方法替换默认保存对话框,通过accessoryView添加自定义标签输入框。
  • 标签数据可以存储在文件的扩展属性中,不会污染文档本身的内容;也可根据需求嵌入到文档内容里(比如用JSON格式存储内容和标签)。
  • 该方案会自动适配系统的保存菜单和快捷键,无需额外处理。

方案二:纯SwiftUI FileDocument手动处理保存

如果想保持纯FileDocument的跨平台特性,可以手动添加保存按钮并自定义保存流程,但需要自己处理系统菜单的替换:

import SwiftUI
import AppKit

struct CustomFileDocument: FileDocument {
    static var readableContentTypes: [UTType] { [.plainText] }
    
    var content: String
    var tags: String
    
    init(content: String = "", tags: String = "") {
        self.content = content
        self.tags = tags
    }
    
    init(configuration: ReadConfiguration) throws {
        guard let data = configuration.file.regularFileContents,
              let string = String(data: data, encoding: .utf8) else {
            throw CocoaError(.fileReadCorruptFile)
        }
        // 读取文件扩展属性中的标签
        if let tagData = try configuration.file.resourceValues(forKeys: [.extendedAttributesKey]).extendedAttributes?["com.yourapp.tags"],
           let tagString = String(data: tagData, encoding: .utf8) {
            tags = tagString
        } else {
            tags = ""
        }
        content = string
    }
    
    func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
        let data = content.data(using: .utf8)!
        let fileWrapper = FileWrapper(regularFileWithContents: data)
        // 写入标签到扩展属性
        if !tags.isEmpty {
            try fileWrapper.setExtendedAttribute(data: tags.data(using: .utf8)!, name: "com.yourapp.tags")
        }
        return fileWrapper
    }
}

struct ContentView: View {
    @Binding var document: CustomFileDocument
    @Environment(\.dismissWindow) private var dismissWindow
    
    var body: some View {
        VStack {
            TextEditor(text: $document.content)
            TextField("标签", text: $document.tags)
                .padding(.horizontal)
            Button("保存文档") {
                saveDocument()
            }
            .padding()
        }
        .toolbar {
            ToolbarItem(placement: .primaryAction) {
                Button("保存") {
                    saveDocument()
                }
            }
        }
        // 替换系统默认的保存命令
        .commands {
            CommandGroup(replacing: .saveItem) {
                Button("保存") {
                    saveDocument()
                }
                .keyboardShortcut("s")
            }
        }
    }
    
    private func saveDocument() {
        let savePanel = NSSavePanel()
        savePanel.allowedContentTypes = [.plainText]
        savePanel.title = "保存文档"
        
        savePanel.beginSheetModal(for: NSApp.keyWindow!) { response in
            guard response == .OK, let url = savePanel.url else { return }
            
            do {
                try document.write(to: url, contentType: .plainText)
                dismissWindow()
            } catch {
                NSAlert(error: error).runModal()
            }
        }
    }
}

@main
struct CustomDocumentApp: App {
    var body: some Scene {
        DocumentGroup(newDocument: CustomFileDocument()) { file in
            ContentView(document: file.$document)
        }
    }
}

关键说明

  • 完全基于FileDocument实现,保留跨平台潜力,但需要手动添加保存按钮和替换系统默认的保存命令。
  • 标签的存储逻辑和方案一一致,可选择扩展属性或嵌入文档内容。
  • 需要通过CommandGroup替换系统默认的保存菜单,确保Cmd+S快捷键触发自定义保存逻辑。

内容的提问来源于stack exchange,提问作者sam0701

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.15 08:28:11