[SwiftUI] [Swift] iOS で Packaged Document を使う (その1 Packaged Document を定義して実装する)

SwiftUI

iOS アプリで パッケージ型のドキュメントを使う方法を説明します。

Apple も推していますので、iOS デバイスでも Files 経由で使う ドキュメントベースアプリケーション が普及してくるかと思います。

開発する側視点でも複数のタイプの情報を1つのドキュメントファイルにまとめるよりパッケージ型のドキュメントにしておく方が、様々な処理が容易となります。

Packaged Document サンプル

以前、Apple から、Objective-C で記述され、UIKit ベースのサンプルが提供されていました。

Xcode12 でも動きました。ここからダウンロードできます。

今回は、Apple のサンプルと、Xcode12 が Document App として生成するテンプレートコードをベースに作成していきます。

サンプルアプリ概要

写真とテキスト情報を合わせて、ドキュメントに保存するアプリです。テキスト情報は、テキストファイル。写真の情報は、PNGファイルとして保存されます。
テキストファイル、PNGファイルは、パッケージの内部に保存されますので、アプリのデータファイルとしては、1つに見えます。

Pacakged Doc in Files

「Pacakged Doc in Files」

サンプルアプリ機能

サンプルアプリに実装されている機能は、以下の通りでした。

  • ドキュメントリストアップ/作成/削除/名称変更
  • テキスト編集
  • 写真選択(PhotoPicker を使って)

まだ、Files アプリが提供されていなかった時のサンプルなので、Files アプリの機能も実装されています。

サンプルアプリ内で、ドキュメントの作成や削除、名称変更が行えるようになっていますが、現在の SwiftUI で提供されている DocumentGroup を使って管理機能を提供するほうが、良さそうです。

つまり、SwiftUI アプリ側では、(Package 化された)ドキュメントの読み書きとテキスト編集/写真選択 の機能のみを実装することとします。

SwiftUI アプリ仕様

以下のような仕様で作ります

  • Document type は、Apple のサンプルを流用
  • DocumentGroup から開かれたファイルは、詳細ビューでテキスト編集と写真の選択が行える
  • サンプルアプリのドキュメント編集モードは廃止。(開いた時点で編集モードになっているという前提)

実装

プロジェクト作成

Xcode を起動し、以下の設定でプロジェクトを作成します。

  • Product Name: PackageDocSwiftUI
  • Interface: SwiftUI
  • Life Cycle: SwiftUI App
  • Language: Swift

Product Name は、変更可能です。変更した場合は、サンプルコードを適当に読み替えてください。

ドキュメント設定(Info.plist)

Apple のサンプルを参考に、Document と Exported Type ID を設定します。

define Document Type and Exported Type ID

「define Document Type and Exported Type ID」
SwiftUI[Swift] [iOS][MacOS] Document App を作る(その1:UTI と Document Type の定義)

ドキュメント設定(PackageDocSwiftUIDocument.swift)

以下が、完成コードです。

PackageDocSwiftUIDocument.swift

//
//  PackageDocSwiftUIDocument.swift
//
//  Created by : Tomoaki Yagishita on 2020/11/05
//  © 2020  SmallDeskSoftware
//

import SwiftUI
import UniformTypeIdentifiers

extension UTType {
    // (1)
    static var notesDoc: UTType {
        UTType(exportedAs: "com.apple.dts.notesDoc")
    }
}

struct PackageDocSwiftUIDocument: FileDocument {
    // (2)
    static let textFileName: String = "Text.txt"
    static let imageFileName: String = "Image.png"
    
    var noteString: String = ""
    var image: UIImage? = nil
    // (3)
    init() {
        noteString = ""
        image = nil
    }
    // (4)
    static var readableContentTypes: [UTType] { [.notesDoc] }
    // (5)
    init(configuration: ReadConfiguration) throws {
        let docWrapper = configuration.file
        
        guard let noteWrapper = docWrapper.fileWrappers![PackageDocSwiftUIDocument.textFileName] else { fatalError("failed to get notewrapper") }
        guard let textData = noteWrapper.regularFileContents else { fatalError("failed to get text data") }
        guard let string = String.init(data: textData, encoding: .utf8) else { fatalError("failed to unarchive text data") }

        var image: UIImage? = nil
        if let imageWrapper = docWrapper.fileWrappers![PackageDocSwiftUIDocument.imageFileName] {
            guard let imageData = imageWrapper.regularFileContents else { fatalError("failed to get image data") }
            image = UIImage(data: imageData)
        }
        self.noteString = string
        self.image = image
    }
    // (6)
    func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
        print("----- save -----")
        let docWrapper = FileWrapper.init(directoryWithFileWrappers: [String:FileWrapper]())
        
        guard let textData = noteString.data(using: .utf8) else { fatalError("failed to get text data") }
        let noteWrapper = FileWrapper.init(regularFileWithContents: textData)
        noteWrapper.preferredFilename = PackageDocSwiftUIDocument.textFileName
        docWrapper.addFileWrapper(noteWrapper)

        if let image = self.image {
            guard let imageData = image.pngData() else { fatalError("failed to get png data") }
            let imageWrapper = FileWrapper.init(regularFileWithContents: imageData)
            imageWrapper.preferredFilename = PackageDocSwiftUIDocument.imageFileName
            docWrapper.addFileWrapper(imageWrapper)
        }

        return docWrapper
    }
}
コード解説
  1. Info.plist でも定義したタイプを Swift 上でも定義します
  2. Package 内での保存に使われるファイル名を定義しています
  3. ドキュメントの initializer
  4. ドキュメントが Read できるタイプを定義しています。別途定義しなければ、同じタイプに対して Write もできることになります。
  5. Files アプリ上で ドキュメントをクリックした際に呼ばれます
  6. 保存するときに使用する FileWrapper を定義しています

ドキュメント読込の詳細(PackageDocSwiftUIDocument.swift)

ドキュメントの読込部分について詳細を説明します。(保存側を先に読んだほうがわかりやすいかもしれません)

PackageDocSwiftUIDocument.swift

    init(configuration: ReadConfiguration) throws {
		// (1)
        let docWrapper = configuration.file
      // (2)
        guard let noteWrapper = docWrapper.fileWrappers![PackageDocSwiftUIDocument.textFileName] else { fatalError("failed to get notewrapper") }
        guard let textData = noteWrapper.regularFileContents else { fatalError("failed to get text data") }
        guard let string = String.init(data: textData, encoding: .utf8) else { fatalError("failed to unarchive text data") }
	  // (3)
        var image: UIImage? = nil
        if let imageWrapper = docWrapper.fileWrappers![PackageDocSwiftUIDocument.imageFileName] {
            guard let imageData = imageWrapper.regularFileContents else { fatalError("failed to get image data") }
            image = UIImage(data: imageData)
        }
      // (4)
        self.note = Note(notes: string, image: image)
    }
ドキュメント読込 解説
  1. ドキュメントが開かれると ReadConfiguration を引数として init が呼ばれます。.file に ドキュメント全体の FileWrapper がセットされています
  2. ドキュメント全体の FileWrapper には、保存ファイル名をキーとして、テキストファイル用とイメージファイル用の FileWrapper が設定されています。ここでは、テキストファイル用の FileWrapper を取得し、FileWrapper から、テキストを読み込んでいます。
  3. テキストと同様に、イメージファイルから UIImage を読み込んでいます。(イメージは、optional としているため、保存されていなケースも考慮しています)
  4. 取得したテキストとイメージを使って、ドキュメントモデル(Note) を設定しています

ドキュメント保存の詳細(PackageDocSwiftUIDocument.swift)

PackageDocSwiftUIDocument.swift

    func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
      // (1)        
        let docWrapper = FileWrapper.init(directoryWithFileWrappers: [String:FileWrapper]())
        
      // (2)        
        guard let textData = note.notes.data(using: .utf8) else { fatalError("failed to get text data") }
        let noteWrapper = FileWrapper.init(regularFileWithContents: textData)
        noteWrapper.preferredFilename = PackageDocSwiftUIDocument.textFileName
        docWrapper.addFileWrapper(noteWrapper)

      // (3)        
        if note.image != nil {
            guard let imageData = note.image?.pngData() else { fatalError("failed to get png data") }
            let imageWrapper = FileWrapper.init(regularFileWithContents: imageData)
            imageWrapper.preferredFilename = PackageDocSwiftUIDocument.imageFileName
            docWrapper.addFileWrapper(imageWrapper)
        }

      // (4)        
        return docWrapper
    }
}
ドキュメント保存 解説
  1. Package で保存するため、ドキュメント全体の FileWrapper は、このように初期化して作成します。Package 内に含まれる要素についての FileWrapper を追加していきます。
  2. テキストデータを保存するための FileWrapper を作成し、ファイル名を設定してから、ドキュメント全体の FileWrapper へ追加します。(後から、このファイル名をキーに取得できます)
  3. イメージデータを持っているならば、テキストと同様に、FileWrapper を作成し、ドキュメント全体の FileWrapper へ追加します。
  4. 必要な 下位の FileWrapper を設定した FileWrapper を返します。

途中まとめ: Package Document を使う iOS アプリの設定

Package Document を使う iOS アプリの設定
  • Document Type, Exported Type IDを設定する
  • UTType も定義し、FileDocument#readableContentTypes に設定する
  • FileDocument#init で、読み込み用の FileWrapper を設定する
  • FileDocument#fileWrapper で、保存用の FileWrapper を設定する

説明は以上です。 Happy Coding!

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です