SwiftUIで作るmacOSメニューバーアプリ実践ガイド#
Funny Day Calendar——ちょっと変わった記念日を教えてくれるmacOSアプリ——を作っているとき、「カレンダーが載っているだけのウィンドウ」は製品として成立しないとすぐに気づきました。今日の記念日は、アプリをわざわざ開いて確認するものではなく、ちらりと見たいものです。つまり、それはメニューバーに置くべき情報だということです。
こうして、このプロジェクトには同じコンテンツを表示する3つの場所が生まれました。カレンダーと記念日の詳細を表示するメインウィンドウ、今日の記念日を示すメニューバーアイコン(MenuBarExtra)、そしてデスクトップウィジェットです。以下は、これをSwiftUIで組み立て、Mac App Store(id 6773287898)に公開するまでに学んだことです。
そもそもなぜメニューバーアプリなのか#
macOSのメニューバーは「小さなウィンドウ」ではなく、アプリとして存在するまったく別のモードです。ユーザーはDockからあなたのアプリを起動したり、Cmd+Tabで切り替えたりしません。視界の端でアイコンに気づくだけです。これは、そこに置くべきコンテンツの条件を変えます——一目で把握できる情報量ちょうど、それ以上は1バイトも要らない、ということです。
Funny Day Calendarにとってこれは、メニューバーのアイコンには今日の記念日を一行だけ表示し、記念日の履歴や検索、テーマや背景の設定はすべてメインウィンドウに残す、という意味でした。この切り分けは、実装の細部よりもずっと重要でした。とはいえ実装も一筋縄ではいかないので、まずはそこから見ていきます。
MenuBarExtra: アイコンからコンテンツまで#
SwiftUI以前、メニューバーにアイコンを置く唯一の方法はAppKitのNSStatusItemでした——動作はしますが、NSPopoverやNSMenuを手動で管理する必要があり、冗長でした。macOS 13から、Appleは宣言的なシーンタイプMenuBarExtraを追加し、この定型作業の大部分を取り除きました。
基本的な実装は数行に収まります。
import SwiftUI
@main
struct FunnyDayCalendarApp: App {
@StateObject private var holidayStore = HolidayStore()
var body: some Scene {
WindowGroup {
CalendarWindowView()
.environmentObject(holidayStore)
}
MenuBarExtra {
MenuBarContentView()
.environmentObject(holidayStore)
} label: {
MenuBarLabelView(holiday: holidayStore.todayHoliday)
}
.menuBarExtraStyle(.window)
}
}ポイントは、MenuBarExtraが同じAppの中で普通のWindowGroupと並べて宣言できることです。これらは1つのEnvironmentObjectを共有する、独立した2つのシーンタイプです。そのため、HolidayStoreに一度だけ読み込まれた今日の記念日は、メインウィンドウとメニューバーの両方で同期して利用できます——読み込みロジックを重複させる必要はありません。
メニューバーのラベルには長いテキスト文字列を避けるべきです。スペースが足りなくなるとmacOSはメニューバー項目を切り詰めてしまうため、SF Symbolと短いテキストの組み合わせのほうがずっとすっきりします。
struct MenuBarLabelView: View {
let holiday: Holiday?
var body: some View {
if let holiday {
Label(holiday.shortTitle, systemImage: "calendar.badge.clock")
} else {
Image(systemName: "calendar")
}
}
}.window と .menu: どちらを選ぶか#
MenuBarExtraにはコンテンツの表示スタイルが2つあり、その違いは見た目の問題ではなく、アーキテクチャ上の問題です。
| 基準 | .menu(デフォルト) | .window |
|---|---|---|
| 描画されるもの | 実際のNSMenuとメニュー項目 | ポップアップウィンドウ内の任意のSwiftUIコンテンツ |
| 操作性 | Button・Toggle・Divider・サブメニューのみ | List、スライダー、画像、カスタムレイアウトなど任意のview |
| runloopのブロック | メニューが開いている間ブロックされ、アプリ内のアニメーションやタイマーが一時停止する | ブロックされない。通常のSwiftUIシーンと同じ挙動 |
| メニューバーの自動非表示 | 想定どおりに動作する | フルスクリーンアプリの上でポップアップを開いたままにするとメニューバーが再表示されるという既知のバグがある |
| 向いている用途 | 「更新」「設定」「終了」などのシンプルなコマンド | カレンダーや記念日カード、テーマなどのリッチなプレビュー |
Funny Day Calendarでは選択は明白でした。今日の記念日はコマンドではなく、イラストと説明文を持つミニカードなので、.windowのほうが合っていました。その代償として、ウィンドウの外側をクリックしたときの閉じ方は自分で設計する必要があり、フルスクリーンモードでのメニューバー自動非表示にも注意が必要です。これはApple Developerフォーラムでも未解決のトピックであり、2026年時点でもシステムレベルのきれいな解決策はありません。UXにとって重要な挙動であれば、NSWindowのデリゲートで手動対応することになります。
SettingsとDockアイコン: ライフサイクルの細かな話#
アプリにMenuBarExtraが加わると、そもそもDockに表示すべきかという疑問が出てきます。Funny Day Calendarでは「通常は表示する」が答えです。純粋なバックグラウンドユーティリティではなく、きちんとしたウィンドウを持つアプリだからです。しかし、ミニマルさを好むユーザーは、Dockアイコンを隠してメニューバーだけで暮らせるオプションを期待します。
技術的にはNSApplication.ActivationPolicyで解決します。
import AppKit
enum DockVisibility {
static func setHidden(_ hidden: Bool) {
NSApp.setActivationPolicy(hidden ? .accessory : .regular)
}
}.accessoryは、Info.plistのLSUIElementキーと同じようにDockアイコンとアプリスイッチャーの項目を取り除きますが、これはプログラムから行われます。つまり「Dockに表示する」というトグルを、ビルド時に固定するのではなくアプリのUIに持たせることができます。
もう一つのつまずきポイントがSettingsシーンです。SwiftUIは宣言的なAPIを提供しています。
Settings {
SettingsView()
.environmentObject(holidayStore)
}そして、それを開くためのシステムボタンSettingsLinkがあります。しかし実際には、アプリが.accessoryとして動作している状態でMenuBarExtraのウィンドウからSettingsLinkを呼び出しても、設定ウィンドウが必ずしも最前面に来るとは限りません——メニューバー側がアクティブなままで、設定ウィンドウが他のアプリの背後に開いてしまうことがあります。実用的な回避策は、設定を開く前に明示的にアプリをアクティブ化することです。
Button("設定…") {
NSApp.activate(ignoringOtherApps: true)
NSApp.sendAction(
Selector(("showSettingsWindow:")),
to: nil,
from: nil
)
}私が書いた中で一番エレガントなコードとは言えませんが、Funny Day CalendarをテストしたすべてのmacOSバージョンで安定して動作しています。
デスクトップウィジェット: もう一つのアプリを作らずにWidgetKitで#
macOS Sonoma以降、WidgetKitのウィジェットは通知センターだけでなくデスクトップに直接置けるようになり、macOS TahoeではLiquid Glassの背景が壁紙に合わせて透けるようになりました。Funny Day Calendarにとってこれは、iOSと同じウィジェットターゲットをそのまま使えるということでした——同じWidgetKind、TimelineProvider、記念日カードのSwiftUIレイアウトに、デスクトップ対応が加わるだけです。
本当のエンジニアリング上の課題は描画ではなく、データの受け渡しです。ウィジェットエクステンションは独立したプロセスであり、メインアプリのHolidayStoreには直接アクセスできません。データはApp Groupを通して境界を越えます。
struct HolidayProvider: TimelineProvider {
func getTimeline(
in context: Context,
completion: @escaping (Timeline<HolidayEntry>) -> Void
) {
let holiday = SharedHolidayStore.shared.todayHoliday()
let entry = HolidayEntry(date: .now, holiday: holiday)
// 次の深夜0時に更新——今日の記念日は1日1回変わる
let midnight = Calendar.current.startOfDay(
for: .now.addingTimeInterval(86_400)
)
completion(Timeline(entries: [entry], policy: .after(midnight)))
}
func placeholder(in context: Context) -> HolidayEntry { .placeholder }
func getSnapshot(
in context: Context,
completion: @escaping (HolidayEntry) -> Void
) {
completion(.placeholder)
}
}メインアプリが設定でテーマや背景を変更するなどしてデータを更新したときは、システムに明示的にタイムラインの再構築を依頼する必要があります。
import WidgetKit
WidgetCenter.shared.reloadAllTimelines()この呼び出しがなければ、ウィジェットは次の予定された更新まで古いスナップショットを表示し続けます。WidgetKitの更新予算はバッテリーを節約するために意図的に制限されているため、「そのうち自動で更新される」とは考えられません。
App SandboxとMac App Storeへの道#
ここでよくある誤解を解いておく価値があります。ノータライズ(notarytool)は、Developer IDを通じてMac App Store外で配布されるアプリのための手続きです。Mac App Store経由で配布されるアプリは、独立したステップとしてのノータライズは通りません——その代わりに、App Sandboxが必須となり、App Reviewを通過します。
App SandboxはFunny Day Calendarにとってかなり寛容なものでした。アプリにはネットワークアクセスが不要で(記念日データはローカルにあります)、そのため必要なentitlementsは最小限で済みました。
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.pro.dodecaidr.funnydaycalendar</string>
</array>
</dict>
</plist>ここでのApp Group(application-groups)の指定は不可欠です。サンドボックス内ではお互いのファイルに直接アクセスできないため、メインアプリとウィジェットエクステンションがデータをやり取りする経路が、まさにこれだからです。
もう一つ確認しておくべきことがあります。メインターゲットとウィジェットエクステンションの両方が、同じApp Groupと同じTeam IDに属している必要があります。そうでないとUserDefaults(suiteName:)が黙ってnilを返し、なぜかを説明する一行のログもないままウィジェットは空白のままになります。
メニューバーアプリのApp Review: 実際に重要だったこと#
Mac App StoreのApp Reviewの観点からは、MenuBarExtraアプリは特別なカテゴリーではありません。ただし、機能の一部がメインウィンドウの外に存在するからこそ表面化したニュアンスがいくつかありました。
- 最低限の機能性(ガイドライン4.2)。 もしFunny Day Calendarがメニューバーアイコンだけで実質的なメインウィンドウを持たなかったら、それは教科書どおりの却下候補に見えたはずです——「単独アプリとして正当化するには機能が不十分」という理由です。カレンダーと記念日の履歴、設定を備えた完全なウィンドウは、UX上の判断であると同時に、レビューでの指摘に対する保険でもあります。
- スクリーンショットはあくまでメインウィンドウについてのもの。 Mac App Storeのリスティング用マーケティングスクリーンショットは、システムが求める画面サイズで主要なユーザー体験を示す必要があります——メニューバーのアイコンだけでは、実際のインターフェースの画面に代わることはできません。
- レビュアー向けのメモは無駄にはならない。 メインウィンドウに現れない機能(メニューバーアイコンやデスクトップウィジェット)については、レビュアー向けのノートに明示しておく価値があります。初回起動ですぐには分からないものを、Appleのレビュアーが探し回らずに済むからです。
これらはどれもルールを回避するための小手先の工夫ではありません。むしろ、通常のユーザーが目にするのとまったく同じ製品の姿を、レビュアーにも見てもらうための配慮です。
まとめ#
SwiftUIのMenuBarExtraは、かつてAppKitで手作業が必要だった定型作業の大部分を取り除いてくれますが、アーキテクチャ上の判断まではなくしてくれません。どのスタイルを選ぶか、メインウィンドウ・メニューバー・ウィジェットの間でどうデータを同期させるか、Dockアイコンや設定をどう扱うか、といった判断です。Funny Day Calendarにとって、ウィンドウ・メニューバー・ウィジェットというこの組み合わせは、単なる機能のチェックリストではなく、「今日は何の記念日か」という同じシンプルな事実を示す3通りの方法になりました。
実際にどんな仕上がりになったか見てみたい方は、アプリ名はFunny Day Calendarで、Mac App Storeで公開されています。



