🦋

SwiftUI: fileExporterの罠と実装パターン

2024/05/30に公開

fileExporterを用いるとアプリ外のローカルにファイルを出力し保存することができます。ただし、様々なケースに対応するためにAPIがたくさん用意されていたり、それぞれの使い方についてが公式リファレンスを読んでもわからなかったりするので、学んだことをまとめます。

おそらくこれは不具合ですが、fileExporterを同じViewに対して2度使った場合、後勝ちになって1度目のfileExporterは機能しません。

struct ContentView: View {
    @State var showingImageFileExporter = false
    @State var showingVideoFileExporter = false

    var body: some View {
        VStack {
            Button("Save Image") {
                showingImageFileExporter = true
            }
            Button("Save Video") {
                showingVideoFileExporter = true
            }
        }
        .fileExporter(isPresented: $showingImageFileExporter,
                      document: ImageFile(),
                      onCompletion: { _ in })
        .fileExporter(isPresented: $showingVideoFileExporter,
                      document: VideoFile(),
                      onCompletion: { _ in })
    }
}

struct ImageFile: FileDocument { /* 省略 */ }
struct VideoFile: FileDocument { /* 省略 */ }

そのため、fileExporterを複数使いたい場合はその画面内の上書きされない独立したView要素にModifierをつける必要があります。上の例の場合はButtonfileExporterを1対1にすれば良さそうですが、つけるべきView要素がない場合は見えないViewを用意してModifierをつけたりする工夫が必要です。

実装パターン

fileExporterには大きく分けると2つのAPIがあります。FileDocument protocolに準拠したstructでファイルの出力をハンドリングするAPIとTransferable protocolに準拠したstructでファイルの出力をハンドリングするAPIです。また、それぞれに1つのファイルを出力するためのAPIと複数のファイルを出力するためのAPIがあります。

FileDocumentを使う

主にサイズの小さめなファイルを出力するために使う方法です。documentdocumentsを引数にとるAPIを使います。最終的にはFileWrapperというオブジェクトにしてファイルを出力することになります。ファイルをDataに変換してFileWrapperで包む方法と、キャッシュ領域に保存したファイルのURLをFileWrapperに渡す方法があります。

ファイルの元となるオブジェクトを直接渡してもいいし、必要になったタイミングでDataやURLを渡すようにクロージャーを持たせておくのでも良いです。

直接ファイルをDataに変換するパターン
struct ImageFile: FileDocument {
    static var readableContentTypes: [UTType] = [.png]

    let image: CGImage

    init(image: CGImage) {
        self.image = image
    }

    init(configuration: ReadConfiguration) throws {
        fatalError("Not Implemented")
    }

    func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
        let rep = NSBitmapImageRep(cgImage: image)
        guard let data = rep.representation(using: .png, properties: [:]) else {
            throw CocoaError(.fileWriteUnknown)
        }
        return FileWrapper(regularFileWithContents: data)
    }
}
クロージャーでURLを渡すパターン
struct ImageFile: FileDocument {
    static var readableContentTypes: [UTType] = [.png]

    let exportHandler: () throws -> URL

    init(exportHandler: @escaping () throws -> URL) {
        self.exportHandler = exportHandler
    }

    init(configuration: ReadConfiguration) throws {
        fatalError("Not Implemented")
    }

    func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
        let fileURL = try exportHandler()
        return try FileWrapper(url: fileURL, options: .immediate)
    }
}

Transferableを使う

主にサイズの大きめなファイル(動画とか)を出力するために使う方法です。itemitemsを引数にとるAPIを使います。あらかじめキャッシュ領域にファイルを保存しておき、そのファイルURLを渡してユーザーの指定した場所に移動させるというような使い方をするようです。最終的にはTransferRepresentationの形でファイルを出力することになります。

キャッシュファイルのURLを直接渡してもいいし、必要になったタイミングで渡すようにクロージャーを持たせておくのでも良いです。

URLを直接渡すパターン
struct ImageFile: Transferable {
    let url: URL

    static var transferRepresentation: some TransferRepresentation {
        FileRepresentation(exportedContentType: .png) { imageFile in
            SentTransferredFile(imageFile.url)
        }
    }
}
クロージャーでURLを渡すパターン
struct ImageFile: Transferable {
    let exportHandler: () throws -> URL

    static var transferRepresentation: some TransferRepresentation {
        FileRepresentation(exportedContentType: .png) { imageFile in
            let fileURL = try imageFile.exportHandler()
            return SentTransferredFile(fileURL)
        }
    }
}

ここで、キャッシュ領域にファイルを保存しておくというのはどうやるのかというと、

FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first

でアプリがSandBox環境で使えるキャッシュ領域のディレクトリパスを取得できますので、FileManagerを使ってファイルパスを組み立ててよしなに保存してください。

複数ファイルを保存するTips

複数ファイルを保存するときはdocumentsitemsを引数にとるAPIを用います。ただ、このAPIにはdefaultFilenameを指定できないため、保存する際のファイル名も指定できません。システムによって勝手にナンバリングされたファイル名になって保存されます。

  • Exported PNG image.png
  • Exported PNG image 1.png
  • Exported PNG image 2.png
  • Exported PNG image 3.png

のような感じになります。
これは嫌なのでちゃんと名前を指定したいです。

FileRepresentationにはsuggestedFileNameというAPIがあって一見名前を指定できそうなのですが、私の確認した範囲では全く意図通りに動きません。

FileRepresentation(exportedContentType: .png) { imageFile in
    let fileURL = try imageFile.exportHandler()
    return SentTransferredFile(fileURL)
}
.suggestedFileName { imageFile in
    "frame_\(imageFile.index).png"
}

では、あらかじめ名前をつけたファイルをフォルダに入れて、そのフォルダを保存したいです。
FileManagerを使ってキャッシュ領域にディレクトリを作り、その中に複数ファイルを名前を指定して詰め込み、そのディレクトリのURLをFileDocumentTransferableで扱った場合どうでしょうか?

FileDocumentの場合はFileWrapper(url:options:)でディレクトリも出力できます。
しかし、Transferableの場合はエラーになってしまいます。

Error Domain=NSCocoaErrorDomain Code=256 "The file “Caches” couldn’t be opened."
Error Domain=NSPOSIXErrorDomain Code=21 "Is a directory"

また、あらかじめ名前をつけたファイルをフォルダに入れておくのではなく、複数ファイルをFileDocumentに渡してその場で名前をつけてフォルダに入れて保存したい場合は以下のようにします。

struct ImagesFolderFile: FileDocument {
    static var readableContentTypes: [UTType] = [.folder]

    let images: [Data]

    init(images: [Data]) {
        self.images = images
    }

    init(configuration: ReadConfiguration) throws {
        fatalError("Not Implemented")
    }

    func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
        var files: [String: FileWrapper] = [:]
        images.indices.forEach { i in
            let filename = "frame_\(i).png"
            files[filename] = FileWrapper(regularFileWithContents: images[i])
        }
        return FileWrapper(directoryWithFileWrappers: files)
    }
}

FileWrapper(directoryWithFileWrappers:)を使えば、FileWrapperを入れ子にできます。Dictionaryのキー名に指定した文字列がそのままファイル名になります。

複数種類のFileDocumentを一つのfileExporterで扱う

fileExporterのAPIを複数回使いたい場合、一つのViewにつけると後勝ちになってしまう問題は冒頭で書きましたが、それでは一回のfileExporterで複数種類のFileDocumentに対応させるにはどうしたらいいでしょうか?FileDocumentはprotocolでfileExporterのAPIはgenericsによりコンパイル時に型が解決しないといけません。そんな時はType Erasureをしましょう。

Type Erasureにより1回のfileExporterで複数種類のFileDocumentに対応
struct ExportFile: FileDocument {
    static var readableContentTypes: [UTType] = []

    private let base: FileDocument

    init(_ base: FileDocument) {
        self.base = base
        Self.readableContentTypes = type(of: base).readableContentTypes
    }

    init(configuration: ReadConfiguration) throws {
        fatalError("Not Implemented")
    }

    func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
        try base.fileWrapper(configuration: configuration)
    }
}

struct PNGFile: FileDocument { /* 省略 */ }
struct PDFFile: FileDocument { /* 省略 */ }

struct ContentView: View {
    @State var showingFileExporter = false
    @State var fileType: UTType = .png

    var body: some View {
        Button("Push") {
            showingFileExporter = true
        }
        .fileExporter(
            isPresented: $showingFileExporter,
            document: document,
            onCompletion: { _ in })
    }

    var document: ExportFile? {
        switch fileType {
        case .png: ExportFile(PNGFile())
        case .pdf: ExportFile(PDFFile())
        default: nil
        }
    }
}

参考

Discussion