2022年11月03日, 編集履歴
macOSのメニューバーエクストラは、画面最上部のメニューバー右側に配置されるアイコンであり、それをクリックする事でメニューあるいはポップオーバーを表示し、アプリ等が最前面になくとも機能を呼び出せるUIである。常駐アプリ等で設定変更や状態確認のために使われることが多い。
SwiftUI macOSアプリでメニューバーアイコンを出すにはMenuBarExtra
を使う。
文字列表示
メニューバーエクストラに文字列として項目を表示したい場合は、
@main
struct MenuBarExtraSampleApp: App {
var body: some Scene {
MenuBarExtra("MenuBarExtra") {
Button("About App") {
NSApp.orderFrontStandardAboutPanel(nil)
NSApp.activate(ignoringOtherApps: true)
}
Button("Settings...") {
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
NSApp.activate(ignoringOtherApps: true)
}
Divider()
Button("Quit App") {
NSApp.terminate(nil)
}
}
}
}
のようにする。この例ではメニュー形式のメニューバーエクストラが作られる。
アイコン表示
アイコンを表示する場合は、
MenuBarExtra("MenuBarExtra", image: "icon") {
...
}
や、
MenuBarExtra("MenuBarExtra", systemImage: "star.fill") {
...
}
とする。このとき、第一引数の文字列は表示されなくなる。
アイコンと文字列表示
アイコンも文字列も両方出したい場合は、
MenuBarExtra {
...
} label: {
Label("MenuBarExtra", systemImage: "star.fill")
.labelStyle(.titleAndIcon)
}
とする。ここで単にLabel
だけだとやはり文字列が表示されない。.labelStyle(.titleAndIcon)
を指定することでアイコンと文字列両方が表示される。
ポップオーバー表示
通常はメニュー形式で表示されるメニューバーエクストラだが、ポップオーバー形式にすることもできる。その場合は、
MenuBarExtra("MenuBarExtra") {
HStack {
Image(systemName: "heart.fill")
Button("MenuBarExtra") {}
Image(systemName: "heart.fill")
}
.frame(width: 300, height: 200)
}
.menuBarExtraStyle(.window)
のようにmenuBarExtraStyle
を使う。
メニューバーエクストラの表示・非表示切り替え
メニューバーエクストラの表示・非表示の切り替えをしたい場合はisInserted
が入っているイニシャライザを使う。
@main
struct StatusBarSampleApp: App {
@AppStorage("MenuBarExtraShown") private var menuBarExtraShown = true
var body: some Scene {
MenuBarExtra("MenuBarExtra", systemImage: "star.fill", isInserted: $menuBarExtraShown) {
...
}
}
}
この場合、どこかのビューで、
struct GeneralSettingsView: View {
@AppStorage("MenuBarExtraShown") private var menuBarExtraShown: Bool = true
var body: some View {
Form {
Toggle("Menu Bar Extra", isOn: $menuBarExtraShown)
}
.padding()
}
}
のように切り替えをするUIを用意する。
常駐アプリ化
メインウィンドウを持たずにメニューバーエクストラを操作の起点とするような常駐アプリにしたい場合は、ターゲットの「Info」欄で「Application is agent (UIElement)」を「YES」にする。こうすることで、アプリがDockやAppスイッチャーに表示されなくなる。
2022年10月31日, 編集履歴
SwiftUI macOSアプリで、以下のような設定ウィンドウを作りたいとする。
ラベルは右揃えで、ポップアップメニューやテキストフィールド等の操作部品は左揃えにして整列させたい。VStack
や単純なForm
ではきれいな整列ができない(……ことはないがややこしい黒魔術が必要となる)。よくあるパターンのレイアウトなのに変な感じだった。
そんな中、macOS 13.0から使える新しいLabeledContent
をForm
と組み合わせることで、かんたんにきれいな整列が実現できるようになった。
環境は以下の通り。
- macOS 13.0
- Xcode 14.1 RC2
- macOS 13.0 SDK
まずはこれまでの状態を確認してみる。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もだんだん良くなってきた!
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
を使った方が自然な記述ができる。
2021年12月21日, 編集履歴
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(_:_:)
ではなく、アクティブなドキュメントを簡便に参照できるような仕組みは最初から入っていて欲しかった。定型文的な準備が必要なのはなんか変だ。