コンテンツへスキップ

SwiftUI 4でのmacOSウィンドウ管理

SwiftUI 4にアップグレード後、Macアプリのウィンドウ処理をモダナイズした際の学びを共有します。`\.openWindow`や`.windowResizability`などを解説します。

SwiftUI 4でのmacOSウィンドウ管理

SwiftUIは毎年大幅に改善されています。昨年(2022年)には、改善されたナビゲーションAPIだけでなく、AppleはmacOSのサポートも大幅に強化しました。SwiftUI 4でMacアプリ開発向けに受け取ったSwiftUI APIは1.0レベルに達しており、最も一般的なタスクのためにAppKitに頼ることなく、SwiftUI内であらゆることができるようになったと言えます。SwiftUI 3を使ってRemafoXを開発した際にMac上でのSwiftUIを経験しましたが、一部は本当に悪夢のような体験でした。

iOS開発出身の私は、AppKitの細部まで学ぶ必要がないことを期待していました。しかし、ウィンドウを閉じるといった最も単純な操作のためにも、あらゆるハック的なコードを書く必要がありました。あるいは、ウィンドウのフルスクリーンボタンを無効にするとか。しかし良いニュースがあります:アプリのターゲットをmacOS 13.0に引き上げることで、SwiftUIを通じてウィンドウ管理ができるようになり、アプリに入れていたすべてのハックを排除できました。

ついに、アプリは本当に100% SwiftUI駆動になった感じがします。ここからは、達成したいタスクごとにグループ化した、活用できた新しいAPIを紹介します。ウィンドウ管理はSwiftUI時代におけるiOSとmacOS開発の最大の違いの1つなので、この記事はiOSからmacOSへの移行を検討している方がMacでのウィンドウ管理を理解するのにも役立つかもしれません。

ウィンドウを開く

WindowGroup(SwiftUI 3で利用可能だった唯一のウィンドウタイプ)を使用している場合、SwiftUI 4では2つの方法があります。1つ目は以前からサポートされていたhandlesExternalEventsメソッドを使う方法です:

enum Window: String, Identifiable {
   case paywall
   // ...
   var id: String { self.rawValue }
}

@main
struct AppView: App {
   var body: some Scene {
      // ...
      WindowGroup("Plan Chooser") { ... }
         .handlesExternalEvents(matching: [Window.paywall.id])
      // ...
   }
}

このウィンドウを開きたい場合は、外部URLを開くのと同じように、カスタムURLスキームを使ったURLを開く必要があります。例えば:

@main
struct AppView: App {
   @Environment(\.openURL)
   var openURL

   var body: some Scene {
      // ...
      WindowGroup(...) { ... }
         .commands {
            CommandGroup(after: .windowArrangement) {
               Button("Show Plan Chooser") {
                  self.openURL(URL(string: "remafox://\(Window.paywall.id)")!)
               }
               .keyboardShortcut("1")
            }
         }
   }
}

ただし、この方法は新しいWindow型では機能しません(APIとしては完全に利用可能ですが)。ドキュメントには明確に記載されています:

This modifier is only supported for WindowGroup Scene types.

しかし、2つ目の方法はWindowGroupWindowの両方で動作します。新しい\.openWindow Environment値です!まず、イニシャライザでidを定義します:

enum Window: String, Identifiable {
   case paywall
   // ...
   var id: String { self.rawValue }
}

@main
struct AppView: App {
   var body: some Scene {
      // ...
      WindowGroup("Plan Chooser", id: Window.paywall.id) { ... }
      // ...
   }
}

次に、そのidopenWindowに渡すだけで手動的にプレゼンテーションをトリガーできます:

@main
struct AppView: App {
   @Environment(\.openWindow)
   var openWindow

   var body: some Scene {
      // ...
      WindowGroup(...) { ... }
         .commands {
            CommandGroup(after: .windowArrangement) {
               Button("Show Plan Chooser") {
                  self.openWindow(id: Window.paywall.id)
               }
               .keyboardShortcut("1")
            }
         }
      // ...
   }
}

こちらの方がずっとスマートですね!なお、DocumentGroup用には\.openDocumentもあります。

ウィンドウの重複を防ぐ

ウィンドウにidを使用しても、同じウィンドウが複数表示されるのを防げるわけではありません:

Prevent duplicate windows

少なくともWindowGroupについてはそうですが、Windowを使えば同じidのウィンドウが複数作成されないことを保証できます!ただし、これが常に選択肢になるわけではありません。

WindowWindowGroupと比べて様々な制約があります。例えば、上で行ったようにアプリのメインメニューにボタンを設定するための.commandsを呼び出すことができません。また、WindowはmacOS専用のAPIなので、iPadOSでコードを再利用できません。RemafoXで使えなかった理由は、先に述べた制約です:WindowhandlesExternalEventsをサポートしていません。しかし、RemafoXはエクステンションとCLIツールの両方を通じてXcodeと深く統合されており、それらは同じターゲットの一部ではないため\.openWindowを利用できません。ただし、「外部」URLは開けます!

WindowGroupの重複を防ぎたい場合は、以下のようにします:

WindowGroup("Plan Chooser", id: Window.paywall.id, for: String.self) { _ in
   // ...
} defaultValue: {
   Window.paywall.id
}

追加のfor type引数とdefaultValue引数を取るWindowGroupイニシャライザのオーバーロードバージョンを使用しています。Profile型のような任意のデータ型に関連する特定のウィンドウを開くために使えますが、ここではウィンドウをユニークにするためにString型のidを再利用しています。

複数の箇所でこれを行う必要があったため、エクステンションを作成しました:

extension WindowGroup {
   init<W: Identifiable, C: View>(_ titleKey: LocalizedStringKey, uniqueWindow: W, @ViewBuilder content: @escaping () -> C)
   where W.ID == String, Content == PresentedWindowContent<String, C> {
      self.init(titleKey, id: uniqueWindow.id, for: String.self) { _ in
         content()
      } defaultValue: {
         uniqueWindow.id
      }
   }
}

これにより、Window.paywall.idを2回呼び出していた上のコードがこれだけになりました:

WindowGroup("Plan Chooser", uniqueWindow: Window.paywall) {
   // ...
}

ウィンドウを開く際には、openWindowvalueパラメータも追加で渡します:

Button("Show Plan Chooser") {
   self.openWindow(id: Window.paywall.id, value: Window.paywall.id)
}

ここでもWindow.paywall.idの複数回呼び出しが気になったので、ヘルパーを作成しました:

extension OpenWindowAction {
   func callAsFunction<W: Identifiable>(_ window: W) where W.ID == String {
      self.callAsFunction(id: window.id, value: window.id)
   }
}

これで.idサフィックスも不要になり、シンプルに呼び出せます:

self.openWindow(Window.paywall)

ウィンドウを閉じる

ウィンドウをユニークに開けるようになったところで、次は閉じる方法です。以前の状況はウィンドウを開くよりもさらにひどく、WindowGroupのようなSwiftUI内のワークアラウンドすらありませんでした。SwiftUIで直接ウィンドウを閉じる方法が単純に存在しなかったのです。AppKitを使ってこのハックを実装する必要がありました:

struct WindowAccessor: NSViewRepresentable {
   @Binding
   var window: NSWindow?

   func makeNSView(context: Context) -> NSView {
      let view = NSView()
      DispatchQueue.main.async {
         self.window = view.window
      }
      return view
   }

   func updateNSView(_ nsView: NSView, context: Context) {}
}

@main
struct AppView: App {
   @State
   private var window: NSWindow?

   var body: some Scene {
      WindowGroup(...) {
         SomeView(...)
            .background(WindowAccessor(window: self.$window))
      }
   }
}

ウィンドウを閉じたい場合はself.window.close()を呼んでいました。

2021年には、\.dismiss Environment値が追加され、sheetpopoverfullScreenCoverのようなプレゼンテーションされたビューをその内部からdismissできるようになりました。この挙動がその当時からあったのか2022年に追加されたのかは定かではありませんが、現在のドキュメントには追加で以下のように記載されています:

Close a window that you create with WindowGroup or Window.

当然ながら、対象のウィンドウ内にモーダルビューが表示されていない場合にのみ機能します。その条件が満たされていれば完璧に動作し、以下のように書くだけです:

@main
struct AppView: App {
   @Environment(\.dismiss)
   var dismiss

   var body: some Scene {
      WindowGroup(...) {
         // ...
         Button("Close") {
            self.dismiss()  // <= this closes the window if no modal
         }
      }
   }
}

フルスクリーンボタンの無効化

Disabling full screen

以前は、先ほど述べたNSWindowにアクセスするのと同じハックを使って、固定サイズのビュー(ウェルカムウィンドウやAboutウィンドウなど)のフルスクリーンボタンを無効にするなど、さらなる設定を行っていました。しかし今では、新しいモディファイアwindowResizabilityを使って間接的にフルスクリーンボタンを無効にできます。デフォルトでは、SettingsWindowGroupと同様のScene型)を除くすべてのウィンドウでcontentMinSizeに設定されています。以下のようにできます:

@main
struct AppView: App {
   var body: some Scene {
      WindowGroup(...) {
         SomeView(...)
            .frame(maxWidth: 400, maxHeight: 400)
         }
         .windowResizability(.contentSize)
      }
   }
}

windowResizability.contentSizeに設定することで、frameモディファイアで指定したサイズにより厳密に従うようSwiftUIに指示します。論理的な帰結として、ビューが指定する最大サイズがユーザーの現在のスクリーンサイズより小さい場合、SwiftUIが自動的にフルスクリーンボタンを無効にしてくれます!非常に直感的なAPIとは言えませんが、理にかなっています。実質的に、1366 x 768(2015年の11インチMacBook Airのネイティブサイズ)未満の値を指定すれば、ほとんどのユーザーでボタンが無効になるはずです。

TCA向けの追加情報

私と同じようにThe Composable Architecture(TCA)をアプリに使っている場合、ウィンドウの開閉などのロジックはリデューサーで行うべきなので、これらの新しいEnvironment値をリデューサーにどう転送するのが最適か疑問に思うかもしれません。TCAの素晴らしいコミュニティがこれをエレガントに解決する手助けをしてくれました。特にThomas Grapperonさんが提供してくれた型をOnChangeとリネームしたものがあり、ここからコピー&ペーストできます。ビューでは、.onChangeモディファイアを使ってリデューサーに転送したい任意の値を渡します:

@Environment(\.openWindow)
var openWindow

var body: some View {
   WithViewStore(...) { viewStore in
      SomeView(...)
         .onChange(of: \.$openWindow, store: self.store) { window in
            self.openWindow(window)
         }
   }
}

\.$openWindowState内のフィールドを参照しているので、定義する必要があります:

struct SomeState {
   @OnChange
   var openWindow: Window?
}

これで、リデューサー内でstate値をWindow enumのcaseに設定するだけで、ビューが自動的にその変更を@Environment値に転送します。これは他のSwiftUIアトリビュートでも機能します。@FocusStateでも使いました!

ちなみに、TCAには\.dismiss依存関係が同梱されていますが、リデューサー内でawait self.dismiss()を呼んでも、(現時点では)SwiftUIの\.dismiss Environment値のようにウィンドウを閉じる動作はしません。何も起こらず、警告が出力されるだけです。ワークアラウンドとして、AppKitのAPIを使って開いているウィンドウを走査し、一致するものを見つけて閉じるDependencyを実装しました。Gistをここからコピー&ペーストできます。使い方は以下の通りです:

// add the dependency to your reducer
@Dependency(\.closeWindow)
var closeWindow

// close a window in a `run` effect
return .run { _ in await self.closeWindow(Window.paywall) }

以上が、今回共有したかったSwiftUI 4でのウィンドウ管理についてのすべてです!

この記事を楽しんでいただけましたか?エキスパートアドバイスを受けてみませんか!

この記事が参考になりましたか?BlueskyMastodonでフォローして、Swiftのヒントやインディー開発の最新情報をチェックしてください。