😼

SwiftUIでmacOSアプリケーションのメニューを作成

に公開

概要

SwiftUIでメニューバーに独自メニューの追加、既存メニューへの項目追加、項目の見た目やアクション、有効無効を制御します。

環境

  • macOS: Sequoia 15.5
  • Xcode: 16.4

新規にメニューを追加

Sceneにcommandsを付与するとメニューバーにコマンドを追加できます。
CommandMenuはメニューバーの一番上のメニューを追加します。
Buttonはメニュー項目を追加します。
Menuで階層構造のメニューを追加します。
Dividerで区切り線を追加します。
keyboardShortcutはショートカットキーを設定します。

以下が実装例です。

HelloWorldApp.swift
@main
struct HelloWorldApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .commands {
            CommandMenu("Dango") {
                Button("Find shop") {
                    print("Select find shop")
                }
                .keyboardShortcut(KeyEquivalent("F"), modifiers: EventModifiers([.command]))
                Divider()
                Menu("Pattern 1") {
                    Button("Red bean") {
                        print("Select red bean")
                    }
                    Button("Matcha") {
                        print("Select matcha")
                    }
                    Button("White bean") {
                        print("Select white bean")
                    }
                }
                Menu("Pattern 2") {
                    Button("White bean") {
                        print("Select white bean")
                    }
                    Button("Red bean") {
                        print("Select red bean")
                    }
                    Button("Matcha") {
                        print("Select matcha")
                    }
                }
                Menu("Pattern 3") {
                    Button("Matcha") {
                        print("Select matcha")
                    }
                    Button("White bean") {
                        print("Select white bean")
                    }
                    Button("Red bean") {
                        print("Select red bean")
                    }
                }
            }
        }
    }
}

SwiftUI new menu

既存のメニューに項目追加

CommandGroupで既存のメニューにコマンドを追加できます。
以下の例の場合はNew Windowの次にコマンドを追加します。

HelloWorldApp.swift
@main
struct HelloWorldApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .commands {
            CommandGroup(after: .newItem) {
                Button("Open") {
                    print("Select open")
                }
                .keyboardShortcut(KeyEquivalent("O"), modifiers: EventModifiers([.command]))
            }
        }
    }
}

SwiftUI append menu

既定のメニューを消してアプリケーション独自のメニューを追加

既定で結構多くのメニューが既に表示されます。
アプリケーションによってはあまり使わないメニューもあると思います。
.commandsの代わりにcommandsReplacedを使用すると既存のメニューを新しいメニューに置き換えます。

HelloWorldApp.swift
@main
struct HelloWorldApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .commandsReplaced {
            CommandMenu("Dango") {
                Button("Find shop") {
                    print("Select find shop")
                }
                .keyboardShortcut(KeyEquivalent("F"), modifiers: EventModifiers([.command]))
                Divider()
                Menu("Pattern 1") {
                    Button("Red bean") {
                        print("Select red bean")
                    }
                    Button("Matcha") {
                        print("Select matcha")
                    }
                    Button("White bean") {
                        print("Select white bean")
                    }
                }
                Menu("Pattern 2") {
                    Button("White bean") {
                        print("Select white bean")
                    }
                    Button("Red bean") {
                        print("Select red bean")
                    }
                    Button("Matcha") {
                        print("Select matcha")
                    }
                }
                Menu("Pattern 3") {
                    Button("Matcha") {
                        print("Select matcha")
                    }
                    Button("White bean") {
                        print("Select white bean")
                    }
                    Button("Red bean") {
                        print("Select red bean")
                    }
                }
            }
        }
    }
}

メニューの定義をSceneの外に逃す

メニューを追加していくとSceneが肥大化してくるのでSceneの外へ逃します。
メニューにはCommandsを設定できるので、以下のようにあらかじめ用意しておいたCommandsをメニューに設定します。

HelloWorldApp.swift
struct DangoMenu: Commands {
    var body: some Commands {
        CommandMenu("Dango") {
            Button("Find shop") {
                print("Select find shop")
            }
            .keyboardShortcut(KeyEquivalent("F"), modifiers: EventModifiers([.command]))
            Divider()
            Menu("Pattern 1") {
                Button("Red bean") {
                    print("Select red bean")
                }
                Button("Matcha") {
                    print("Select matcha")
                }
                Button("White bean") {
                    print("Select white bean")
                }
            }
            Menu("Pattern 2") {
                Button("White bean") {
                    print("Select white bean")
                }
                Button("Red bean") {
                    print("Select red bean")
                }
                Button("Matcha") {
                    print("Select matcha")
                }
            }
            Menu("Pattern 3") {
                Button("Matcha") {
                    print("Select matcha")
                }
                Button("White bean") {
                    print("Select white bean")
                }
                Button("Red bean") {
                    print("Select red bean")
                }
            }
        }
    }
}

Commands内のViewも外へ逃してもいいかもしれません。

メニューにチェックマークを付ける

Commands内の項目の表示はViewを指定します。
これまでButtonと記載していた箇所をToggleにするとToggleで指定するBool値がtrueの場合にチェックマークが表示されるようになります。

HelloWorldApp.swift
@main
struct HelloWorldApp: App {
    @State private var isChecked: Bool = false
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .commands {
            CommandMenu("Dango") {
                Toggle(isOn: $isChecked) {
                    Text("Delicious")
                }
                .keyboardShortcut(KeyEquivalent("D"), modifiers: EventModifiers([.command]))
            }
        }
    }
}

SwiftUI toggle menu false
SwiftUI toggle menu true

Viewごとに個別に持っている値によってメニューの内容を変える

前の節でToggleを使うことでBool値によってメニューにチェックが付いたり消えたりする仕組みがわかりましたが、場合によってはこれでは問題があるかもしれません。
チェック状態を保持するBoolの変数はAppが持っています。
アプリケーションがシングルウィンドウのようなアプリケーション全体で同じ値を共有する場合は問題ありませんが、例えば画像ビューアのようなマルチウィンドウのアプリケーションでView毎に値を持っておりアクティブなウィンドウに紐づくViewの値によってメニューのチェックの有無を変えたい場合に困ります。

例を挙げると、ビデオプレイヤーでウィンドウ毎にコンテンツが異なる場合は、アクティブなウィンドウに紐づくViewで再生状態を持っててBool値がtrueの場合は「停止する」、falseの場合は「再生する」という文言切り替える処理や、メニューの有効/無効の切り替え処理はViewから値を取得することで可能になります。
メニュー側で再生状態が切り替わりBool値をアクティブなウィンドウに紐づくView側に通知して設定する必要があります。

こうしたメニュー表示時はView側の値を参照し、逆にメニュー側で値が変わった場合はView側に通知するような仕組みがFocusedValueで可能になります。

まずはアプリケーションで必要な処理を定義します。
今回はBool値をViewから取得する処理とViewへBool値を設定する処理が必要なので以下のように2つ記述します。

HelloWorldApp.swift
struct FocusedAction {
    // To Scene
    var isChecked: @MainActor @Sendable () -> Bool
    
    // To View
    var setChecked: @MainActor @Sendable () -> Void
}

続いて以下のようにextensionを記載します。これはアプリケーションの要件に関わらず毎回同様の処理です。

HelloWorldApp.swift
struct FocusedActionKey: FocusedValueKey {
    typealias Value = FocusedAction
}

extension FocusedValues {
    var action: FocusedAction? {
        get { self[FocusedActionKey.self] }
        set { self[FocusedActionKey.self] = newValue }
    }
}

Viewにget/setの処理を記載します。
focusedSceneValueにFocusedActionに記載した2つの処理を実装します。

ContentView.swift
struct ContentView: View {
    @State var isEnabled: Bool = false
    
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundStyle(.tint)
            Text("Hello, world!")
        }
        .padding()
        .focusedSceneValue(\.action, FocusedAction(
            isChecked: {
                isEnabled
            },
            setChecked: {
                isEnabled.toggle()
            }
        ))
    }
}

メニュー側の値を参照/通知する処理を記載します。

HelloWorldApp.swift
@main
struct HelloWorldApp: App {
    @FocusedValue(\.action) private var action

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .commands {
            CommandMenu("Dango") {
                let isChecked = Binding<Bool>(
                    get: {
                        action?.isChecked() ?? false
                    },
                    set: { _ in
                        action?.setChecked()
                    }
                )
                Toggle(isOn: isChecked) {
                    Text(isChecked.wrappedValue ? "無効にする" : "有効にする")
                }
                .keyboardShortcut(KeyEquivalent("D"), modifiers: EventModifiers([.command]))
            }
        }
    }
}

アプリケーションを実行して動作確認してみます。
既定のメニューに存在するNew Windowを選択すると新しいウィンドウが生成されます。
SwiftUI create new window

片方のウィンドウで作成したメニューコマンドを選択してチェック済みの状態に変更します。
SwiftUI FocusedValue1

もう片方のウィンドウを表に出して再度メニューを確認すると既定のチェックなしの状態であることが確認できます。
このようにウィンドウ毎に個別に値を持ち、アクティブウィンドウ側のViewの値を参照/設定することがわかります。
SwiftUI FocusedValue1

参考

Discussion