在 SwiftUI App 中擁有共用的可觀察狀態,然後將同一個實例傳入兩個場景。將每個視窗的草稿保留在其根視圖中。如此一來,選單列控制項就能更新每個視窗,而各視窗仍保有自己的未完成文字。
本教學將為一款簡易的 Mac 標籤編輯器建立這種配置:多個視窗顯示同一個共用標籤,每個視窗都有獨立的草稿,而選單列面板可以編輯共用標籤或開啟另一個視窗。所有範例文字都屬於 app,並使用虛構標籤。
決定哪些變更應套用至每個視窗
先釐清每個值預期的行為。編輯共用標籤時,所有地方都應同步變更。未完成的草稿則只應在輸入文字的視窗中變更。這些需求會決定各值應由誰擁有。
Apple 說明,WindowGroup 建立的每個視窗,都會有自己在視圖階層內實例化之狀態的獨立儲存空間。相較之下,Apple 的模型資料指南示範了在最上層的 App 中擁有可觀察參照,並將它傳入視圖階層。結合這兩種模式,就能制定以下擁有權配置。WindowGroup、管理 app 中的模型資料。
| 值 | 本範例中的擁有者 | 預期行為 |
|---|---|---|
| 共用標籤 | App,使用搭配 @Observable 模型的 @State |
每個視窗與選單列面板都編輯同一個值 |
| 未完成的草稿 | 每個視窗的根視圖,使用 @State |
輸入內容只保留在該視窗 |
| 選單列控制項 | 接收 app 擁有的模型 | 變更會傳到使用該實例的所有項目 |
| 開啟視窗的動作 | 從選單列視圖的環境讀取 | 從指定的群組開啟視窗 |
關鍵在於由哪個實例擁有。兩個分別建立的物件即使具有相同的類別名稱,也不代表它們的內容是共用的。同樣地,在多個視窗中放入同一個 app 擁有物件的參照,也不會建立該物件的獨立副本。因此,本範例只在 app 層級建立一次共用模型。
加入場景前先設定 macOS 基準版本
使用 macOS SwiftUI app 專案,部署目標設為 macOS 14 或更新版本,並使用支援 Observation 的 Xcode 工具鏈,例如 Xcode 15 或更新版本。SwiftUI 的 Observation 整合從 macOS 14 開始提供。MenuBarExtra 及其視窗樣式從 macOS 13 起即可使用,因此本實作的基準版本由 Observation 決定,需設為較高的版本。管理 app 中的模型資料、MenuBarExtra、視窗。
選單列面板使用 .menuBarExtraStyle(.window),因為其中包含文字欄位和自訂版面配置。Apple 將此樣式描述為類似彈出視窗的視窗,其控制項樣式與一般視窗中的控制項相似。它是與編輯器 WindowGroup 分開的場景。視窗。
本範例也明確將模型和 UI 型別隔離至 @MainActor。Apple 將主演員者定義為具有等同於主派送佇列的執行器。在此,所有變更都源自 UI 控制項,不需要背景工作。MainActor。
建立共用模型與各視窗獨立的草稿
以下是完整的單檔範例,請用它取代產生的 app 進入點。目標中只保留一個 @main 宣告。範例包含所需匯入、模型、視圖和初始文字。已根據文件中的 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)
}
}
這裡有兩處明確的環境注入:一處用於視窗內容,另一處用於選單列內容。兩處都接收 store。不要預期附加至視窗內容的環境修飾器會將相依項目提供給同級場景。Apple 說明環境會透過視圖階層共用;這兩處注入會明確指定每個階層所需的相依項目。管理 app 中的模型資料。
在控制項需要繫結時使用 @Bindable
SharedLabelControls 透過 @Environment(LabelStore.self) 讀取模型。在其 body 中,區域的 @Bindable 變數會提供 TextField 所需的投影繫結。它參照的是既有的可觀察物件,不會建立另一個儲存區。Apple 文件中示範了這種從環境取得繫結的方式。Bindable。
草稿欄位則使用 $draft,因為它的值已由視窗視圖的 @State 擁有。按鈕會將目前草稿複製到共用標籤。這是一個明確的動作:之後共用標籤的變更不會改寫任何視窗的草稿。這就是本範例選定的編輯行為。
確保視窗群組識別碼完全相符
選單列按鈕會從自身環境取得 openWindow,並以 "label-editor" 呼叫它,與群組宣告相符。Apple 說明了這種依識別碼從群組建立視窗的做法。按鈕的用途是開啟另一個編輯器,因此本範例不保證會尋找並重用某個特定的既有視窗。WindowGroup。
透過兩個視窗的操作流程確認範圍
以下操作流程可用來檢查範例預期的行為。無須額外加入檢測機制,就能分辨共用模型識別與獨立草稿儲存空間。
- 啟動 app。第一個編輯器會顯示共用標籤
Sample Alpha和草稿Sample Draft。 - 開啟選單列面板,選擇
Open Another Editor。 - 將第一個視窗的草稿改為
Draft One。第二個視窗的草稿維持不變。 - 在選單列面板中,將共用標籤改為
Sample Beta。兩個編輯器視窗都應顯示這個共用標籤,同時保留各自不同的草稿值。 - 在第一個視窗中,選擇
Use Draft as Shared Label。兩個視窗和選單列面板中的共用標籤都會變成Draft One。第二個視窗的草稿仍為Sample Draft。
這個流程會檢查三種不同路徑:本機輸入、選單列編輯,以及明確的本機到共用值複製。如果本機輸入會變更另一個視窗的草稿,請檢查草稿是否被移入 app 擁有的儲存區。如果選單列編輯無法傳到視窗,請檢查選單列階層中是否又建立了另一個 LabelStore()。
| 症狀 | 優先檢查項目 |
|---|---|
| 視窗與選單列的標籤不一致 | 分別初始化了模型,而不是共用同一個實例 |
| 所有草稿一起變更 | 草稿儲存在共用模型中 |
| 檢視模型時發生錯誤 | 該階層中缺少 .environment(store) |
| 編輯器意外重設 | 擁有該視圖的視圖識別已變更,或其模型被取代 |
| 無法開啟另一個編輯器 | 宣告的場景識別碼與要求的識別碼不一致 |
文件說明了缺少環境的後果:依型別讀取非可選的可觀察物件時,代表該物件是由某個上層提供;Apple 指出,如果物件不存在,就會引發例外。預覽和其他進入點也要提供這項相依項目。管理 app 中的模型資料。
本範例將所有內容儲存在記憶體中,沒有實作儲存或還原功能。因此,關閉視窗應視為捨棄其中未完成的草稿,而結束 app 則應視為捨棄共用標籤。如果草稿必須在這些操作後繼續保留,請先定義獨立的持續性需求,再擴充本範例。
將焦點命令與共用選單列控制項分開
共用標籤不需要選取作用中的視窗:每個使用者都會收到同一個模型。編輯目前焦點視窗草稿的動作則有不同需求,因為它必須識別特定視窗。
對於標準 app 選單命令,Apple 說明可使用 focusedSceneValue 發佈視窗擁有的模型,再於 Commands 實作中透過 @FocusedValue 使用它。其範例會在沒有焦點模型時停用命令。這是讓操作目標隨作用中場景變化的支援方式。使用 SwiftUI 建置及自訂選單列。
不要從該命令範例推斷,開啟 MenuBarExtra 後就能可靠地參照先前作用中的編輯器。本文的面板刻意操作 app 全域狀態。如果面板必須指定個別編輯器為目標,請加入明確的目標選取設計,例如由 app 擁有的編輯器識別碼清單,而不要假設應由哪個視窗接收操作。
只有在編輯器需要時才加入 AppKit 或 TextKit
這個標籤編輯器只需要 SwiftUI 控制項。如果需求擴大,必須加入自訂的 AppKit 文字視圖,Apple 支援的橋接方式是使用 NSViewRepresentable,在 SwiftUI 階層中建立、更新和拆除 NSView。Apple 也說明了如何使用協調器,將委派和目標動作訊息傳回 SwiftUI。NSViewRepresentable。
加入此橋接時,請保留原有的擁有權配置:每個編輯器的視圖實例和編輯狀態都應與其視窗關聯,而共用內容則只在預期要共用時透過 app 模型傳遞。協調器應透過繫結或模型動作轉送編輯內容。作為實作上的預防措施,在取代文字視圖的內容前,請先比較傳入內容,避免無關的共用更新反覆重設正在進行的編輯。
對於 TextKit 2 實作,Apple 指出 textLayoutManager 是現代的版面配置引擎存取點。Apple 的 TextKit 說明指出,在 TextKit 2 文字視圖上存取舊版 layoutManager 可能會觸發相容模式,將該視圖切換至 TextKit 1,且不會自動切換回來。請避免只為了檢查文字視圖而使用這個舊版屬性。TextKit、TextKit 與文字視圖的新功能。
如果必須支援 macOS 13,請保留相同的擁有權配置,但改用較舊的觀察方式:ObservableObject 搭配 @Published、負責擁有物件的 @StateObject,以及 .environmentObject 搭配接收端的 @EnvironmentObject 屬性。Apple 的遷移指南說明了這些機制與新版 Observation 方法的對應關係。變更觀察系統時,仍應保留預期的共用範圍。從 Observable Object 協定遷移至 Observable 巨集。
請根據使用者對介面的預期來選擇擁有者:共用標籤由 app 擁有一個實例,每個未完成的草稿由各自的視窗擁有,並以明確的動作將兩者連結。每當選單列面板或編輯器擴充時,都保留兩個視窗的操作流程作為迴歸檢查。
來源
- 管理 app 中的模型資料 — Apple
- WindowGroup — Apple
- MenuBarExtra — Apple
- 視窗 — Apple
- MainActor — Apple
- Bindable — Apple
- 使用 SwiftUI 建置及自訂選單列 — Apple
- NSViewRepresentable — Apple
- TextKit — Apple
- TextKit 與文字視圖的新功能 — Apple
- 從 Observable Object 協定遷移至 Observable 巨集 — Apple