SwiftUI の App で共有する Observable State を所有し、同じインスタンスを両方のシーンに渡します。各ウィンドウの下書きは、それぞれのルートビュー内に保持します。これにより、メニューバーのコントロールで全ウィンドウを更新しながら、各ウィンドウには未確定のテキストを個別に保持できます。
このチュートリアルでは、小さな Mac 用ラベルエディタでこの構成を作ります。複数のウィンドウに同じ共有ラベルを表示し、各ウィンドウに個別の下書きを持たせ、メニューバーのパネルから共有ラベルを編集したり、別のウィンドウを開いたりできるようにします。すべてのサンプルテキストはアプリに属し、架空のラベルを使用しています。
どの変更をすべてのウィンドウに反映するか決める
まず、それぞれの値に期待する動作を決めます。共有ラベルは編集するとすべての場所で変わるべきです。未確定の下書きは、入力したウィンドウだけで変わるべきです。この要件によって、値の置き場所が決まります。
Apple のドキュメントによると、WindowGroup が作成する各ウィンドウは、そのビュー階層内でインスタンス化された状態を個別に保持します。一方、Apple のモデルデータに関するガイダンスでは、トップレベルの App で Observable な参照を所有し、それをビュー階層に渡す方法が示されています。この2つのパターンを組み合わせると、次の所有関係になります。WindowGroup、Managing model data in your app。
| 値 | この例での所有者 | 想定される動作 |
|---|---|---|
| 共有ラベル | @Observable モデルを使う App の @State |
すべてのウィンドウとメニューバーパネルが同じ値を編集する |
| 未確定の下書き | 各ウィンドウのルートビューの @State |
入力内容はそのウィンドウ内にとどまる |
| メニューバーのコントロール | App が所有するモデルを受け取る | そのインスタンスを使うすべての要素に変更が反映される |
| ウィンドウを開くアクション | メニューバービューの環境から取得する | 指定されたグループからウィンドウを開く |
重要なのはインスタンスの所有者です。同じクラス名を持つオブジェクトを別々に作成しても、その内容は共有されません。同様に、App が所有する1つのオブジェクトへの参照を複数のウィンドウに置いても、そのオブジェクトの独立したコピーは作られません。そのため、この例では共有モデルを App レベルで一度だけ作成します。
シーンを追加する前に macOS の基準を設定する
macOS 14 以降をデプロイメントターゲットとし、Xcode 15 以降など Observation に対応する Xcode ツールチェーンを使用する SwiftUI の macOS アプリプロジェクトを使います。SwiftUI の Observation 統合は macOS 14 から利用できます。MenuBarExtra とそのウィンドウスタイルは macOS 13 から利用できますが、この実装では Observation の要件に合わせて基準を引き上げます。Managing model data in your app、MenuBarExtra、window。
メニューバーのパネルにはテキストフィールドとカスタムレイアウトが含まれるため、.menuBarExtraStyle(.window) を使います。Apple の説明では、このスタイルは通常のウィンドウと同様のスタイルのコントロールを備えた、ポップオーバー風のウィンドウです。これはエディタの WindowGroup とは別のシーンです。window。
この例では、モデルと UI の型を明示的に @MainActor に分離することもしています。Apple は Main Actor のエグゼキュータをメインディスパッチキューと同等と定義しています。ここではすべての変更が UI コントロールから発生するため、バックグラウンド処理は必要ありません。MainActor。
共有モデルとウィンドウごとの下書きを作る
生成されたアプリのエントリポイントを、次の完全な単一ファイルの例に置き換えます。ターゲット内の @main 宣言は1つだけにしてください。このサンプルには import、モデル、ビュー、初期テキストが含まれています。ドキュメントに記載された API と照合しましたが、この環境では Swift ツールチェーンが利用できなかったため、コンパイルはできていません。
import SwiftUI
import Observation
@MainActor
@Observable
final class LabelStore {
var sharedLabel = "Sample Alpha"
}
@main
@MainActor
struct LabelScopeApp: App {
@State private var store = LabelStore()
var body: some Scene {
WindowGroup("Label Editor", id: "label-editor") {
EditorWindow()
.environment(store)
}
MenuBarExtra("Label Controls", systemImage: "square.grid.2x2") {
LabelMenuPanel()
.environment(store)
}
.menuBarExtraStyle(.window)
}
}
@MainActor
struct EditorWindow: View {
@Environment(LabelStore.self) private var store
@State private var draft = "Sample Draft"
var body: some View {
VStack(alignment: .leading, spacing: 16) {
SharedLabelControls()
Divider()
TextField("This window's draft", text: $draft)
.textFieldStyle(.roundedBorder)
Button("Use Draft as Shared Label") {
store.sharedLabel = draft
}
Text("Publishing the draft keeps its local text intact.")
.font(.caption)
}
.padding(20)
.frame(minWidth: 420, minHeight: 220)
}
}
@MainActor
struct SharedLabelControls: View {
@Environment(LabelStore.self) private var store
var body: some View {
@Bindable var editableStore = store
VStack(alignment: .leading, spacing: 8) {
Text("Shared label: \(store.sharedLabel)")
TextField("Shared label", text: $editableStore.sharedLabel)
.textFieldStyle(.roundedBorder)
}
}
}
@MainActor
struct LabelMenuPanel: View {
@Environment(\.openWindow) private var openWindow
var body: some View {
VStack(alignment: .leading, spacing: 16) {
SharedLabelControls()
Button("Open Another Editor") {
openWindow(id: "label-editor")
}
}
.padding(16)
.frame(width: 320)
}
}
環境への注入箇所は2つあります。ウィンドウのコンテンツに1つ、メニューバーのコンテンツに1つです。どちらにも store を渡します。ウィンドウのコンテンツに付けた環境モディファイアが、兄弟シーンにも依存関係を提供すると考えないでください。Apple が説明している環境の共有範囲はビュー階層です。2つの注入箇所により、それぞれの階層に必要な依存関係が明示されます。Managing model data in your app。
コントロールにバインディングが必要な箇所では @Bindable を使う
SharedLabelControls は @Environment(LabelStore.self) を通してモデルを読み取ります。その body 内では、ローカルの @Bindable 変数が TextField に必要な投影バインディングを提供します。これは既存の Observable オブジェクトを参照するもので、新しいストアを作成するものではありません。Apple のドキュメントには、この環境からバインディングを得る方法がそのまま示されています。Bindable。
一方、下書きフィールドでは $draft を使います。その値はウィンドウビューの @State がすでに所有しているためです。ボタンは現在の下書きを共有ラベルにコピーします。これは明示的なアクションです。その後に共有ラベルを変更しても、どちらのウィンドウの下書きも書き換わりません。これがこのサンプルで選んだ編集の動作です。
ウィンドウグループの識別子を正確に一致させる
メニューバーのボタンは、独自の環境から openWindow を取得し、グループ宣言と一致する "label-editor" を渡して呼び出します。Apple は、グループからウィンドウを作成する方法として、この識別子を使う方法を説明しています。このボタンの目的は別のエディタを開くことなので、特定の既存ウィンドウを見つけて再利用することは、この例では保証しません。WindowGroup。
2つのウィンドウを使った手順でスコープを確認する
次の手順は、サンプルで想定される動作を確認するものです。追加の計測をしなくても、共有モデルの同一性と下書きの個別保存を区別できます。
- アプリを起動します。最初のエディタには、共有ラベル
Sample Alphaと下書きSample Draftが表示されます。 - メニューバーパネルを開き、
Open Another Editorを選びます。 - 最初のウィンドウの下書きを
Draft Oneに変更します。2つ目のウィンドウの下書きはそのままにします。 - メニューバーパネルで共有ラベルを
Sample Betaに変更します。2つのエディタウィンドウの両方にその共有ラベルが表示され、それぞれ異なる下書きが保持されているはずです。 - 最初のウィンドウで
Use Draft as Shared Labelを選びます。両方のウィンドウとメニューバーパネルで、共有ラベルがDraft Oneになります。2つ目のウィンドウの下書きはSample Draftのままです。
この手順では、ローカル入力、メニューバーからの編集、ローカルから共有への明示的なコピーという3つの経路を確認します。ローカル入力で別のウィンドウの下書きまで変わる場合は、下書きが App 所有のストアに移されていないか確認してください。メニューバーからの編集がウィンドウに反映されない場合は、メニューバーの階層で別の LabelStore() が作成されていないか確認してください。
| 症状 | 最初に確認すること |
|---|---|
| ウィンドウとメニューバーでラベルが一致しない | 共有インスタンスを1つ使わず、モデルを別々に初期化していないか |
| すべての下書きが同時に変わる | 下書きの保存先が共有モデルになっていないか |
| モデルを読み取るとビューで失敗する | その階層に .environment(store) がないか |
| エディタが予期せずリセットされる | 所有元のビューの identity が変わっていないか、モデルが置き換わっていないか |
| 別のエディタを開けない | 宣言されたシーン識別子と要求された識別子が一致しているか |
環境が不足している場合の結果は、ドキュメントに記載されています。型を指定して非オプショナルな Observable オブジェクトを読み取ることは、祖先がそれを提供したことを前提とします。Apple によると、オブジェクトが存在しない場合は例外が発生します。プレビューや別のエントリポイントでも依存関係を渡してください。Managing model data in your app。
このサンプルはすべてをメモリに保存し、保存や復元の実装を含みません。そのため、ウィンドウを閉じると未確定の下書きは破棄され、アプリを終了すると共有ラベルも破棄されるものとして扱ってください。下書きをそれらの操作後も保持する必要がある場合は、この例を拡張する前に、別途永続化の要件を定義してください。
フォーカス中のコマンドと共有メニューバーコントロールを分ける
共有ラベルにアクティブウィンドウの選択は必要ありません。すべての利用者が同じモデルを受け取るためです。現在フォーカスされているウィンドウの下書きを編集するアクションには、特定のウィンドウを識別する必要があるため、別の要件があります。
標準的なアプリケーションメニューのコマンドについては、Apple は focusedSceneValue を使ってウィンドウ所有のモデルを公開し、Commands 実装内で @FocusedValue を通じて利用する方法を説明しています。その例では、フォーカスされたモデルがない場合にコマンドを無効にしています。これは、対象がアクティブなシーンに応じて変わるアクションに対する、サポートされた方法です。Building and customizing the menu bar with SwiftUI。
そのコマンドの例を根拠に、MenuBarExtra を開けば直前にアクティブだったエディタへの確実な参照が得られると考えないでください。この記事のパネルは意図的にアプリ全体の状態を操作します。パネルから個別のエディタを対象にする必要がある場合は、どのウィンドウが対象かを推測するのではなく、アプリ所有のエディタ識別子の一覧など、明示的な対象選択の設計を追加してください。
エディタに必要な場合だけ AppKit や TextKit を追加する
このラベルエディタに必要なのは SwiftUI のコントロールだけです。要件が拡張されてカスタム AppKit テキストビューが必要になった場合、NSViewRepresentable は SwiftUI の階層内で NSView を作成、更新、破棄するために Apple が提供するブリッジです。Apple は、デリゲートやターゲットとアクションのメッセージを SwiftUI に転送するためのコーディネーターの利用方法も説明しています。NSViewRepresentable。
このブリッジを追加するときも、所有関係の方針を保ってください。各エディタのビューインスタンスと編集状態はそのウィンドウに関連付け、共有する意図がある場合に限って共有コンテンツを App モデル経由で渡します。コーディネーターはバインディングまたはモデルのアクションを通じて編集を転送します。実装上の注意として、テキストビューの内容を置き換える前に入力内容を比較し、無関係な共有更新によって進行中の編集が何度もリセットされないようにしてください。
TextKit 2 を実装する場合、Apple は textLayoutManager を最新のレイアウトエンジンにアクセスする方法として示しています。Apple の TextKit セッションでは、TextKit 2 のテキストビューで従来の layoutManager にアクセスすると互換モードが起動し、そのビューが TextKit 1 に切り替わって自動では戻らないことが説明されています。テキストビューを調べるだけの目的で、この従来のプロパティを使うのは避けてください。TextKit、What’s new in TextKit and text views。
macOS 13 をサポートする必要がある場合も、同じ所有関係を維持しながら、従来の監視方法を使います。ObservableObject と @Published、所有側の @StateObject、そして .environmentObject と受け取り側の @EnvironmentObject プロパティを使ってください。Apple の移行ガイドでは、これらの仕組みが新しい Observation の方法にどう対応するかを説明しています。監視方法を変更しても、意図した共有範囲は維持してください。Migrating from the Observable Object protocol to the Observable macro。
インターフェースに期待される動作に沿って所有者を選びます。共有ラベルは App が所有する1つのインスタンス、未確定の下書きはウィンドウごとに所有する値とし、両者をつなぐアクションを明示します。メニューバーパネルやエディタを拡張するたびに、2つのウィンドウを使った手順を回帰確認として実行してください。
参照資料
- Managing model data in your app — Apple
- WindowGroup — Apple
- MenuBarExtra — Apple
- window — Apple
- MainActor — Apple
- Bindable — Apple
- Building and customizing the menu bar with SwiftUI — Apple
- NSViewRepresentable — Apple
- TextKit — Apple
- What’s new in TextKit and text views — Apple
- Migrating from the Observable Object protocol to the Observable macro — Apple