ぜのぜ

しりとりしようぜのぜのぜのぜ

SwiftUIでステータスバーにメニューを追加する.NSApplicationDelegateを使う.

さて,こすりまくったEmojiGachaネタもこれで打ち止めです.今日はステータスバーにメニューを追加する話をする.

NSApplicationDelegateをSwiftUIで使う

ステータスバーにメニューを追加するのはSwiftUIの世界だけでは不可能っぽかったので,AppKitの世界とSwiftUIの世界を繋げる方法について話す.

エントリポイントになっている,Appに準拠するstructに@NSApplicationDelegateAdaptorでラップしたプロパティを定義する.そのproperty wrapperの引数として,別に定義したNSApplicationDelegateに準拠する型のメタタイプを渡す.

今回はAppKitだったが,UIKitの世界とつなげたい場合は@UIApplicationDelegateAdaptorを使えばいい.もちろんNSApplicationDelegateUIApplicationDelegateに変える.

// SomeApp.swift
import SwiftUI

@main
struct SomeApp: App {
    @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}
// AppDelegate.swift

import AppKit

class AppDelegate: NSObject, NSApplicationDelegate {}

ステータスバーにメニューを追加する

これでAppKitの世界のコードを実行できるようになったので,applicationDidFinishLaunchingでメニューを追加する.(それが定石っぽいので

NSStatusBar.systemでシステムワイドなステータスバーが取れるので,それに対してstatusItem(withLength:)を呼ぶとメニューが作られる.一つ注意が必要で,statusItem(withLength:)は新しく作られたNSStatusItemインスタンスを返すが,その参照はユーザで持たないといけない.

The receiver does not retain a reference to the status item, so you need to retain it. Otherwise, the object is removed from the status bar when it is deallocated.

最小コードはこんな感じ.

ステータスバーのメニュー

import AppKit

class AppDelegate: NSObject, NSApplicationDelegate {
    var statusItem: NSStatusItem!

    func applicationDidFinishLaunching(_ notification: Notification) {
        statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)

        let image = NSImage(systemSymbolName: "face.smiling", accessibilityDescription: "Some App's status menu")!
        statusItem.button?.image = image

        let menu = NSMenu()

        let quitMenuItem = NSMenuItem.init(title: "Quit SomeAPp", action: #selector(quit), keyEquivalent: "q")
        menu.addItem(quitMenuItem)

        statusItem.menu = menu
    }

    @objc func quit() {
        print("quit")
    }
}

EmojiGachaで書いたコードはこんな感じ.ビューが入れられるしインタラクションもちゃんと動くので割と何でもできる.

EmojiGachaのステータスバーのメニュー

import AppKit
import SwiftUI

class AppDelegate: NSObject, NSApplicationDelegate {
    var statusItem: NSStatusItem!

    func applicationDidFinishLaunching(_ notification: Notification) {
        statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)

        let image = NSImage(systemSymbolName: "face.smiling", accessibilityDescription: "EmojiGacha's status menu")!
        image.isTemplate = true
        statusItem.button?.image = image

        statusItem.menu = {
            let menu = NSMenu()

            let customView = MenuView()
            let hostingView = NSHostingView(rootView: customView)
            hostingView.setFrameSize(.init(width: 320, height: 100))
            let customViewMenuItem = NSMenuItem.init()
            customViewMenuItem.view = hostingView
            menu.addItem(customViewMenuItem)

            menu.addItem(.separator())

            let quitMenuItem = NSMenuItem.init(title: "Quit EmojiGacha", action: #selector(quit), keyEquivalent: "q")
            menu.addItem(quitMenuItem)

            return menu
        }()
    }

    @objc private func quit() {
        NSRunningApplication.current.terminate()
    }
}

struct MenuView: View {
    @State var emoji: Emoji = Emoji.random()

    var body: some View {
        VStack {
            Text(emoji.image)
                .font(.system(size: 40))
            Text(emoji.description)
                .font(.system(size: 15))
                .padding([.leading, .trailing])
            Button("gacha") {
                gacha()
            }
        }
    }

    func gacha() {
        emoji = Emoji.random()

        let pboard = NSPasteboard.general
        pboard.clearContents()
        pboard.setString(emoji.image, forType: .string)
    }
}