feed

2022年07月06日, 編集履歴

ImageDiff ver. 2.0をリリースしました

ImageDiff ver. 2.0をリリースした。ふたつの画像間の差異をいくつかの方法で視覚化するmacOSアプリである。

ImageDiffは2011年に最初のヴァージョンを公開して以降、大きな変更を加えることなく放置気味だったのだが、この度、SwiftUIを用いてイチから書き直した。SwiftUIでのmacOSアプリを構築する感触を確かめるには、ちょうど良い規模感だったように思う。

主な変更点は、

である。

ver. 2.0は、前ヴァージョンまでとは別のアプリとしてMac App Storeに登録したので、前ヴァージョンをご利用の方は改めてダウンロードしなおす必要がある。また、前ヴァージョンは有料アプリだったが、ver. 2.0からは基本無料アプリとした。基本無料アプリとは言っても全機能無料で使用できる。アプリ内課金機能で開発者支援ができるので、気が向いた方は是非。

どうぞよろしく。

2022年05月18日, 編集履歴

Silver Baton ver. 1.4.1をリリースしました

Silver Baton ver. 1.4.1をリリースしました。手元のトラックパッド/マウス操作でMusic.appを操作できるパネルを表示するmacOS用アプリです。

軽微な修正のみのヴァージョンです。どうぞよろしく。

2021年12月21日, 編集履歴

SwiftUIでDocument-Based Appな画像閲覧アプリを作る その4 メニューコマンドの実装

SwiftUIでDocument-Based Appな画像閲覧アプリ習作の覚書その4。

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

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

ビルド環境は、

である。

前回はツールバーを実装した。今回はメニューコマンドを実装する。

ここで、今回のキモとなる.focusedSceneValueは、本来はmacOS/iOS共通して使えるものであるが、iOS 15.2のiPhone 8実機およびiPadシミュレータでは動作しなかった。macOSは12.0.1までは動作しなかったが、12.1で動作するようになった。

.commandsCommandMenuCommandGroup

メニューコマンドを実装するには、WindowGroupシーンやDocumentGroupシーンに対して.commandsを指定し、CommandMenuあるいはCommandGroupを使ってメニュー構造を作る。

@main
struct ImageViewerSwiftUIApp: App {
  var body: some Scene {
    DocumentGroup(viewing: ImageDocument.self) { file in
      ContentView(document: file.document)
    }
    .commands {
      CommandMenu("MyMenu") {
        Button("Action 1") { print("action 1") }
      }
      CommandGroup(after: .toolbar) {
        Button("Action 2") { print("action 2") }
      }
    }
  }
}

CommandMenuはメニューバーのトップレベルに新しいメニューを作り、CommandGroupは既存のメニューの中にメニュー項目を作る。

ここで、マルチウィンドウなDocument-Based Appのメニューコマンドを実装するとき、複数存在しうるウィンドウ(ドキュメント)のどれに対してのアクション/操作なのかを指定したい。メニューコマンドの実装部分はドキュメントオブジェクトが存在するスコープの外なので、そのままでは対象のドキュメントを指定できない。

そこで.focusedSceneValue(_:_:)を使う。

.focusedSceneValue(_:_:)FocusedValueKeyFocusedValues

.focusedSceneValue(_:_:)はシーンの切り替わりに応じて、通常はアクティブなウィンドウの切り替わりに応じて、何らかの値を公開できる機能である。今回のDocument-Based Appな画像閲覧アプリの場合、シーン(ウィンドウ)とドキュメントオブジェクトが一対一対応しているので、シーン(ウィンドウ)の切り替わりに応じて対応するドキュメントオブジェクトを.focusedSceneValue(_:_:)で公開すると良い。

.focusedSceneValue(_:_:)を使うには、準備としてFocusedValueKeyプロトコルに準拠した構造体の定義とFocusedValues構造体の拡張が必要となる。

struct FocusedSceneDocumentKey: FocusedValueKey {
  typealias Value = ImageDocument
}

extension FocusedValues {
  var focusedSceneDocument: ImageDocument? {
    get { self[FocusedSceneDocumentKey.self] }
    set { self[FocusedSceneDocumentKey.self] = newValue }
  }
}

ほとんど定型文的な記述になる。

FocusedValueKeyプロトコルに準拠した構造体では公開したい値の型を指定する。ここでのImageDocumentReferenceFileDocumentな参照型のドキュメントオブジェクトである。この構造体の型を次の計算型プロパティで使用する。

FocusedValues構造体の拡張では計算型プロパティを定義する。プロパティの型は公開したい型のオプショナル型になる。このプロパティ名が後述の.focusedSceneValue(_:_:)で使用するkey path名となる。

これで準備完了。シーン配下のビューで.focusedSceneValue(_:_:)を使う。

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

第一引数にはFocusedValues構造体の拡張で作ったプロパティ名をkey path名として指定し、第二引数で公開したい値を指定する。

公開された値を使用するときは@FocusedValueプロパティラッパを使用する。

@FocusedValue(\.focusedSceneDocument) var document

@FocusedValueなプロパティの宣言には、FocusedValues構造体で作ったプロパティ名をkey path名として指定する。@FocusedValueなプロパティは、.focusedSceneValue(_:_:)で公開されているドキュメントオブジェクト、あるいはnilが入っているオプショナル型である。

メニューコマンドの外部化

今回はメニューコマンドの実装を外部化する。通常のビューを外部化するときはViewプロトコルが用いられるが、メニューコマンドの場合はCommandsプロトコルを使用する。

struct ImageViewerSwiftUIApp: App {
  var body: some Scene {
    DocumentGroup(viewing: ImageDocument.self) { file in
      ContentView(document: file.document)
        .focusedSceneValue(\.focusedSceneDocument, file.document)
    }
    .commands {
      ZoomCommands()
    }
  }
}

struct ZoomCommands: Commands {

  @FocusedValue(\.focusedSceneDocument) var document

  var body: some Commands {
    CommandGroup(after: .toolbar) {
      Button("Actual Size") {
        document?.resetViewSize(animate: true)
      }
      .keyboardShortcut(KeyEquivalent("0"))
      .disabled(document == nil)

      Button("Zoom In") {
        document?.scaleViewSize(2.0, animate: true)
      }
      .keyboardShortcut(KeyEquivalent("+"))
      .disabled(document == nil)

      Button("Zoom Out") {
        document?.scaleViewSize(0.5, animate: true)
      }
      .keyboardShortcut(KeyEquivalent("-"))
      .disabled(document == nil)

      Divider()
    }
  }
}

前回までで実装した閲覧中の画像の拡大・縮小表示を行う処理を実装した。

既存のViewメニューの中に入れたかったので、CommandGroup(after: .toolbar){ ... }とし、Viewメニューの中のツールバー関連メニューの次に表示させるようにした。

キーボードショートカットやメニュー項目の有効・無効化処理も入れている。メニューに境界線を引きたいときはDivder()を使う。

終わりに

この.focusedSceneValueの方法はiOSでも使えるはずなのだが、iOS 15.2の段階では@FocusedValueなプロパティが常にnilになって正常に動作しない。macOSでも12.0.1では同様だった。本来はmacOS 12.0、iOS 15.0から使えていなければならなかった。

また、SwiftUIがまだまだ過渡期ゆえだとは思うが、.focusedSceneValue(_:_:)ではなく、アクティブなドキュメントを簡便に参照できるような仕組みは最初から入っていて欲しかった。定型文的な準備が必要なのはなんか変だ。

2021年12月03日, 編集履歴

Murasaki ver. 2.4.1をリリース

Murasaki ver. 2.4.1をリリースしました。macOS用のEPUBリーダアプリです。

前回からの主な変更点は、

です。

macOS 10.15 Catalina辺りから、Murasaki同梱のEPUB用Spotlight/Quick Lookプラグインが動かなくなっていました。前回のリリースでプラグインの同梱をやめたのは、これが理由です。

従前、EPUBにはorg.idpf.epub-containerというUTI(ファイル形式を同定する識別子)が定義されていました。プラグインの開発ではこのUTIを用いてファイル形式の判別等を行います。しかし、いつの頃からか、com.apple.ibooks.epubなるUTIが定義されており、EPUBファイル用のUTIとしてこちらが使われるようになってしまっており、プラグインが動作しない状況になっていました。Murasaki同梱のSpotlightプラグインが動かなくなっていた原因はこれです。

今回、UTIのバッティング問題に気づいたことで、Spotlightプラグインを修正できました。

Quick Lookプラグインについては、macOSには最初からEPUB用Quick Lookプラグインが組み込まれており、アプリ同梱プラグインより優先して読み込まれるようになっていたことが原因です。通常は、とあるファイル形式に対応したプラグインがOSに組み込まれていたとしても、アプリ同梱のプラグインや/Users/***/Library/QuickLookにインストールされたものが優先される仕様のはずなのです。

しかしながら、macOS 12 MontereyではQuick Lookプラグインの優先読み込み問題が知らぬ間に解決されていたことが解りました。Appleにバグレポートを投げても梨の礫だったこともあって、腑に落ちぬところはありますが、ともあれ解決したのならヨシ!

とは言え、手元に環境がないので解らないのですが、おそらくmacOS 11以前ではQuick Lookプラグインの問題は解決していないと思うので、今回からmacOS 12以降のみをサポートすることにします。

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が入るので、オーバレイ表示するビューの表示・非表示の条件に用いた。

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
}

これで、

等々からのドラッグ&ドロップで画像を読み込み、表示することができる。

ただし、Safariで画像そのもののURLを開いて、アドレス欄のURLをドラッグ&ドロップした場合は読み込めない。この場合の読み込み方法は謎である。

また、loadItem()の引数でUTType.image.identifierを指定しているのに、取得できるのがURL型になるのも変な感じがする。直感的にはData型になりそうなものだが……(NSImage(data:)で画像データを作れるイメージ)。

最後に

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