2021年09月06日, 編集履歴
SwiftUI macOSアプリでファイルを開く
SwiftUIの非Document AppなmacOSアプリにおいて、ファイルを開く処理を実装する。今回は以下の3つの手法を試した。
NSOpenPanelfileImporter()onDrop()
成果物をGitHubに公開している。
ビルド環境は、
- macOS 11.5.2
- Xcode 12.5.1
である。
NSOpenPanel
まずは、これまで通りNSOpenPanelを使う方法から。以下のようなビューがあったとする。

ImageとButtonが並んでおり、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()
}
}
}
Buttonのactionコールバック内でNSOpenPanelを生成し、runModal()してやればよい。その返り値が.OKの場合はファイル選択が成功し、NSOpenPanelのurlプロパティに選択されたファイルのURLが入っている。
対応形式にUTIが求められるときはUTType型を使うとよい。UTTypeは新しい型で、UTIを使うAPIによってはUTType型を求められたり、String型で求められたりが混在している。String型を求められているAPIならUTTypeのidentifierを使う。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が入るので、オーバレイ表示するビューの表示・非表示の条件に用いた。
2023年4月3日追記
(たぶん)macOS 13から.onDrop()とloadItem()の挙動が変わって、上記コードでは画像読み込みができなくなった。macOS 13に対応したコードを以下に示す。
//(省略)
.onDrop(of: [.image], isTargeted: $isDropTargeted) { providers in
guard let provider = providers.first else { return false }
if provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
provider.loadItem(forTypeIdentifier: UTType.image.identifier) { item, error in
guard error == nil,
let url = item as? URL,
let loadedImage = NSImage(contentsOf: url)
else { return }
image = loadedImage
}
}
return true
}
これで、
- Finderに表示している画像ファイル
- プレビュー.appのタイトルバーアイコン
- Safariに表示された画像
- ミュージック.appの「情報を見る」の「アートワーク」の画像
等々からのドラッグ&ドロップで画像を読み込み、表示することができる。
ただし、Safariで画像そのもののURLを開いて、アドレス欄のURLをドラッグ&ドロップした場合は読み込めない。この場合の読み込み方法は謎である。
また、loadItem()の引数でUTType.image.identifierを指定しているのに、取得できるのがURL型になるのも変な感じがする。直感的にはData型になりそうなものだが……(NSImage(data:)で画像データを作れるイメージ)。
最後に
今回は説明のためにそれぞれの読み込み方法を別々のビューに実装したが、実際に使用する場合は前二者の方法とonDrop()の方法を組み合わせたり、メニューからの読み込み処理の呼び出しを実装したりするとよい。