Храните общее наблюдаемое состояние в SwiftUI App, а затем передайте один и тот же экземпляр обеим сценам. Черновик каждого окна храните в его корневом представлении. Так элемент управления в строке меню сможет обновлять все окна, а в каждом окне останется свой незавершённый текст.

В этом руководстве показана такая схема на примере небольшого редактора меток для Mac: в нескольких окнах отображается одна общая метка, в каждом окне есть независимый черновик, а панель в строке меню позволяет редактировать общую метку или открывать ещё одно окно. Весь текст примера относится к приложению и содержит синтетические метки.

Определите, какие изменения должны применяться во всех окнах

Сначала определите ожидаемое поведение каждого значения. Общая метка должна меняться во всех окнах при редактировании. Незавершённый черновик должен меняться только в том окне, где вводят текст. Эти требования определяют, где хранить значения.

Apple указывает, что каждое окно, созданное с помощью WindowGroup, имеет собственное хранилище состояния, созданного внутри иерархии его представлений. В руководстве Apple по данным модели, напротив, показано, как хранить наблюдаемую ссылку на верхнем уровне App и передавать её в иерархию представлений. Сочетание этих подходов даёт следующую схему владения. WindowGroup, Управление данными модели в приложении.

Значение Владелец в этом примере Ожидаемое поведение
Общая метка App, с использованием @State и модели @Observable Во всех окнах и на панели в строке меню редактируется одно и то же значение
Незавершённый черновик Корневое представление каждого окна, с использованием @State Ввод текста затрагивает только это окно
Элементы управления в строке меню Получают модель, которой владеет приложение Изменения доступны всем потребителям этого экземпляра
Действие открытия окна Получает из окружения представления строки меню Открывает окно из указанной группы

Различие заключается во владельце экземпляра. Если создать два отдельных объекта с одним и тем же именем класса, их содержимое не станет общим. Точно так же размещение ссылки на один объект, которым владеет приложение, в нескольких окнах не создаёт независимые копии этого объекта. Поэтому в примере общая модель создаётся один раз на уровне приложения.

Задайте минимальную версию macOS до добавления сцен

Используйте проект приложения SwiftUI для macOS с целевой версией macOS 14 или новее и набором инструментов Xcode, поддерживающим Observation, например Xcode 15 или новее. Интеграция Observation в SwiftUI доступна начиная с macOS 14. MenuBarExtra и его стиль окна доступны начиная с macOS 13, поэтому для этой реализации минимальную версию определяет Observation. Управление данными модели в приложении, MenuBarExtra, окно.

Для панели в строке меню используется .menuBarExtraStyle(.window), поскольку она содержит текстовые поля и пользовательскую компоновку. Apple описывает этот стиль как окно, похожее на всплывающую панель, с элементами управления, оформленными так же, как в обычном окне. Это отдельная сцена, не входящая в WindowGroup редактора. окно.

В примере также явно привязаны модель и типы интерфейса к @MainActor. Apple определяет главный актор как использующий исполнитель, эквивалентный главной очереди диспетчеризации. Здесь все изменения инициируются элементами управления интерфейса; фоновые задачи не требуются. MainActor.

Создайте общую модель и независимые черновики окон

Замените сгенерированную точку входа приложения следующим полным примером в одном файле. В целевом объекте оставьте только одно объявление @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 документирует передачу окружения внутри иерархии представлений; два внедрения явно задают зависимость каждой иерархии. Управление данными модели в приложении.

Используйте @Bindable, когда элементу управления требуется привязка

SharedLabelControls получает доступ к модели через @Environment(LabelStore.self). Внутри body локальная переменная @Bindable предоставляет проецируемую привязку, необходимую для TextField. Она ссылается на существующий наблюдаемый объект и не создаёт ещё одно хранилище. Apple документирует именно такой шаблон: от окружения к привязке. Bindable.

Вместо этого поле черновика использует $draft, поскольку его значение уже принадлежит @State представления окна. Кнопка копирует текущий черновик в общую метку. Это явное действие: последующие изменения общей метки не перезаписывают черновики ни в одном из окон. Так в примере задана семантика редактирования.

Точно укажите идентификатор группы окон

Кнопка в строке меню получает openWindow из собственного окружения и вызывает его с аргументом \"label-editor\", который совпадает с объявлением группы. Apple документирует этот подход с идентификатором для создания окна из группы. Кнопка предназначена для открытия ещё одного редактора, поэтому пример не обещает найти и повторно использовать конкретное существующее окно. WindowGroup.

Проверьте область видимости, последовательно открыв два окна

Ниже приведена проверка ожидаемого поведения примера. Она позволяет отличить общий экземпляр модели от независимого хранилища черновиков без дополнительной инструментации.

  1. Запустите приложение. В первом редакторе отображаются общая метка Sample Alpha и черновик Sample Draft.
  2. Откройте панель в строке меню и выберите Open Another Editor.
  3. Измените черновик в первом окне на Draft One. Оставьте черновик во втором окне без изменений.
  4. На панели в строке меню измените общую метку на Sample Beta. В обоих окнах редактора должна отображаться эта общая метка, а значения черновиков должны остаться разными.
  5. В первом окне выберите Use Draft as Shared Label. В обоих окнах и на панели в строке меню общей меткой станет Draft One. Черновик во втором окне останется Sample Draft.

Эта последовательность проверяет три разных сценария: локальный ввод, изменение на панели в строке меню и явное копирование локального значения в общее. Если локальный ввод меняет черновик в другом окне, проверьте, не перенесли ли черновик в хранилище, которым владеет приложение. Если изменения с панели в строке меню не доходят до окон, проверьте, не создали ли в иерархии строки меню другой LabelStore().

Симптом Что проверить в первую очередь
Метки в окне и в строке меню отличаются Вместо одного общего экземпляра модели созданы отдельные экземпляры
Все черновики меняются одновременно Черновики хранятся в общей модели
Не удаётся прочитать модель в представлении В этой иерархии отсутствует .environment(store)
Редактор неожиданно сбрасывается Изменился идентификатор представления-владельца или заменена его модель
Не удаётся открыть ещё один редактор Идентификаторы объявленной и запрошенной сцен не совпадают

Для случая с отсутствующим окружением последствия задокументированы: чтение необязательного наблюдаемого объекта по типу предполагает, что предок предоставил этот объект; по словам Apple, если объекта нет, возникает исключение. Также предоставляйте зависимость в предпросмотрах и альтернативных точках входа. Управление данными модели в приложении.

Пример хранит всё в памяти и не включает реализацию сохранения или восстановления. Поэтому при закрытии окна следует считать его незавершённый черновик утраченным, а при завершении приложения — общей метку. Если черновики должны сохраняться после этих действий, прежде чем расширять пример, сформулируйте отдельное требование к постоянному хранению данных.

Отделяйте команды для активного окна от общих элементов управления в строке меню

Для общей метки не нужно выбирать активное окно: все потребители получают одну и ту же модель. Действие, которое редактирует черновик текущего окна, требует другого подхода, поскольку для него нужно определить конкретное окно.

Для стандартных команд меню приложения Apple документирует публикацию модели, принадлежащей окну, с помощью focusedSceneValue и её получение через @FocusedValue в реализации Commands. В примере Apple команда отключается, если нет модели, связанной с фокусом. Это поддерживаемый способ для действий, цель которых соответствует активной сцене. Создание и настройка строки меню с помощью SwiftUI.

Не следует делать вывод из этого примера команд, что открытие MenuBarExtra обеспечивает надёжную ссылку на редактор, который был активен ранее. В этой статье панель намеренно работает с состоянием всего приложения. Если панель должна быть нацелена на отдельный редактор, предусмотрите явный выбор цели — например, список идентификаторов редакторов, которыми владеет приложение, — вместо предположений о том, какое окно должно получить действие.

Добавляйте AppKit или TextKit, только если этого требует редактор

Для редактора меток достаточно элементов управления SwiftUI. Если требования расширятся и понадобится пользовательское текстовое представление AppKit, NSViewRepresentable — поддерживаемый Apple способ создать, обновить и удалить NSView в иерархии SwiftUI. Apple также описывает использование координатора для передачи в SwiftUI сообщений от делегатов и обработчиков действий. NSViewRepresentable.

При добавлении этого моста сохраняйте схему владения: экземпляр представления и состояние редактирования каждого редактора должны относиться к его окну, а общий контент следует передавать через модель приложения только там, где обмен действительно нужен. Координатор должен передавать изменения через привязку или действие модели. В качестве меры предосторожности при реализации сравнивайте входящий контент перед заменой содержимого текстового представления, чтобы несвязанные общие изменения не сбрасывали текущий ввод при каждом обновлении.

Для реализации на TextKit 2 Apple указывает textLayoutManager как современную точку доступа к подсистеме компоновки. В руководстве Apple по TextKit говорится, что обращение к устаревшему layoutManager в текстовом представлении на TextKit 2 может включить режим совместимости, переключив это представление на TextKit 1 без автоматического возврата. Не обращайтесь к этому устаревшему свойству только для проверки текстового представления. TextKit, Что нового в TextKit и текстовых представлениях.

Если требуется поддержка macOS 13, сохраните ту же схему владения, но используйте прежний способ наблюдения: ObservableObject с @Published, владеющий объект @StateObject и .environmentObject вместе с получающими его свойствами @EnvironmentObject. В руководстве Apple по миграции объясняется, как эти механизмы соотносятся с новым подходом Observation. При смене системы наблюдения сохраняйте заданные границы общего доступа. Переход с протокола Observable Object на макрос Observable.

Выбирайте владельца исходя из ожидаемого поведения интерфейса: один экземпляр приложения для общей метки, отдельное значение каждого окна для каждого незавершённого черновика и явное действие, связывающее их. Используйте последовательность с двумя окнами как регрессионную проверку при каждом расширении панели в строке меню или редактора.

Источники