feed

2021年08月23日, 編集履歴

SwiftUIでDocument-Based Appな画像閲覧アプリを作る その1 プロジェクト作成から画像の表示まで

SwiftUIでDocument-Based Appな画像閲覧アプリを習作した。PNG/JPEG画像を開いて閲覧、スクロール、ピンチジェスチャで拡大縮小、ツールバーにボタン配置等を実装した。その覚書その1。

このシリーズの他のブログは、

プロジェクトはGitHubで公開している。

ビルド環境は、

である。

Document Appテンプレートでプロジェクト作成

SwiftUIでDocument-Based Appなプロジェクトを作成するには「Document App」テンプレートを使用する。今回はmacOS/iOS両対応で作りたいので、「Multiplatform」の「Document App」テンプレートを選択してプロジェクトを作成する。

Info.plistの設定

Info.plistファイルで、アプリが読み書きできるファイル形式を以下の項目で指定する。

Document Typesは必須、既存のファイル形式を扱う場合は「Imported 〜」を、アプリ独自の形式を定義する場合は「Exported 〜」を使用する。

今回は既存のPNG/JPEG画像を扱いたいので、「Exported 〜」は不使用。

Document Typesでは、PNG/JPEGそれぞれに形式の名称やUTIを指定する。また、アプリは画像の閲覧専用とするので、「Role」は「Viewer」を、「Handler Rank」は「Alternate」を選択した。

Imported Type Identifiersでは、それぞれの形式のUTIや拡張子、MIME Type等を指定する。

macOS/iOSのInfo.plistは独立しているので、両対応させる場合は両方で設定を行う必要がある。

FileDocument/ReferenceFileDocumentで対応形式を宣言

SwiftUI Document-Based Appでは、対応するファイル形式のモデルをFileDocumentあるいはReferenceFileDocumentに適合したオブジェクトで表現する。FileDocumentあるいはReferenceFileDocumentが持つstatic var readableContentTypes: [UTType] { get }プロパティで、対応する形式のUTIを[UTType]で返すようにする。

  static var readableContentTypes: [UTType] {
    [
      UTType(importedAs: "public.png"),
      UTType(importedAs: "public.jpeg")
    ]
  }

今回はPNG/JPEG画像を同様に扱いたいので、ひとつのドキュメントモデルで一緒に宣言した。形式によって読み書きの処理等を変えたい場合は、複数のドキュメントモデルを作成する。

ファイルの書き込みにも対応する場合は、static var writableContentTypes: [UTType] { get }を実装する。

NSImageUIImageを一律に扱う

今回のアプリはmacOSとiOSの両対応にしたい。それぞれの環境で画像を表すオブジェクトであるNSImageUIImageを一律に扱うため、typealiasを使ってIVImageなる型を定義する。

#if os(macOS)
typealias IVImage = NSImage
#elseif os(iOS)
typealias IVImage = UIImage
#endif

また、後の工程で画像を表示するビューとしてSwiftUIのImageを使うが、IVImageを扱えるようにextensionで拡張する。

extension Image {
  init(ivImage: IVImage) {
    #if os(macOS)
    self.init(nsImage: ivImage)
    #elseif os(iOS)
    self.init(uiImage: ivImage)
    #endif
  }
}

ドキュメントモデルで読み書き処理を実装

FileDocument/ReferenceFileDocumentなドキュメントのモデルで画像の読み書きの処理を実装する。次回の工程でモデルのプロパティを@Publishedにしたかったので、今回のアプリではReferenceFileDocumentで実装する。この場合、

が必要である。

class ImageDocument: ReferenceFileDocument {

  typealias Snapshot = IVImage

  static var readableContentTypes: [UTType] {
    [
      UTType(importedAs: "public.png"),
      UTType(importedAs: "public.jpeg")
    ]
  }

  var image: IVImage

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

  required init(configuration: ReadConfiguration) throws {
    guard let data = configuration.file.regularFileContents,
          let image = IVImage(data: data)
    else {
      throw CocoaError(.fileReadCorruptFile)
    }
    self.image = image
  }

  func fileWrapper(snapshot: IVImage, configuration: WriteConfiguration) throws -> FileWrapper {
    throw CocoaError(.fileWriteUnknown)
  }

  func snapshot(contentType: UTType) throws -> IVImage {
    return self.image
  }

}

読み込み処理は、単純に画像を読み込んでIVImageNSImageUIImage)型のimageプロパティに格納する。func fileWrapper(snapshot:configuration:)はファイルの書き込みに用いられるが、今回のアプリは閲覧専用なのでエラーを吐かせておく。

init(image:)は後の工程でビューのプレビューを表示させるときに空のドキュメントを供給するときに必要なので実装しておいた。

DocumentGroupの指定

Appプロトコルに適合した構造体の中、DocumentGroupシーンでドキュメントモデルの指定を行う。

struct ImageViewerSwiftUIApp: App {
  var body: some Scene {
    DocumentGroup(viewing: ImageDocument.self) { file in
      ContentView(document: file.document)
    }
  }
}

今回は閲覧専用なので、init(viewing:viewer:)を使った。第1引数にはドキュメントモデルの型を指定する。複数のドキュメントモデルを用いる場合は、DocumentGroupを複数宣言する。

ビューの作成

DocumentGroup内で指定したビューでドキュメントの内容を表示する(前項のContentView)。

struct ContentView: View {

  @ObservedObject var document: ImageDocument

  var body: some View {
    ScrollView([.horizontal, .vertical]) {
      Image(ivImage: document.image)
    }
  }
}

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

今回のドキュメントモデルで採用したReferenceFileDocumentObservableObjectに適合しているので、プロパティとして保持するときは@ObservedObjectで受ける。大きな画像を開いたときにスクロールできるように、ImageScrollViewに内包させた。

ここまでで、とりあえずPNG/JPEG画像を閲覧するアプリが動く。

Document-Based Appなので、複数のウィンドウで別々の画像を開いたり、複数開いたウィンドウをタブでひとつのウィンドウにまとめたりできる。

次回はピンチイン/アウトジェスチャによる画像の拡大縮小の実装を行う。