feed

2021年09月06日, 編集履歴

SwiftUI macOSアプリでファイルを開く

SwiftUIの非Document AppなmacOSアプリにおいて、ファイルを開く処理を実装する。今回は以下の3つの手法を試した。

成果物をGitHubに公開している。

ビルド環境は、

である。

NSOpenPanel

まずは、これまで通りNSOpenPanelを使う方法から。以下のようなビューがあったとする。

ImageButtonが並んでおり、Button押下でNSOpenPanelを表示、選択された画像をImageに表示する場合を考える。対応する画像形式はPNGとJPEG、Imageのサイズは一定とする。

import SwiftUI
import UniformTypeIdentifiers

struct NSOpenPanelView: View {

  @State private var image: NSImage? = nil

  var body: some View {
    VStack {
      Image(nsImage: image ?? NSImage())
        .resizable()
        .aspectRatio(contentMode: .fit)
        .frame(width: 500, height: 500)

      Button("Open") {
        let openPanel = NSOpenPanel()
        openPanel.allowsMultipleSelection = false
        openPanel.canChooseDirectories = false
        openPanel.canChooseFiles = true
        openPanel.allowedFileTypes = [UTType.png.identifier, UTType.jpeg.identifier]
        if openPanel.runModal() == .OK {
          guard let url = openPanel.url,
                let newImage = NSImage(contentsOf: url)
          else { return }
          image = newImage
        }
      }
      .padding()
    }
  }
}

Buttonactionコールバック内でNSOpenPanelを生成し、runModal()してやればよい。その返り値が.OKの場合はファイル選択が成功し、NSOpenPanelurlプロパティに選択されたファイルのURLが入っている。

対応形式にUTIが求められるときはUTType型を使うとよい。UTTypeは新しい型で、UTIを使うAPIによってはUTType型を求められたり、String型で求められたりが混在している。String型を求められているAPIならUTTypeidentifierを使う。UTType型を使うときはimport UniformTypeIdentifiersが必要である。後の項でも同様。

fileImporter()

SwiftUIらしいやり方として、fileImporter(isPresented:allowedContentTypes:onCompletion:)やそのヴァリエーションを使う方法がある。前項と同様のビューがあったとする。

今度もButton押下でファイル選択のパネルを出す。

import SwiftUI
import UniformTypeIdentifiers

struct FileImporterView: View {

  @State private var image: NSImage? = nil
  @State private var importerPresented = false

  var body: some View {
    VStack {
      Image(nsImage: image ?? NSImage())
        .resizable()
        .aspectRatio(contentMode: .fit)
        .frame(width: 500, height: 500)

      Button("Open") {
        importerPresented = true
      }
      .padding()
    }
    .fileImporter(isPresented: $importerPresented, allowedContentTypes: [.png, .jpeg]) { result in
      switch result {
      case .success(let url):
        guard let newImage = NSImage(contentsOf: url) else { return }
        image = newImage
      case .failure:
        print("failure")
      }
    }
  }
}

fileImporter()は、その第1引数isPresentedに指定する@Stateなプロパティがtrueになったときにファイル選択パネルを表示する。Button押下時のactionとしてそのプロパティをtrueにする処理を入れてやればよい。isPresentedに指定したプロパティは、ファイル選択パネルでの操作が終わったときに自動的にfalseに戻る。

fileImporter()のコールバックの引数はResult<URL, Error>型なのでswitch文でURLを取り出し、画像を生成する。

ファイル選択パネルでキャンセルしても.failureにはならないようである。

onDrop()

画像のドラッグ&ドロップも試してみる。以下のようなImageが全面に配置されたビューがあったとする。

ドラッグ&ドロップによってImageに画像を表示する場合を考える。また、ドラッグ中に画像がビューの領域に入った場合は、Textをオーバーレイ表示させるとする。

import SwiftUI
import UniformTypeIdentifiers

struct DropView: View {

  @State private var image: NSImage? = nil
  @State private var isDropTargeted = false

  var body: some View {
    ZStack {
      Image(nsImage: image ?? NSImage())
        .resizable()
        .aspectRatio(contentMode: .fit)
        .frame(width: 500, height: 500)
      if isDropTargeted {
        Rectangle()
          .fill(Color(.windowBackgroundColor))
          .opacity(0.8)
          .overlay(
            Text("Drop Here")
              .font(.system(size: 64).bold())
          )
      }
    }
    .onDrop(of: [.png, .jpeg, .url, .fileURL], isTargeted: $isDropTargeted) { providers in
      guard let provider = providers.first
      else { return false }
      if provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
        provider.loadItem(forTypeIdentifier: UTType.image.identifier, options: nil) { data, error in
          guard let imageData = data as? Data,
                let newImage = NSImage(data: imageData)
          else { return }
          image = newImage
        }
      }
      else if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
        provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { data, error in
          guard let urlData = data as? Data,
                let url = URL(dataRepresentation: urlData, relativeTo: nil),
                let newImage = NSImage(contentsOf: url)
          else { return }
          image = newImage
        }
      }
      return true
    }
  }
}

ドロップに対応させるにはonDrop(of:isTargeted:perform:)やそのヴァリエーションを使う。

第1引数で対応形式を指定するが、今回は[.png, .jpeg, .url, .fileURL]とした。

他のアプリから画像データそのものがドロップされた場合.png.jpegに対応する。Music.appの「情報を見る」の「アートワーク」タブから画像をドロップした場合などがこれである。

Safariで画像のURLを開き、アドレス欄のファビコン部分をドラッグ&ドロップした場合は.urlに対応する。このとき、データの取得にインターネットアクセスを必要とするので、App Sandboxの設定で「Outgoing Connections (Client)」のチェックを入れておく。

Finderから画像ファイルをドロップした場合や、プレビューアプリのタイトルバー部分のアイコンをドロップした場合は.fileURLに対応する。

また、Safariで表示されている画像そのものをドロップした場合は、.png等の画像形式と.url形式に両対応する。

ドロップされたデータの読み込みはloadItem(forTypeIdentifier:options:completionHandler:)やそのヴァリエーションを使う。今回は.png.jpegはその親形式であるUTType.imageとしてまとめて処理し、.fileURLは親形式の.urlとまとめて処理した。.url.fileURLに関して、今回は読み込み後の形式チェックはしていないので、PNG/JPEG以外でもNSImageが対応している形式であれば読み込みが成功するようになっている。

onDrop(of:isTargeted:perform:)の第2引数isTargeted@Stateなプロパティを指定しておけば、ドロップ領域に入った場合にtrue、外に出たときにfalseが入るので、オーバレイ表示するビューの表示・非表示の条件に用いた。

最後に

今回は説明のためにそれぞれの読み込み方法を別々のビューに実装したが、実際に使用する場合は前二者の方法とonDrop()の方法を組み合わせたり、メニューからの読み込み処理の呼び出しを実装したりするとよい。