feed

2022年10月31日, 編集履歴

SwiftUI macOSアプリの設定ウィンドウ項目をLabeledContentで整列させる

SwiftUI macOSアプリで、以下のような設定ウィンドウを作りたいとする。

ラベルは右揃えで、ポップアップメニューやテキストフィールド等の操作部品は左揃えにして整列させたい。VStackや単純なFormではきれいな整列ができない(……ことはないがややこしい黒魔術が必要となる)。よくあるパターンのレイアウトなのに変な感じだった。

そんな中、macOS 13.0から使える新しいLabeledContentをFormと組み合わせることで、かんたんにきれいな整列が実現できるようになった。

環境は以下の通り。

VStackや単純なFormの場合

まずはこれまでの状態を確認してみる。UIを縦に並べていく場合、VStackやFormを使う。VStackでは単純な左揃えや中央揃え等になってしまうので却下。こういった設定項目を縦に並べる場合はFormを使う。たとえば以下のような感じ。

Form {
  Picker("Pref 1:", selection: .constant(1)) {
    Text("Option 1").tag(1)
    Text("Option 2").tag(2)
  }
  .fixedSize()

  TextField("Preference 2:", text: .constant(""))
    .frame(width: 200)

  HStack {
    Text("Long Preference 3:")
    Toggle("Enabled Hoge", isOn: .constant(true))
  }

  HStack {
    Text("Pref 4:")
    Button("Button") { }
  }
}
.padding()

このとき、PickerやTextFieldなど、標準で左側にラベル的要素を持つUIは意図通りきれいに整列される。しかし、左側にラベル的要素を持たないToggleやButtonなどは、ラベル的要素を表現するためにHStackと組み合わせる必要があり、その場合、意図通りには整列されない。

macOS 12までは、Example of aligning labels in SwiftUI.Form on macOSにあるような.alignmentGuideを使うややこしい黒魔術的な手法で整列させなければならなかった。

LabeledContentの場合

macOS 13.0からLabeledContentが使えるようになった。これは前述のToggleやButton等の、標準では左側にラベル的要素を持たないUI部品にラベルを付与できるようになる。そして、それをFormと組み合わせることできれいな整列をかんたんに実現できる。こんな感じ。

Form {
  Picker("Pref 1:", selection: .constant(1)) {
    Text("Option 1").tag(1)
    Text("Option 2").tag(2)
  }
  .fixedSize()

  TextField("Preference 2:", text: .constant(""))
    .frame(width: 300)

  LabeledContent("Long Preference 3:") {
    Toggle("Enabled Hoge", isOn: .constant(true))
  }

  LabeledContent("Pref 4:") {
    Button("Button") {}
  }
}
.padding()

前項のコード例と違うのは、ToggleとButtonの部分でHStackの代わりにLabeledContentを使ってラベルを持たせている点。

特に黒魔術は必要なく、素直に意図通りのレイアウトが実現できる。SwiftUIもだんだん良くなってきた!

追記 .formStyle(.grouped)

macOS 13.0 VenturaからはFormに対して.formStyle()で表示形式を変更できるようになった。

Form {
  Picker("Pref 1", selection: .constant(1)) {
    Text("Option 1").tag(1)
    Text("Option 2").tag(2)
  }

  TextField("Preference 2", text: .constant(""))

  Toggle("Long Long Preference 3", isOn: .constant(true))

  HStack {
    Text("Pref 4")
    Spacer()
    Button("Button") { }
  }

  LabeledContent("Pref 5") {
    Button("Button") { }
  }
}
.formStyle(.grouped)

このように.formStyle(.grouped)を指定することで、Venturaの新しいシステム設定のような表示形式にできる。

この場合、Toggleは前述のLabeledContentを使わずとも自然に記述できる。Buttonに関してはHStackでラベル的要素を付けて整列できるが、LabeledContentを使った方が自然な記述ができる。

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で動作するようになった。

.commands、CommandMenu、CommandGroup

メニューコマンドを実装するには、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(_:_:)、FocusedValueKey、FocusedValues

.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プロトコルに準拠した構造体では公開したい値の型を指定する。ここでのImageDocumentはReferenceFileDocumentな参照型のドキュメントオブジェクトである。この構造体の型を次の計算型プロパティで使用する。

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以降のみをサポートすることにします。