macOS   SwiftUIプログラミング   メニューバー

ホーム

【お知らせ】
SwiftUIで作った macOS Todo アプリ ToDone を100ダウンロードまで無料にしました。マニュアルページは、ToDone サポートページ です。

【本文】
メニューバーはデスクトップの一番上部に表示されるメニューです。

メニューバーは App で設定しますが、メニューアイテムで選んだコマンドの結果は View で表示されます。なので、App と View でデータを共有しなければなりません。ここではそのデータを共有するためのコードも記述しています。

このコーナーでは、任意のテキストエディタでコードを記述し、 ターミナルを使ってビルドする方法で作業を進めています。Xcode をお使いの場合は、Xcodeで作業する場合 をご一読ください。

なお、ターミナルで作業する場合も、Swift コンパイラや SwiftUI フレームワークなどを Mac にインストールするために Xcode をインストールして、一度起動させなければなりません。インストールが終われば Xcode は終了しても大丈夫です。

また、作成したアプリケーションを App Storeに提出するためのファイルにするには、Xcode を使わなければならなかったかもしれません。どこかで Xcode を使わずに作る方法を見たような気もするのですが...

私の開発環境は次のとおりです。

  • MacBook Air 2018年モデル、メモリ8G
  • macOS Monterey 12.4
  • Xcode 13.4.1
  • Swift 5.6.1

更新履歴
2022/07/13 詳しいコード説明をつけました。
2022/07/13「メニューバーから既存のメニュー項目を削除する」を追加しました。
2022/07/14「CommandGroup で消せるメニューアイテム一覧」を追加しました。

Menu Bar

今回はいろいろな技術が必要となります。メニューは App で作りますが、メニューを選んだ結果は View に反映される場合が多いです。そのためには、App と View でデータがバインディング(共有と言った方が分かりやすいかも知れません)されていなければなりません。なぜなら SwiftUI の場合、View の表示の変更は、その View とバインディングされたデータを変更することによって行われるからです。

Binding

ここでバインディングについて、整理します。

  1. macOS の場合は、一つウィンドウ(ContentView、さらにはその上位の Scene)の中でのバインディングには、@State を使います。例:ボタン
  2. 複数のウィンドウ(ContentView、Scene)でデータをバインディング(共有と言った方が分かりやすい)するには、
    1. ObservableObject プロトコルを採用したクラスを共通のデータ保管場所として定義する。
    2. そしてそのデータを参照する View の方では @ObservedObject 属性を付けたプロパティに、共通のデータのクラスを指定する。
    という工程が必要となります。例:アプリケーションの終了
  3. さて、今回のように App と View(言い換えればアプリケーション全体)でデータを共有するには、@EnvironmentObject を使うことになっています。しかし、実際にやってみると @EnvironmentObject は記述しなくても問題ありません(記述しても問題ありません)。もしかするとシンタックスシュガー(syntax sugar、糖衣構文)的な処置が施されているのかも知れませんし、SwiftUI じたいが、どんどんと進化しているのかも知れません。アプリケーション全体でデータを共有するには、実質 2 と同じコードになります。


では、実際にコーディングしていきます。次のファイルは、以前は Main.swift というファイル名でした。これからは App.swift というファイル名で統一したいと思います。Xcode で作業している場合は、プロダクト名App(プロダクト名App.swift)に次のコードを記述してください。

App.swift


/************************************
    App.swift
    copyright    :   vivacocoa.jp
    last modified:   Jul. 05, 2022
************************************/

import SwiftUI

class ColorData: ObservableObject {
    @Published var name = "Black"
    @Published var color = Color.black
}

@main
struct FooApp: App {
    @StateObject private var data = ColorData()
//  @ObservedObject var data = ColorData() でも良い
    @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    
    var body: some Scene {
        WindowGroup {
            ContentView(data: data)
                //.environmentObject(data)
        }
        
        .commands {
            
            CommandMenu("RGB") {
                Button(action: {
                    data.color = .red; data.name = "Red"
                }, label: { Text("Red")})
                Button(action: {
                    data.color = .green; data.name = "Green"
                }, label: {Text("Green")})
                Button(action: {
                    data.color = .blue; data.name = "Blue"
                }, label: {Text("Blue")})
            }
            CommandMenu("CMYK") {
                Button(action: {
                    data.color = Color(red:0, green:1, blue:1); data.name = "Cyan"
                }, label: {Text("Cyan")})
                Button(action: {
                    data.color = Color(red:1, green:0, blue:1); data.name = "Magenta"
                }, label: {Text("Magenta")})
                Button(action: {
                    data.color = .yellow; data.name = "Yellow"
                },label: {Text("Yellow")})
                Button(action: {
                    data.color = .black; data.name = "Black"
                }, label: {Text("Black")})
            }
        }
    }
}
// 次のコードの NSWindowDelegate は要りません。
class AppDelegate: NSObject, NSApplicationDelegate/*, NSWindowDelegate*/ {

    func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
        return true
   }
}
	

コード説明

  1. class ColorData: ObservableObject {
    ObservableObject プロトコルを採用したクラスを定義しています。ObservableObject プロトコルを採用することによって、このクラスを共有することができるようになります_。
  2. @Published var name = "Black"
    @Published を付けたプロパティが ObservedObject(ObservableObject と違うので注意)を付けて宣言された App や View のプロパティとバインディングされることになります。ただし、このプロパティが1つの場合は、@Published は省略できます。2つ以上の場合でも、1つは省略できます。しかし一応、バイティングされるデータには、@Published を付けることを習慣とした方が良いでしょう。
  3. @StateObject private var data = ColorData()
    ここは @ObservedObject でも良いのですが、@StateObject を替りに使ってみました。 両方とも、この属性を付けて宣言されたプロパティは、設定されたクラスのデータとバインディングされることになります。StateObject と ObservedObject の違いは次の通りです。
    1. StateObject は View が更新されても値を保持し続けます。
    2. ObservedObject は View が更新された場合、値が初期化される場合があります。
  4. ContentView(data: data)
    ContentViewを作成しています。ContentView の data プロパティに、App 自身の data プロパティに設定された ColorData のインスタンスを渡しています。今回の場合、この作業で App と View の間でデータがバインディング(共有)されています。ここに data: ColorData() などと新しいインスタンスを渡すと App と View は違うインスタンスを参照してしまい。結果データは共有されません。
  5. .environmentObject(data)
    ここが問題で、結果から言うと記述しても意味がありません。各種ドキュメントによると、このコードでデータの共有が始まるとされていますが、データの共有は、ひとつ前のコードで完了しています。実際に引数に data を渡そうが、ColorData() と新しいインスタンスを渡そうが、アプリケーションの動作は、まったく同じです。当然記述しなくてもなんら問題はありません。私の憶測では、SwiftUI は現在進行形でどんどんと進化しているとしか言えません。
  6. .commands {
    WindowGroup の .commads モディファイアでメニューを設定していきます。ただしSwiftUI で現在出来ることはメニューの追加だけです。どこに追加されるのかも恐らく不定です。メニューバーをすべて置き換えることはできません。
  7. CommandMenu("RGB") {
    メニューを作っています。
  8. Button(action: {
    メニューアイテム(メニュー項目)を作っています。Button として設定するんですね。言語仕様が簡略化されていてなんか好きですw。なお、Button の設定方法はいろいろと存在するみたいですが、今回のような Button(action: {}, label:{}) と言う形が一番標準的みたいです。


View.swift

Xcode で作業している場合は、ContentView(ContentView.swift)に次のコードを記述してください。


/************************************
    View.swift
    copyright    :   vivacocoa.jp
    last modified:   Jul. 05, 2022
************************************/

import SwiftUI

struct ContentView: View {
    @ObservedObject var data: ColorData
    
    var body: some View {
        VStack {
            Text(data.name)
                .font(.largeTitle)
                .fontWeight(.heavy)
                .foregroundColor(.white)
                .frame(maxWidth: .infinity, maxHeight: .infinity)
                .background(data.color)
        }
        .padding(10)
        .frame(minWidth: 300, maxWidth: .infinity, minHeight: 200, maxHeight: .infinity)
        .contextMenu(menuItems:{
            Button(action:{
                data.color = Color(red:0, green:1, blue:1)
                data.name = "Cyan"}){Text("Cyan")}
            Button(action:{
                data.color = Color(red:1, green:0, blue:1)
                data.name = "Magenta"}){Text("Magenta")}
            Button(action:{
                data.color = .yellow; data.name = "Yellow"
            }, label:{Text("Yellow")})
            Button(action:{
                data.color = .black; data.name = "Black"}){Text("Black")}
        })
    }
}
/* Xcode で記述してビルドする場合は、ContentView_Previews も変更しなければなりません。
変更しない場合は、プレビューは使えなくなりますが、コメントアウトしてください。
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView(data: ColorData())
            .environmentObject(ColorData())
    }
}
*/
	

コード説明

  1. @ObservedObject var data: ColorData
    今回 View.swift でやるべき、一番の大事なコードです。@ObservedObject は @StateObject に替えても問題ありません。ここでは data プロパティの型として ColorData を指定しています。data = ColorData() として、インスタンスを代入するよりも型を決めておくだけの方が良いでしょう。ここのデータは App で ContentView が作成される時に書き換えられます。無駄にインスタンスを作るのは避けたほうが良いでしょう。
  2. .contextMenu(menuItems:{
    .contextMenu モディアイアについては、コンテキストメニュー で説明したいと思います。
  3. struct ContentView_Previews: PreviewProvider {
    このコードは Xcode で作業する場合に、プレビューを表示するためのコードです。実際のアプリケーションにはまったく関係のないコードです。ただここも記述しておかないと、アプリケーション実行時に落ちてしまうというドキュメントもありましたので一応書いてみました。ただ私の環境のXcode 上で試してみましたが、デフフォルトのままのコードでも変更したコードでも、プレビューに変化はなかったし、アプリケーションが落ちることもありませんでした。気になる方はここのコード全体をコメントアウトするのが良いと思います。


アプリケーションの名前を変えるために、Info.plist の CFBundleName を書き換えます。ここでは「Menubar」という名前にしました。 Xcode で作業している場合は、ここは無視してください。 機会があれば、 また説明します。

アイコンも フリーアイコンSVG、PNG、ICO、ICNS からダウンロードしました。

Info.plist


<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"https://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>CFBundleExecutable</key>
	<string>foo</string>
	<key>CFBundleIconFile</key>
	<string>icon-macbook.icns</string>
	<key>CFBundleName</key>
	<string>Menubar</string>
</dict>
</plist>
    


ビルド


swiftc App.swift View.swift -o foo
mv foo Foo.app/Contents/MacOS
    

実行

メニューバーに RGB メニューと CMYK メニューが追加されています

RGB メニューと CMYK メニューの中身は次のとおりです。


メニューバーから既存のメニュー項目を削除する

SwiftUI では、macOS のメニューバーを完全に独自のメニューバーに入れ替えることはできません。ただし、New Window アイテムと、Show Tab Bar アイテムと、Show All Tabs アイテムを削除する方法は見つかりました。また、AppDelegate を使ってアプリケーションの起動時だけ、アップルメニューと、アプリケーションメニュー以外のメニューを削除する方法もあります。しかしこの方法は、ウィンドウを最小化すると元のメニューに戻ってしまいます。

私の場合は、Show Tab Bar と Show All Tabs は削除したいです。New Window はできたら残したいのですが、New Window で新しいウィンドウを作ると、指定した大きさではなく、かなり大きなウィンドウになってしまいます。このことが直れば New Window は残したいのですが、今回は 3 つとも削除することにします。まあ、New Window がなければ、ウィンドウを閉じた場合にアプリケーションを修了する機能の実装も簡単になりますからね。

App.swift


/************************************
    App.swift
    copyright    :   vivacocoa.jp
    last modified:   Jul. 14, 2022
************************************/

import SwiftUI

class ColorData: ObservableObject {
    @Published var name = "Black"
    @Published var color = Color.black
}

@main
struct FooApp: App {
    @ObservedObject var data = ColorData()
    init() {
            // 次のコードでタブ(Tab)に関するメニューが無くなります。
            NSWindow.allowsAutomaticWindowTabbing = false
        }
    
    var body: some Scene {
        WindowGroup {
            ContentView(data: data)
        }
        
        .commands {
            // .newItem(New Window アイテム)を 中身のないもの { } に
            // replacing(置き換える)とNew Window アイテムが無くなります。
            CommandGroup(replacing: .newItem) {}
            
            CommandMenu("RGB") {
                Button(action: {
                    data.color = .red; data.name = "Red"
                }, label: { Text("Red")})
                Button(action: {
                    data.color = .green; data.name = "Green"
                }, label: {Text("Green")})
                Button(action: {
                    data.color = .blue; data.name = "Blue"
                }, label: {Text("Blue")})
            }
            CommandMenu("CMYK") {
                Button(action: {
                    data.color = Color(red:0, green:1, blue:1); data.name = "Cyan"
                }, label: {Text("Cyan")})
                Button(action: {
                    data.color = Color(red:1, green:0, blue:1); data.name = "Magenta"
                }, label: {Text("Magenta")})
                Button(action: {
                    data.color = .yellow; data.name = "Yellow"
                },label: {Text("Yellow")})
                Button(action: {
                    data.color = .black; data.name = "Black"
                }, label: {Text("Black")})
            }
        }
    }
}
// 今回はアプリケーションの終了も SwiftUI で実装しますので、AppDelegate はいりません。
    

View.swift


/************************************
    App.swift
    copyright    :   vivacocoa.jp
    last modified:   Jul. 14, 2022
************************************/

import SwiftUI

struct ContentView: View {
    @ObservedObject var data: ColorData
    
    var body: some View {
        VStack {
            Text(data.name)
                .font(.largeTitle)
                .fontWeight(.heavy)
                .foregroundColor(.white)
                .frame(maxWidth: .infinity, maxHeight: .infinity)
                .background(data.color)
        }
        .padding(10)
        .frame(minWidth: 300, maxWidth: .infinity, minHeight: 200, maxHeight: .infinity)
        // 今回はウィンドウが消えれば、アプリケーションが修了するようにします。
        .onDisappear{
            NSApplication.shared.terminate(self)
        }
        .contextMenu(menuItems:{
            Button(action:{
                data.color = Color(red:0, green:1, blue:1)
                data.name = "Cyan"}){Text("Cyan")}
            Button(action:{
                data.color = Color(red:1, green:0, blue:1)
                data.name = "Magenta"}){Text("Magenta")}
            Button(action:{
                data.color = .yellow; data.name = "Yellow"
            }, label:{Text("Yellow")})
            Button(action:{
                data.color = .black; data.name = "Black"}){Text("Black")}
        })
    }
}
// ContentView_Previews は省略します。
    



File メニューから New Window アイテムが消えています。


View メニューから Tab 関係のアイテムが消えています。

CommandGroup で消せるメニューアイテム一覧

コード効果
CommandGroup(replacing: .newItem) {} File メニューの New Window 項目が消えます。
CommandGroup(replacing: .undoRedo) {} File メニューの Undo と Redo 項目が消えます。
CommandGroup(replacing: .pasteboard) {} Edit メニューの Cut と Copy と Paste 項目が消えます。
CommandGroup(replacing: .windowSize) {} Window メニューの Minimize と Zoom 項目が消えます。
CommandGroup(replacing: .help) {} Help メニューの Help 項目が消えます。
CommandGroup(replacing: .textEditing) {}不明
CommandGroup(replacing: .windowList) {}不明
CommandGroup(replacing: .windowArrangement) {}不明




5960 visits
Posted: Jul. 05, 2022
Update: Jul. 14, 2022

ホーム   目次