この記事はSwiftUI Advent Calendar 2022の3日目になります。
つよつよエンジニアの皆さんに紛れて参加するのは緊張しますが、気軽に読んでいただければと思います!
はじめに
10月にリリースされたmacOS Venturaから、SwiftUIのMenuBarExtra APIが使えるようになりました。
MenuBarExtraというのは、GoogleドライブやKarabiner-Elementsなどの常駐アプリで見かける、メニューバーの右上に並んだアイコンのことです。
今までは、SwiftUIを使ったアプリでもMenuBarExtraだけはAppKitで実装しなければならず、別れを告げたはずのAppDelegateを使用する必要がありました。
SwiftUIでのMenuBarExtraの作成は、細かい部分ではAppKitほどのカスタマイズ性はないものの、割と自由度があり、コードの記述量も大幅に短縮できます。
それでは、サンプルアプリを作りながら説明していきます。
ドキュメント
↓これはメニューバーが人格を持っているページ(?)です。(かわいい)
AppKitとの比較
こちらの記事に以前までの実装方法との比較が載っていますが、かなり簡潔に書けるようになっています。
プロジェクトの作成
Xcodeを開き、SwiftUIでプロジェクトを作成します。
試しにビルドすると、デフォルトではこのようなウィンドウが表示されます。
MenuBarExtraを表示させる
<アプリ名>App.swift
を編集していきます。
import SwiftUI
@main
struct MenuBarExtraSampleApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
+ MenuBarExtra("Sample", systemImage: "star.fill") {
+ MenuView()
+ }
}
}
そして、メニューバーに表示する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の中から指定できます。
(assetsが使えるかどうかは未確認です)
おまけ:ウィンドウとして表示
import SwiftUI
@main
struct MenuBarExtraSampleApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
MenuBarExtra("Sample", systemImage: "star.fill") {
MenuView()
}
.menuBarExtraStyle(.window)
}
}
.menuBarExtraStyle(.window)
モディファイアを設定すると、メニューではなくウィンドウが表示されるようになります。
テキストしか表示していないので違いが分かりづらいかもしれませんが、こんな感じです。
常駐アプリとして設定する
<アプリ名>App.swift
から、WindowGroup
を削除します。
ContentView.swift
は使わないので消してしまって構いません。
import SwiftUI
@main
struct MenuBarExtraSampleApp: App {
var body: some Scene {
- WindowGroup {
- ContentView()
- }
MenuBarExtra("Sample", systemImage: "star.fill") {
MenuView()
}
}
}
しかしこのままではDockにアイコンが表示されてしまうので、以下のように編集し、Application is agent (UIElement)
をYES
にします。
メニューバーに表示する内容をカスタムする
先ほどの書き方ではSF Symbolsのアイコンを表示することしかできませんでしたが、以下のように書くことでカスタムできます。
import SwiftUI
@main
struct MenuBarExtraSampleApp: App {
var body: some Scene {
MenuBarExtra {
MenuView()
} label: {
Image(systemName: "message.fill")
Text("10件")
}
}
}
Image
とText
以外に何が使えるかは確認できていません。
メニューの内容を作る
MenuView.swift
を編集していきます。
ちなみに、メニュー部分は従来のCommandMenu
の場合と同じように表示されるようです。
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
でループを回すことで、ボタンを並べています。
ちなみに、メニューが表示されている間にプロパティが更新されても、メニューを再度開くまでUIは再描画されないようです(要出典)。
AppKitでは表示中にメニューを書き換えることもできましたが、たまに表示がバグるのと、操作中にメニューが変化する挙動もあまり良くない気がするので、特に問題はないと思います。
メニューを入れ子にする
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
を使うことで、入れ子にできます。
設定画面を作る
<アプリ名>App.swift
にSettings
を追加します。
import SwiftUI
@main
struct MenuBarExtraSampleApp: App {
var body: some Scene {
MenuBarExtra {
MenuView()
} label: {
Image(systemName: "message.fill")
Text("10件")
}
+ Settings {
+ SettingsView()
+ }
}
}
続いて、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
を使って、ユーザー名が保存されるようにしてみました。
常駐アプリは左上のメニューが表示されないのでまだ設定を開く方法がありませんが、これからボタンを作っていきます。
よくあるボタンを作る
メニューバー常駐アプリでよく見かける、こんな感じのボタンを実装してみます。
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)
}
}
いい感じになりました。
それぞれのボタンもちゃんと動きます。
設定画面はタブが一つしかなくて寂しいですね…
おまけ:メニューにAppStorage
の情報を表示してみる
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)
}
}
// 省略
}
// 省略
}
設定画面で変更した値がメニューに反映できるようになりました。
おわりに
AppKitではUIの再描画を考慮する必要がありましたが、SwiftUIでは基本的に勝手にやってくれるのでとても楽です。
もちろん、SwiftUI特有の罠というのも存在しますが、シンプルなメニューバーアプリを作るのであればSwiftUIはかなり使いやすいと感じました。
この記事が皆さんのアプリ開発の参考になれば幸いです。
もしよければ最後に💚を押していただけると嬉しいです!