togo

= ひとりごと to go

zumuya の人による机の上系情報サイト

SwiftUI でウインドウを開く/閉じる

  • Apple
  • ソフトウェア
  • 開発

Apple にとって主力プラットフォームの iOS ですら機能不足だと言われ続けた SwiftUI もバージョンを重ね、最近は macOS でも実用的になってきた感じ。自分もこの数年は新規 Mac App を SwiftUI ベースにすることが多くなってる。

とはいえ SwiftUI って名前空間を贅沢に使いすぎるせいで API やそのドキュメントが分散しているし、名前も広いプラットフォームを想定した汎用的なもので逆にわかりにくかったりするのでせっかく用意された機能に気づかないまま別の API で遠回りしたり諦めてしまうことがよくある。なので基本っぽい機能でも一つのテーマで一箇所にまとめれば価値があるはず。…この手の記事を書いても最近は AI のエサにしかならないかもしれないけど、世に高品質な Mac App が生まれることに少しでも役立てばそれでいいのだ1

今日のテーマはウインドウ。macOS の SwiftUI ではウインドウを Window(_:id:content:) もしくは UtilityWindow(_:id:content:) で Scene として定義できる。名前のとおり前者が通常のウインドウなのに対して後者はユーティリティパネル2だ。同じ種類のウインドウを複数開く場合は WindowGroup を使おう。

ちなみに設定ウインドウは Settings で定義するし、アプリケーションが書類を扱う場合だと DocumentGroup というこれもまた Scene の一種を使うが話が長くなるので割愛したい。

Scene は App.body に並べて定義するが、ここで最も先頭に近い Window がアプリケーション起動時に自動的に表示されるウインドウとなる。それ以外のものはユーザが必要なときに開くオプションであり、ここでの定義順に“ウインドウ”メニュー内に項目として並び、ユーザがそれを選択したタイミングで初めて表示される。

@main
struct MyApp: App
{
    var body: some Scene
    {
        Window("MyApp", id: "main") {
            ContentView()
        }
        Window("Fruits", id: "fruits") {
            Text("🍎🍇🍓").font(.system(size: 36))
        }

        /// この場合は`id: "main"`のウインドウが起動時に表示される
    }
}
起動時に開くウインドウとそうでないウインドウ

今回はこれらを開いたり閉じたりする方法を挙げてみよう。

コードから指定したウインドウを開く:EnvironmentValues.openWindow

ユーザが“ウインドウ”メニューから選択した場合だけでなく、処理の開始時とかにコードから表示したいシチュエーションがあるはず。SwiftUI ではウインドウを開くための API として EnvironmentValues.openWindow が定義されているので、@Environment で取り出して使おう。

OpenWindowAction という型の構造体が得られるが、それに callAsFunction(id:) が定義されているため関数のように使用できる。この id: には見覚えがあるはず。Window のイニシャライザの引数にあった id: が目的ウインドウの指定に役立つのだ。

struct ContentView: View
{
    @Environment(\.openWindow) private var openWindow

    var body: some View
    {
        Button("Show Fruits") {
            openWindow(id: "fruits")
        }
    }
}
特定のウインドウが開く

ちなみに callAsFunction(id:value:) という別引数のバリエーションも用意されており、これは WindowGroup のためのもの。同じ定義のウインドウを複数開くことができてそれぞれに value: として内容を与える。ビューは Scene のところで定義済みなのでここでの内容というのはビューではなくウインドウで扱うモデルっぽいもの。

コードから自身のウインドウを閉じる:EnvironmentValues.dismiss

表示したウインドウを閉じるには? これはその対象のウインドウ内だと特に簡単である。

先ほどと同様に EnvironmentValues.dismiss が定義されているので、@Environment で取り出して使おう。dismiss は iOS 開発とかでも多用するのでお馴染みな存在のはず。これをウインドウ内で呼び出すとそのウインドウが閉じる、とても単純な話だ。

struct ContentView: View
{
    @Environment(\.dismiss) private var dismiss

    var body: some View
    {
        Button("Close") {
            dismiss()
        }
    }
}
自身のウインドウが閉じる

コードから指定したウインドウを閉じる:EnvironmentValues.dismissWindow

ウインドウの外からだと? これもほとんど同じ。dismiss の代わりに .dismissWindow を使用すればよい。ここで得られる DismissWindowAction 構造体はウインドウを開くときに使った OpenWindowAction と対になるような存在であり、 これもまた callAsFunction(id:) が定義されている。

struct ContentView: View
{
    @Environment(\.dismissWindow) private var dismissWindow

    var body: some View
    {
        Button("Hide Fruits") {
            dismissWindow(id: "fruits")
        }
    }
}
特定のウインドウが閉じる

ウインドウを開く/閉じるためのコントロール:WindowVisibilityToggle

前述のようにコードからウインドウの表示状態を制御できることがわかった。長い処理のシーケンスに組み込むには最適だ。一方で、メニューにユーザの選択項目として配置するような用途では専用の View が用意されている。WindowVisibilityToggle である。

名前に Toggle とあるように、表示にも非表示にも使えるし HIG の例にあるようにきちんとタイトルが「〜を表示」「〜を非表示」と表示状態に合わせて変化してくれる優れもののコントロールだ。

Consider using a changeable label that describes an item’s current state. For example, instead of listing two menu items like Show Map and Hide Map, you could include one menu item whose label changes from Show Map to Hide Map, depending on whether the map is visible.

雑に配置するとこんな感じ:

struct ContentView: View
{
    var body: some View
    {
        WindowVisibilityToggle(windowID: "fruits")
    }
}
特定のウインドウが開いたり閉じたり

これは label: 引数を使用したアイコンの指定などできず、サンプルコードなどを見ても基本的にメニュー項目として使用することが想定されているようだ。好きなメニューに項目を用意してみよう。

冒頭に書いたようにウインドウを定義するとデフォルトで“ウインドウ”メニューに項目が(ウインドウが閉じた状態でも)並ぶ。これはほかのメニューに項目を用意する場合は邪魔である。Scene.commandsRemoved{} を使用するとそれを消すことができる。さらに今回の場合は消してから別の場所に項目を追加したいので、その派生版の Scene.commandsReplaced{} を使えば一発だ。

WindowGroup, Window, and other scene types all have an associated set of commands that they include by default. Apply this modifier to a scene to replace those commands with the output from the given builder.

For example, the following code adds a scene for showing the contents of the pasteboard in a dedicated window. We replace the scene’s default Window > Clipboard menu command with a custom Edit > Show Clipboard command that we place next to the other pasteboard commands.

App.body 内を以下の感じに書き換える。どこに追加するのかは普段 Scene.commands{} で通常のメニュー項目を用意するときと同様に CommandGroup の生成時に与える引数で指定していて、この場合は「“サイドバーを表示”の前」という意味になる。

Window("Fruits", id: "fruits") {
    ...
}
.commandsReplaced {
    CommandGroup(before: .sidebar) {
        Section {
            WindowVisibilityToggle(windowID: "fruits")
        }
    }
}
専用メニュー項目ができた!

ただしこれには副作用もあって、ウインドウが表示中であっても“ウインドウ”メニューから消えてしまう。標準的な挙動と違うよね。Apple にフィードバックすべきなのか、使い方が間違っているのか…?


  1. 世のため人のためにやってるアピールではなく、世に高品質な macOS ネイティブなアプリケーションの選択肢が増えると自分が得する構図がある。 ↩︎

  2. 雑に言うとフォントパネルみたいにタイトルバーが細くてほかのウインドウより手前に表示されて表示主のアプリケーションが非アクティブになると隠れるやつ。AppKit だと StyleMask.utilityWindow で生成した NSPanel がこれだ。 ↩︎

Share

リンクも共有もお気軽に。記事を書くモチベーションの向上に役立てます。