【macOS】SwiftUIだけでメニューバー常駐アプリを作ろう

この記事は[SwiftUI Advent Calendar 2022](https://qiita.com/advent-calendar/2022/swiftui)の3日目になります。
つよつよエンジニアの皆さんに紛れて参加するのは緊張しますが、気軽に読んでいただければと思います！

## はじめに

10月にリリースされたmacOS Venturaから、SwiftUIのMenuBarExtra APIが使えるようになりました。

MenuBarExtraというのは、GoogleドライブやKarabiner-Elementsなどの常駐アプリで見かける、メニューバーの右上に並んだアイコンのことです。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/626965/80ecbd25-43d0-007c-cdc9-956b5ebcba9e.png)

今までは、SwiftUIを使ったアプリでもMenuBarExtraだけはAppKitで実装しなければならず、別れを告げたはずのAppDelegateを使用する必要がありました。

SwiftUIでのMenuBarExtraの作成は、細かい部分ではAppKitほどのカスタマイズ性はないものの、割と自由度があり、コードの記述量も大幅に短縮できます。

それでは、サンプルアプリを作りながら説明していきます。

### ドキュメント

https://developer.apple.com/documentation/SwiftUI/MenuBarExtra

↓これはメニューバーが人格を持っているページ(?)です。（かわいい）

https://developer.apple.com/jp/news/?id=2293tuu9

## AppKitとの比較

こちらの記事に以前までの実装方法との比較が載っていますが、かなり簡潔に書けるようになっています。

https://qiita.com/SNQ-2001/items/1ec7bc82dad77a0a206e

## プロジェクトの作成

Xcodeを開き、SwiftUIでプロジェクトを作成します。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/626965/a1d5bd3c-422a-6644-8bcc-e0e187b8ce19.png)

試しにビルドすると、デフォルトではこのようなウィンドウが表示されます。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/626965/908d4bc1-c7bd-36c4-013b-ddd71125e7a8.png)

## MenuBarExtraを表示させる

`<アプリ名>App.swift`を編集していきます。

```diff_swift:<アプリ名>App.swift
import SwiftUI

@main
struct MenuBarExtraSampleApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
+       MenuBarExtra("Sample", systemImage: "star.fill") {
+           MenuView()
+       }
    }
}
```

そして、メニューバーに表示する`MenuView.swift`を作成します。

```swift:MenuView.swift
import SwiftUI

struct MenuView: View {
    var body: some View {
        Text("Hello, Swift!")
    }
}

struct MenuView_Previews: PreviewProvider {
    static var previews: some View {
        MenuView()
    }
}
```

これらを追加してビルドしてみると、先ほどのウィンドウに加えて、メニューバーにアイコンが表示されます。

このアイコンは従来の`Image`同様、[SF Symbols](https://developer.apple.com/sf-symbols/)の中から指定できます。
（assetsが使えるかどうかは未確認です）


![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/626965/c0dbf4aa-e7ea-56e6-7e67-cd1671e51a52.png)

### おまけ：ウィンドウとして表示

```swift:<アプリ名>App.swift
import SwiftUI

@main
struct MenuBarExtraSampleApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        MenuBarExtra("Sample", systemImage: "star.fill") {
            MenuView()
        }
        .menuBarExtraStyle(.window)
    }
}
```

`.menuBarExtraStyle(.window)`モディファイアを設定すると、メニューではなくウィンドウが表示されるようになります。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/626965/e85c10bd-bc9d-cd86-a148-320830b4f3e8.png)

テキストしか表示していないので違いが分かりづらいかもしれませんが、こんな感じです。

## 常駐アプリとして設定する

`<アプリ名>App.swift`から、`WindowGroup`を削除します。
`ContentView.swift`は使わないので消してしまって構いません。

```diff_swift:<アプリ名>App.swift
import SwiftUI

@main
struct MenuBarExtraSampleApp: App {
    var body: some Scene {
-       WindowGroup {
-           ContentView()
-       }
        MenuBarExtra("Sample", systemImage: "star.fill") {
            MenuView()
        }
    }
}
```

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/626965/ff9a20a0-e73c-a3f9-4a76-1fbadec3304c.png)

しかしこのままではDockにアイコンが表示されてしまうので、以下のように編集し、`Application is agent (UIElement)`を`YES`にします。


![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/626965/54ede46c-a1ef-2ef0-7383-aa328f7b1327.png)


## メニューバーに表示する内容をカスタムする

先ほどの書き方ではSF Symbolsのアイコンを表示することしかできませんでしたが、以下のように書くことでカスタムできます。

```swift
import SwiftUI

@main
struct MenuBarExtraSampleApp: App {
    var body: some Scene {
        MenuBarExtra {
            MenuView()
        } label: {
            Image(systemName: "message.fill")
            Text("10件")
        }
    }
}
```

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/626965/161c3449-f644-57fa-6fe3-22a0c9635abd.png)

`Image`と`Text`以外に何が使えるかは確認できていません。

## メニューの内容を作る

`MenuView.swift`を編集していきます。

ちなみに、メニュー部分は従来の[`CommandMenu`](https://developer.apple.com/documentation/swiftui/commandmenu)の場合と同じように表示されるようです。

```swift:MenuView.swift
struct MenuView: View {
    @State private var friendNames = ["太郎", "次郎", "花子", "Swift太郎"]
    
    var body: some View {
        ForEach(friendNames, id: \.self) { friendName in
            Button {
                // 処理
            } label: {
                Image(systemName: "person.fill")
                Text(friendName)
            }
        }
    }
}
```

`ForEach`でループを回すことで、ボタンを並べています。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/626965/6738cc3d-8750-5037-e7d6-f5d4cafb99df.png)

ちなみに、メニューが表示されている間にプロパティが更新されても、メニューを再度開くまでUIは再描画されないようです（要出典）。

AppKitでは表示中にメニューを書き換えることもできましたが、たまに表示がバグるのと、操作中にメニューが変化する挙動もあまり良くない気がするので、特に問題はないと思います。

## メニューを入れ子にする

```swift:MenuView.swift
struct MenuView: View {
    @State private var friendNames = ["太郎", "次郎", "花子", "Swift太郎"]
    
    var body: some View {
        ForEach(friendNames, id: \.self) { friendName in
            Menu {
                Button("電話する") {
                    // 処理
                }
                Button("メッセージを送る") {
                    // 処理
                }
            } label: {
                Image(systemName: "person.fill")
                Text(friendName)
            }
        }
    }
}
```

`Menu`を使うことで、入れ子にできます。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/626965/c168ea8d-5373-c54f-e5c4-66d2aec8f815.png)

## 設定画面を作る

`<アプリ名>App.swift`に`Settings`を追加します。

```diff_swift:<アプリ名>App.swift
import SwiftUI

@main
struct MenuBarExtraSampleApp: App {
    var body: some Scene {
        MenuBarExtra {
            MenuView()
        } label: {
            Image(systemName: "message.fill")
            Text("10件")
        }
+       Settings {
+           SettingsView()
+       }
    }
}
```

続いて、`SettingsView.swift`を作成します。

```swift:SettingsView.swift
import SwiftUI

struct SettingsView: View {
    @AppStorage("yourName") var yourName = "User"
    
    var body: some View {
        TabView {
            Form {
                TextField("あなたの名前", text: $yourName)
            }
            .padding(20)
            .tabItem {
                Label("一般", systemImage: "slider.horizontal.3")
            }
        }
        .frame(width: 500, height: .none)
    }
}

struct SettingsView_Previews: PreviewProvider {
    static var previews: some View {
        SettingsView()
    }
}
```

`AppStorage`を使って、ユーザー名が保存されるようにしてみました。

常駐アプリは左上のメニューが表示されないのでまだ設定を開く方法がありませんが、これからボタンを作っていきます。

## よくあるボタンを作る

メニューバー常駐アプリでよく見かける、こんな感じのボタンを実装してみます。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/626965/4cb50555-1f3b-6efe-0194-8f08e8925aa1.png)

`MenuView.swift`を以下のように編集します。

```swift:MenuView.swift
struct MenuView: View {
    @State private var friendNames = ["太郎", "次郎", "花子", "Swift太郎"]
    
    var body: some View {
        ForEach(friendNames, id: \.self) { friendName in
            Menu {
                Button("電話する") {
                    // 処理
                }
                Button("メッセージを送る") {
                    // 処理
                }
            } label: {
                Image(systemName: "person.fill")
                Text(friendName)
            }
        }
        Divider()
        Button("設定...") {
            showSettingsWindow()
        }
        .keyboardShortcut(",")
        Button("MenuBarExtraSampleについて") {
            showAbout()
        }
        Button("MenuBarExtraSampleを終了...") {
            quitApp()
        }
        .keyboardShortcut("Q")
    }
    
    private func showSettingsWindow() {
        NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
        NSApp.activate(ignoringOtherApps: true)
    }
    
    private func showAbout() {
        NSApp.activate(ignoringOtherApps: true)
        NSApp.orderFrontStandardAboutPanel()
    }
    
    private func quitApp() {
        NSApplication.shared.terminate(nil)
    }
}
```

いい感じになりました。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/626965/036aa801-9ee3-da14-5175-f3ce73b874e0.png)

それぞれのボタンもちゃんと動きます。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/626965/4d2ace21-842f-6cb3-985a-098dbd9d47fb.png)

設定画面はタブが一つしかなくて寂しいですね…

## おまけ：メニューに`AppStorage`の情報を表示してみる

```diff_swift:MenuView.swift
struct MenuView: View {
    @State private var friendNames = ["太郎", "次郎", "花子", "Swift太郎"]
+   @AppStorage("yourName") var yourName = "User"
    
    var body: some View {
+       Text("あなたの名前：\(yourName)")
        ForEach(friendNames, id: \.self) { friendName in
            Menu {
                Button("電話する") {
                    // 処理
                }
                Button("メッセージを送る") {
                    // 処理
                }
            } label: {
                Image(systemName: "person.fill")
                Text(friendName)
            }
        }
        // 省略
    }
    // 省略
}
```

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/626965/f16fdbf7-ea05-795a-ae87-7a1863715a7e.png)

設定画面で変更した値がメニューに反映できるようになりました。

## おわりに

AppKitではUIの再描画を考慮する必要がありましたが、SwiftUIでは基本的に勝手にやってくれるのでとても楽です。

もちろん、SwiftUI特有の罠というのも存在しますが、シンプルなメニューバーアプリを作るのであればSwiftUIはかなり使いやすいと感じました。

この記事が皆さんのアプリ開発の参考になれば幸いです。
もしよければ最後に💚を押していただけると嬉しいです！