SwiftUI: fileExporterの罠と実装パターン
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をつける必要があります。上の例の場合はButtonとfileExporterを1対1にすれば良さそうですが、つけるべきView要素がない場合は見えないViewを用意してModifierをつけたりする工夫が必要です。
実装パターン
fileExporterには大きく分けると2つのAPIがあります。FileDocument protocolに準拠したstructでファイルの出力をハンドリングするAPIとTransferable protocolに準拠したstructでファイルの出力をハンドリングするAPIです。また、それぞれに1つのファイルを出力するためのAPIと複数のファイルを出力するためのAPIがあります。
FileDocumentを使う
主にサイズの小さめなファイルを出力するために使う方法です。documentやdocumentsを引数にとるAPIを使います。最終的にはFileWrapperというオブジェクトにしてファイルを出力することになります。ファイルをDataに変換してFileWrapperで包む方法と、キャッシュ領域に保存したファイルのURLをFileWrapperに渡す方法があります。
ファイルの元となるオブジェクトを直接渡してもいいし、必要になったタイミングでDataやURLを渡すようにクロージャーを持たせておくのでも良いです。
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)
}
}
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を使う
主にサイズの大きめなファイル(動画とか)を出力するために使う方法です。itemやitemsを引数にとるAPIを使います。あらかじめキャッシュ領域にファイルを保存しておき、そのファイルURLを渡してユーザーの指定した場所に移動させるというような使い方をするようです。最終的にはTransferRepresentationの形でファイルを出力することになります。
キャッシュファイルのURLを直接渡してもいいし、必要になったタイミングで渡すようにクロージャーを持たせておくのでも良いです。
struct ImageFile: Transferable {
let url: URL
static var transferRepresentation: some TransferRepresentation {
FileRepresentation(exportedContentType: .png) { imageFile in
SentTransferredFile(imageFile.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
複数ファイルを保存するときはdocumentsやitemsを引数にとる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をFileDocumentやTransferableで扱った場合どうでしょうか?
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をしましょう。
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