⚙️

SwiftUIのメニューバーアプリで設定画面を出す

に公開

概要

本記事は、前回の記事の続きになりメニューバーアプリで設定画面を出せる様に実装してみたいと思います。

https://zenn.dev/slowhand/articles/c421f42e8b019d

macOSバージョン毎の実装方法

実装方法としてはmacOSのバージョンによって以下の3通りある様です。

  1. macOS 14 以降
    • Settings + SettingsLink を使う方法
  2. macOS 13 以降
    • WindowGroup + openWindow(id:) を使う方法
  3. レガシーな実装 (macOS 13 未満)
    • NSApp.sendAction(_:,to:from:) + showPreferencesWindow: を使う方法

今回はmacOS 14 以降を対象している想定なので、Settings Scene + SettingsLink を使って実装していきます。

Settings + SettingsLink

Settings

https://developer.apple.com/documentation/swiftui/settings

アプリの設定を見たり変更したりするためのインターフェイスを提示するシーン

https://developer.apple.com/documentation/swiftui/settingslink

アプリによって定義された設定シーンを開くビュー

前回のプロジェクトをそのまま使って実装していきます。MenuBarExampleApp を以下に修正します。

import SwiftUI

@main
struct MenuBarExampleApp: App {
    var body: some Scene {
        MenuBarExtra(
            "MenuBar Example",
            systemImage: "menubar.rectangle"
        ) {
            Group {
                SettingsLink { Text("Preferences…") }
                Divider()
                Button("Quit") { NSApp.terminate(nil) }
            }
        }

        Settings {
            VStack {
                Text("Settings")
            }.padding(.all, 18)
        }
    }
}

実行するとアプリをクリックで開いた中に「Preferences…」ボタンがあり、押すと設定画面が表示されます。

image1.png

ただ、メニューのみのアプリの場合はこれで良いのですが、menuBarExtraStyle(.window) でクリックすると何かしらのコンテンツが表示され、右クリックで設定などのメニューが開く様なアプリの場合は違った実装になってきます。

ContextMenu内のメニューから設定画面を開く

前回の記事の ContextMenu の箇所の続きにもなりますが、NSMenuItem で「Preferences…」メニューを登録し、クリックされたら設定画面を開く様にしてみます。

MenuBarExtraAccess を使って NSMenu でメニューを作成している為、上記の様に SettingsLink を使うことはできません。。そこで設定画面を開く openSettings というアクションが用意されているので、それを使って実装していこうと思います。

https://developer.apple.com/documentation/swiftui/environmentvalues/opensettings

使う際は @Environment を使用してインスタンスを取得し、必要な場所で呼び出します。

例)

struct SettingsButton: View {
  @Environment(\.openSettings) private var openSettings

    var body: some View {
        Button("Open Settings") {
            openSettings()
        }
    }
}

これを NSMenuItem の Selector で渡さないといけないので、ちょっと工夫が必要です。今回は以下の様に実装しました。

まず MenuBarExampleApp に@Environment を使用して openSettings のインスタンスを取得します。

struct MenuBarExampleApp: App {
  @Environment(\.openSettings) private var openSettings
  // ...
}

次に Selector に渡すため以下のクラスを作成します。

class AppDelegate: NSObject {
    static let shared = AppDelegate()
    var openSettings: OpenSettingsAction?

    @objc func openPreferences(_ sender: Any?) {
        openSettings?()
    }
}

var openSettings: OpenSettingsAction? となっているのは Viewツリー外でopenSettings のインスタンスを扱うと以下の様な警告が発生してしまう為、Viewツリー内で取得したインスタンスを設定するようにしています。

警告

Accessing Environment<OpenSettingsAction>'s value outside of being installed on a View. This will always read the default value and will not update.

最終的には以下の様な実装になりました。

import SwiftUI
import MenuBarExtraAccess

@main
struct MenuBarExampleApp: App {
    @Environment(\.openSettings) private var openSettings

    @State private var isPresented = false
    @State private var statusItem: NSStatusItem?

    var body: some Scene {
        MenuBarExtra {
            Text("Content")
        } label: {
            Image(systemName: "menubar.rectangle")
                .onAppear {
                    AppDelegate.shared.openSettings = openSettings
                }
        }
        .menuBarExtraStyle(.window)
        .menuBarExtraAccess(isPresented: $isPresented) { item in
            statusItem = item
            addRightClickMonitor()
        }

        Settings {
            VStack {
                Text("Settings")
            }.padding(.all, 18)
        }
    }

    private func addRightClickMonitor() {
        guard let item = statusItem else { return }
        NSEvent.addLocalMonitorForEvents(matching: .rightMouseDown) { event in
            if event.window == item.button?.window {
                popupContextMenu(for: item)
                return nil
            }
            return event
        }
    }

    private func popupContextMenu(for item: NSStatusItem) {
        let menu = NSMenu()
        let settingsItem = NSMenuItem(
            title: "Preferences…",
            action: #selector(AppDelegate.openPreferences),
            keyEquivalent: ","
        )
        settingsItem.target = AppDelegate.shared
        menu.addItem(settingsItem)
        menu.addItem(.separator())
        menu.addItem(withTitle: "Quit", action: #selector(NSApp.terminate(_:)), keyEquivalent: "q")
        item.menu = menu
        item.button?.performClick(nil)
        item.menu = nil
    }
}

class AppDelegate: NSObject {
    static let shared = AppDelegate()
    var openSettings: OpenSettingsAction?

    @objc func openPreferences(_ sender: Any?) {
        openSettings?()
    }
}

実行するとクリックでコンテンツが表示され、右クリックのメニューから設定画面が表示されるかと思います。

image2.gif

参考URL

https://zenn.dev/mtgto/articles/70ebd7737aa068

https://github.com/orchetect/SettingsAccess

Discussion