SwiftUIでファイルを開く/保存(macOS)

# 概要
- AppKitではファイルを開く/保存する際に`NSOpenPanel/NSSavePanel`を使用していましたが、SwiftUIでは`fileImporter/fileExporter`が利用できます。

<img width="600" alt="image" src="https://i.imgur.com/baAKPve.png">

# 参考
- [SwiftUI macOSアプリでファイルを開く](https://genjiapp.com/blog/2021/09/06/swiftui-open-file.html)
- [How to export files using fileExporter\(\)](https://www.hackingwithswift.com/quick-start/swiftui/how-to-export-files-using-fileexporter)
- [fileExporter\(isPresented:document:contentType:defaultFilename:onCompletion:\)](https://developer.apple.com/documentation/swiftui/view/fileexporter(ispresented:document:contenttype:defaultfilename:oncompletion:)-32vwk)
- [fileImporter\(isPresented:allowedContentTypes:allowsMultipleSelection:onCompletion:\)](https://developer.apple.com/documentation/swiftui/menu/fileimporter(ispresented:allowedcontenttypes:allowsmultipleselection:oncompletion:))
- `fileImporter`でデフォルトのディレクトリ指定ができないなど、[NSOpenPanel](https://developer.apple.com/documentation/appkit/nsopenpanel)と比べて色々とパラメータが少ないように思います。細かい設定をしたい場合はそちらを使うのがいいかもしれません。

# 実装

- まずファイルのアクセス権が必要となるので、Sandboxの`User Selected File`を`Read/Write`に設定します。

<img width="800" alt="image" src="https://i.imgur.com/qdrOQYW.png">

- また保存するファイルの形式に合わせて、[FileDocument](https://developer.apple.com/documentation/swiftui/filedocument)のサブクラスを作る必要があります。

```swift
import SwiftUI
import UniformTypeIdentifiers

/// refs: https://www.hackingwithswift.com/quick-start/swiftui/how-to-export-files-using-fileexporter
struct TextFile: FileDocument {
    // tell the system we support only plain text
    static var readableContentTypes = [UTType.plainText]

    // by default our document is empty
    var text = ""

    // a simple initializer that creates new, empty documents
    init(initialText: String = "") {
        text = initialText
    }

    // this initializer loads data that has been saved previously
    init(configuration: ReadConfiguration) throws {
        if let data = configuration.file.regularFileContents {
            text = String(decoding: data, as: UTF8.self)
        }
    }

    // this will be called when the system wants to write our data to disk
    func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
        let data = Data(text.utf8)
        return FileWrapper(regularFileWithContents: data)
    }
}
```

- 呼び出し例は以下の通りとなります。

```swift
import SwiftUI

struct ContentView: View {
    
    @State private var text = ""
    @State private var isSaveButtonDisable = true
    @State private var exporterPresented = false
    @State private var importerPresented = false
    
    var body: some View {
        
        VStack {
            textField()
            HStack {
                Spacer()
                saveButton()
                openButton()
            }
        }
        .padding()
        .fileExporter(isPresented: $exporterPresented,
                      document: TextFile(initialText: text),
                      contentType: .plainText,
                      defaultFilename: "Untitled.txt",
                      onCompletion: { result in
            switch result {
            case .success(let url):
                print("\(url)に保存が完了しました！")
            case .failure(let error):
                print(error.localizedDescription)
            }
        })
        .fileImporter(isPresented: $importerPresented,
                      allowedContentTypes: [.plainText],
                      allowsMultipleSelection: false) { result in
            switch result {
            case .success(let urls):
                guard let url = urls.first else { return }
                do {
                    let contents = try String(contentsOf: url)
                    text = contents
                } catch {
                    print(error.localizedDescription)
                }
            case .failure(let error):
                print(error.localizedDescription)
            }
        }
    }
}
```

```swift
// MARK: - ViewBuilder

extension ContentView {
    
    @ViewBuilder
    private func textField() -> some View {
        TextField("Enter here!", text: $text, axis: .vertical)
            .lineLimit(3, reservesSpace: true)
            .textFieldStyle(.roundedBorder)
            .onChange(of: text) { newValue in
                isSaveButtonDisable = newValue.isEmpty
            }
    }
    
    @ViewBuilder
    private func saveButton() -> some View {
        Button {
            exporterPresented.toggle()
        } label: {
            Text("Save...")
        }
        .disabled(isSaveButtonDisable)
    }
    
    @ViewBuilder
    private func openButton() -> some View {
        Button {
            importerPresented.toggle()
        } label: {
            Text("Open...")
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

```