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