SwiftUIを使ってmacOSステータスバーアプリをつくる方法

サービス開発部Backlog課の松本です。アプリを作るよりパスタを作るほうが楽しく感じられる今日この頃ですが、今回はmacOSアプリ開発の話をします。

はじめに

「ステータスバーアプリ」と言われてもピンとこないかもしれませんが、ここではKapKarabiner-Elementsのような、ステータスバーに常駐しているようなアプリを指しています (macOSユーザガイドAPIリファレンスを見る限り、メニューバーのステータスメニューが並んでいる領域をステータスバーと呼ぶようですので、それに合わせています)。

筆者はObjective-C時代にiOSアプリをつくったがSwiftUIやmacOS開発はちょっと触っただけ、というくらいのスキルでしたがステータスバーアプリを作ることができました。それを通して得られた知見を、モックのお天気アプリを作っていきながら紹介します。

本記事はプログラミング経験はあるがXcodeは使ったことがないという方を読者として想定していますが、iOSやSwiftでの開発経験があればより理解しやすいと思います。

※ 本記事はNuCon mini 2022 Springで発表した内容の一部を加筆・訂正してブログ化したものです。

ソースコード

長い文章を読む時間がないといった方向けにソースコードをGitHubに上げています。

お天気アプリをつくる

次のようなモックのお天気アプリをチュートリアル形式で解説を交えながらつくっていきたいと思います。

  • 起動するとステータスバーにアイコンが出る
  • メインウィンドウは出さない
  • ドックにアプリのアイコンを出さない
  • ステータスバーのアイコンをクリックするとポップオーバーが出る
  • ステータスバーのアイコンを右クリックするとメニューが出る
  • 右クリックメニューからアプリを終了できる
  • 右クリックメニューからアプリの設定を表示できる

モックなので、ネットワーク上からお天気データを取得するといった機能は作りません。

AppKitとSwiftUI

最初にAppleが提供しているmacOSで使えるUIフレームワークである、AppKitとSwiftUIについて簡単に紹介します。

AppKit

macOS 10.0から使える、MVCアーキテクチャをベースとしたUIフレームワークです。元々はObjective-Cのみをサポートしていましたが、2016年にSwiftが導入されてからはSwiftでも使えるようになっています。

MacでサポートされたのはmacOS 10.0 (2001年) からですので20年以上、その元になったOpenStepでのAppKitが公開されたのは1994年の9月、その大元のNeXTSTEPは1989年なので、30年ほど使われているフレームワークです (歴史についての詳細はCocoa Fundamentals GuideのBit of Historyを参照してください)。AppKitのクラスやプロトコルにはNS (NeXTSTEPの頭文字) のプレフィックスが付いていますので、ブログ記事中のコードを読むときに気にしてみると理解がしやすいかもしれません。

SwiftUI

Appleが2019年に発表した新しいUIフレームワークで、現在AppleがサポートしているすべてのOSで利用可能です。Swift 5.1で導入された様々な言語機能を活用して宣言型シンタックスとして書けるようになっているのが特徴です。

最初からサポートされているAppKitではmacOSのほとんどの機能が使えます。一方、SwiftUIでは実現できない機能がいくつかあります。今回のアプリを例にすると、macOSのステータスバーまわりの操作などはSwiftUIだけでは実現できません。

そこで、今回は使える部分はSwiftUIを使いつつ、SwiftUIでは実現できない部分をAppKitを使ってカバーするというアプローチをとっていきます。

Xcodeで新規プロジェクトを作る

ではアプリをつくっていきましょう。Xcodeを起動し、新規プロジェクトをMultiplatformのAppで作成します。アプリ名はWeatherSampleとします。コンパイルターゲットがmacOSになっていないかもしれませんので、そのときは、スキーマをWeatherSample (macOS) に変更してコンパイルターゲットをmacOSに変更しておきます。

次に、▶︎をクリックしてアプリが起動されることを確認しておきましょう。コンパイル後にアプリが起動して次のようなウィンドウが表示されます。

AppDelegateを使えるようにする

新規作成されたプロジェクトのWeatherSample.swiftのコードは次のようになっています。

import SwiftUI

@main
struct WeatherSampleApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

まず、起動した直後のタイミングでステータスバーにアイコンを追加させるようにします。

これを実現するには、プロトコルNSApplicationDelegate内にある、起動した直後のタイミングで呼ばれるメソッドapplicationDidFinishLaunching(_:)が呼ばれるようにする必要があります。

そうするために、プロパティラッパーであるNSApplicationDelegateAdaptorを使い、次のような変数宣言をクラスWeatherSampleApp内に入れます (Xcode 13から新規作成時のプロジェクトテンプレートにあったLife Cycleという項目がなくなり、Application Delegateを使っているテンプレートが指定できなくなりました)

@NSApplicationDelegateAdaptor(AppDelegate.self) var delegate

次に、この変数宣言で指定していたクラスAppDelegateをプロトコルNSApplicationDelegateに準拠するようなクラスとして作成します。

こうすることでNSApplicationDelegateAdaptorによって自動的にAppDelegateを作成した上で、NSApp.delegateに代入されます。それによってAppで起きたイベントがAppDelegateに委譲されるようになります (DelegateはAppKitでよく使われているパターンで、アプリで起きる様々なイベントを制御するための仕組みです)。

なお公式ドキュメントでは、可能なかぎりNSApplicationDelegateAdaptorを使わずにScenePhaseを使うようにとありますが、試した限りではmacOSではScenePhaseは使えないようですので (参考) 、諦めてNSApplicationDelegateAdaptorを使っています。

NSApplicationDelegateAdaptorなどはiOSで使えませんので、iOS向けにコンパイルしようとするとコンパイルエラーになります。これを防ぐためには、Compiler Control Statementsを使って#if os(macOS)と#endifで囲う必要があります。

ここまでの修正で、コードは次のようになります。

@main
struct WeatherSample: App {
#if os(macOS)
    @NSApplicationDelegateAdaptor(AppDelegate.self) var delegate
#endif
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

#if os(macOS)
class AppDelegate: NSObject, NSApplicationDelegate {

}
#endif

ステータスバーにアイコンを追加する

NSApplicationDelegateに準拠したクラスAppDelegateを作ったことによって、アプリが起動した後にクラスAppDelegate内にメソッドapplicationDidFinishLaunching(_:)があれば、それが呼ばれるようになりました。

そこで、このクラスに実際にメソッドapplicationDidFinishLaunching(_:)をつくり、このメソッドの中でステータスバーにアイコンを表示させるようにします。次のようなコードをクラスAppDelegateに書いてください。

    private var statusItem: NSStatusItem!

    func applicationDidFinishLaunching(_ notification: Notification) {
        statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
        let button = statusItem.button!
        button.image = NSImage(systemSymbolName: "leaf", accessibilityDescription: nil)
    }

メソッド内の処理を詳しく見ていきましょう。最初の行ではNSStatusBar.systemでステータスバーを取り出して、そこからstatusItem(withLength:)を呼ぶことでNSStatusItemを作っています。

macOS 11から利用可能なNSImageのイニシャライザinit(systemSymbolName:accessibilityDescription:)SF Symbols 3のアイコンを文字列で指定して、それをステータスボタンの画像に設定することで、ステータスバーにアイコンを表示させています。

なお、起動直後にstatusItem(withLength:)を呼んでおり、それ以後はnoneになる可能性はないので、NSStatusItem?ではなくNSStatusItem! (OptionalではなくImplicitlyUnwrappedOptional)にしています。同様に、NSStatusItemのbuttonは自動生成されてそれを返すとありますので ! を使ってforce-unwrapしています。

これで、アプリを起動すると葉っぱのアイコンがステータスバーに表示されるようになりました。このアイコンをクリックしても何も起こりませんし、起動直後にウィンドウが表示されたままです。

アイコンをクリックしたらポップオーバーを出す

ステータスバーのアイコンをクリックしたらポップオーバー (吹き出し) を出すようにします。メソッド applicationDidFinishLaunching(_:) の末尾に次の行を追加します。

        button.action = #selector(showPopover)

これはクリック時のアクションをするメソッド名を指定しています。#selectorはObjective-Cのメソッドやプロパティを取るための式です。

次にAppDelegate内にメソッドshowPopoverとそこで使うメンバー変数を追加します。

    private var popover: NSPopover?

    @objc func showPopover(_ sender: NSStatusBarButton) {
        if popover == nil {
            let popover = NSPopover()
            self.popover = popover
        }
        popover?.show(relativeTo: sender.bounds, of: sender, preferredEdge: NSRectEdge.maxY)
    }

これで、アイコンをクリックすることで空のポップオーバーが表示されるようになりました。

ポップオーバーの中身でSwiftUIのビューを使えるようにする

次に、ポップオーバーの中身に起動時に表示されるものと同じSwiftUIのContentViewを埋め込んで「Hello, world」が表示されるようにします。

そうするには、SwiftUIのビューをホストしてくれるコントローラであるNSHostingControllerを使い、それをNSPopoverのcontentViewControllerに代入するだけです。

    popover.contentViewController = NSHostingController(rootView: ContentView())

ちなみに、ポップオーバーが小さいと吹き出しの位置がずれるようです。

起動直後に表示されるウィンドウを消す

まだ起動直後にウィンドウが表示されてしまっているので消しましょう。一見、次のようにWeatherSampleApp内のプロパティbodyに書かれているWindowGroupを空にしたら済みそうに思えます。

    var body: some Scene {
        WindowGroup {
        }
    }

しかしこれを空にしても、起動直後に何かしらのウィンドウが表示されてしまいました。そこで、メソッドapplicationDidFinishLaunching(_:)内に、起動直後に全ウィンドウを消すというトリッキーなコードを追加することで対処します。

    NSApp.windows.forEach{ $0.close() }

ドックからアイコンを消す

ステータスバーアプリは起動しているときでもドック上にアイコンを表示させたくありません。これを実現するには次のようなコードをメソッドapplicationDidFinishLaunching(_:)内に書きます。

    NSApp.setActivationPolicy(.accessory)

setActivationPolicyの引数には次の3つの値のどれかを指定できます。今回作るアプリはメニューとドックは不要なのでaccessoryにしました。

  • regular 通常のアプリ向け。ドックに表示される
  • accessory アクセサリアプリ向け。ドックに表示されない。メニューバーも使えないがアプリをアクティブにできる。Info.plistでLSUIElementを1にしたときと同じ
  • prohibited バックグラウンドアプリ向け。ドックに表示されない。メニューバーも使えない。アプリをアクティブにできない。Info.plistでLSBackgroundOnlyを1にしたときと同じ

なお、Info.plist的には設定項目がLSUIElementとLSBackgroundOnlyの2つがあり、2 × 2の4つの設定状態があるように見えますが、setActivationPolicyの値から推察すると、両方の設定が1になる状態は想定していないようです。

また、Xcode 13からgenerate Info.plist fileという設定が有効になり、Info.plistが必要に応じて自動生成されるようになりました。そのため、Gitリポジトリで管理するファイルを増やさずに済むので、既にInfo.plistがある場合以外はコード上で設定したほうがよいと思います。

これでアプリを起動すると、起動直後にウィンドウが表示されずに、ドックにもアイコンが出なくなります。

ポップオーバー外のクリックでポップオーバーが閉じるようにする

この時点でのコードではポップオーバーがポップオーバー外のクリックで閉じません。これは、NSPopoverのbehaviorのデフォルト値applicationDefined、すなわちアプリ自身で制御する必要があるからです。

            let popover = NSPopover() // ↓ を追加
            popover.behavior = .transient
            popover.animates = false

behaviortransientにすることで、ポップオーバー外のクリックでポップオーバーが閉じられるようにします。

ただし、これだけでは上手く動きません。細かい理由はよくわかっていませんが、次のようにポップオーバーのウィンドウをキーウィンドウにしてやる必要があります (参考)。

        // popover?.show(...) の下に追加
        popover?.contentViewController?.view.window?.makeKey()

なお、NuCon mini 2022 Springのスライドでは下のコードを使っていました。これはアクティブウィンドウが別アプリだったときにウィンドウがちらつくことがありますので、上記のコードを使うことをお勧めします。

        NSApp.activate(ignoringOtherApps: true)

右クリックでメニューを出す

このままではアプリを終了させる手段がありませんので、ステータスボタンの右クリックでメニューを出せるようにしましょう。ステータスボタンが右クリックを認識できるように、メソッドapplicationDidFinishLaunching(_:)に次のコードを追加してください。

        button.action = #selector(showPopover) // ↓ を追加
        button.sendAction(on: [.leftMouseUp, .rightMouseUp])

続いてshowPopoverの最初に次のようなコードを書いてください。メニューを作ってその中に項目を作り、それを表示させているだけです。UIに表示される文字列はNSLocalizedStringを使うことで、後でローカライズしやすくしています。

    @objc func showPopover(_ sender: NSStatusBarButton) {
        guard let event = NSApp.currentEvent else { return }
        if event.type == NSEvent.EventType.rightMouseUp {
            let menu = NSMenu()

            menu.addItem(
                withTitle: NSLocalizedString("Preference", comment: "Show preferences window"),
                action: #selector(openPreferencesWindow),
                keyEquivalent: ""
            )
            menu.addItem(.separator())
            menu.addItem(
                withTitle: NSLocalizedString("Quit", comment: "Quit app"),
                action: #selector(terminate),
                keyEquivalent: ""
            )
            statusItem?.popUpMenu(menu)
            return
        }
        // これ以降は左クリックの処理

そして、上のコードでアクションとして指定されているメソッドを追加します。

    @objc func terminate() {
        NSApp.terminate(self)
    }

    @objc func openPreferencesWindow() {
    }

これで、ステータスボタンを右クリックすることでメニューが表示され、さらにQuitを選択するとアプリが終了するようになりました。

なお、popUpMenu(_:)はDeprecatedで、代わりにmenuを使うように推奨されるのですが、menuは左クリックしたときに開くメニューの設定なので右クリックのためには使えません (このメソッドが実際に使えなくなったときの対応をご存じの方がいましたら教えてください)。

右クリックから設定ウィンドウを出す

最後に右クリックメニューのPreferenceから設定ウィンドウを表示させてみましょう。

まず設定用のビューSettingsViewをSwiftUIでつくります。次のようなコードを新規のファイルもしくは既存のコードのどこかに貼ってください。

import SwiftUI

struct SettingsView: View {
    @State private var someString = ""
    @State private var useSomething = false

    var body: some View {
        Form {
            TextField("Some string", text: $someString)
            Toggle("Use something", isOn: $useSomething)
        }
        .padding(5)
    }
}

続いて、WeatherSample内のbodyに次のコードを追加します。SettingsはmacOSでのみ有効なので先ほどと同様に#if 〜 #endifで囲います。

#if os(macOS)
        Settings {
            SettingsView()
        }
#endif

アプリにメニューがあるならこれで使えるようになりますが、今回はメニューが使えなくなっています。そこで、先ほど追加したメソッドopenPreferencesWindowに次のような2行を追加します。

    @objc func openPreferencesWindow() {
        NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil)
        NSApp.windows.forEach { if ($0.canBecomeMain) {$0.orderFrontRegardless() } }
    }

プライベートなセレクタshowPreferencesWindow:をアプリにアクションとして渡すことで設定ウィンドウを表示させています。

#selectorではなくSelectorを使っているのはプライベートなセレクタを使いたいためです。 #selector式はコンパイル時にセレクタの存在をチェックをして見つからないときはエラーとします。プライベートなセレクタは存在しないものとして扱われてコンパイラでエラーになってしまうので、今回のケースでは使えません。Selectorではコンパイル時にセレクタの存在をチェックしますが、存在しないセレクタについては警告を出すだけなので使えます (警告を抑制したいので更に括弧で囲んでいます)。

ただし、これだけだと設定ウィンドウが前面に出ないので出す必要があります。それが次の行のwindows.forEachの処理です。ただし、表示されていないメニューやポップオーバーもwindowsに含まれているので、単純にすべてのウィンドウに対してorderFrontRegardlessを呼ぶとポップオーバーが意図せず表示されるようになってしまいます。

そこで、(すべての状況で期待通りに動くのか不明ですが) 設定ウィンドウ以外はcanBecomeMainがfalseとなるので、これを使って設定ウィンドウかどうかを判定し、設定ウィンドウのときにorderFrontRegardlessでウィンドウを前面に持っていくようにしています。

なお、macOS 11ではテキストフィールド上で⌘Xなどのキーボードショートカットが使えず、TextEditingCommandsを指定する必要がありましたが、macOS 12では問題なく使えるようですので今回は省略しています (NuCon mini 2022 Springのスライドではこのワークアラウンドを紹介していますので興味のあるかたはご覧ください)。

最後にContentViewをお天気アプリっぽい見ためにすれば完成です。

struct ContentView: View {
    private func icon(_ name: String) -> some View {
        Image(systemName: name)
            .font(.largeTitle)
            .symbolRenderingMode(.multicolor)
    }

    var body: some View {
        HStack {
            Spacer()
            icon("cloud.sun.rain.fill")
            Spacer()
            icon("cloud.sun.fill")
            Spacer()
            icon("sun.max.fill")
            Spacer()
        }
        .padding()
    }
}

おわりに

SwiftUIを使ってmacOSステータスバーアプリをつくる方法を紹介しました。最終的なWeatherSampleApp.swiftのソースは次のようになります。GitHubに上げているサンプルアプリのリポジトリで今回の差分をすべて見ることができます。

import SwiftUI

@main
struct WeatherSample: App {
#if os(macOS)
    @NSApplicationDelegateAdaptor(AppDelegate.self) var delegate
#endif
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
#if os(macOS)
        Settings {
            SettingsView()
        }
#endif
    }
}

#if os(macOS)
class AppDelegate: NSObject, NSApplicationDelegate {
    private var statusItem: NSStatusItem!
    private var popover: NSPopover?

    func applicationDidFinishLaunching(_ notification: Notification) {
        NSApp.setActivationPolicy(.accessory)
        NSApp.windows.forEach{ $0.close() }
        statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
        let button = statusItem.button!
        button.image = NSImage(systemSymbolName: "leaf", accessibilityDescription: nil)
        button.action = #selector(showPopover)
        button.sendAction(on: [.leftMouseUp, .rightMouseUp])
    }

    @objc func showPopover(_ sender: NSStatusBarButton) {
        guard let event = NSApp.currentEvent else { return }
        if event.type == NSEvent.EventType.rightMouseUp {
            let menu = NSMenu()

            menu.addItem(
                withTitle: NSLocalizedString("Preference", comment: "Show preferences window"),
                action: #selector(openPreferencesWindow),
                keyEquivalent: ""
            )
            menu.addItem(.separator())
            menu.addItem(
                withTitle: NSLocalizedString("Quit", comment: "Quit app"),
                action: #selector(terminate),
                keyEquivalent: ""
            )
            statusItem?.popUpMenu(menu)
            return
        }

        if popover == nil {
            let popover = NSPopover()
            popover.contentViewController = NSHostingController(rootView: ContentView())
            popover.behavior = .transient
            popover.animates = false
            self.popover = popover
        }
        popover?.show(relativeTo: sender.bounds, of: sender, preferredEdge: NSRectEdge.maxY)
        popover?.contentViewController?.view.window?.makeKey()
    }

    @objc func terminate() {
        NSApp.terminate(self)
    }

    @objc func openPreferencesWindow() {
        NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil)
        NSApp.windows.forEach { if ($0.canBecomeMain) {$0.orderFrontRegardless() } }
    }

}
#endif

このブログ記事を通じて、皆さまがmacOSアプリの開発に興味を持っていただけたら幸いです。なお、今回深くは触れなかったSwiftUIについては、Apple公式のチュートリアルなどを参考にしてください。

余談ですが、今回筆者が作ったアプリはFreee人事労務の勤怠を打刻するだけのシンプルなアプリです (社内向けアプリなので一般公開はしていません)。ここまでに書かれた内容とFreee人事労務のAPIを使って作られています。このアプリについてはまた機会がありましたら本ブログに書きたいと思います。

より良いチームワークを生み出す

チームの創造力を高めるコラボレーションツール

製品をみる