Gérez l’état observable partagé dans votre App SwiftUI, puis transmettez la même instance aux deux scènes. Conservez le brouillon de chaque fenêtre dans sa vue racine. Ainsi, une commande de la barre des menus peut mettre à jour toutes les fenêtres, tandis que chacune conserve son propre texte inachevé.
Ce tutoriel met en place cette organisation pour un petit éditeur d’étiquettes Mac : plusieurs fenêtres affichent une même étiquette, chacune possède un brouillon indépendant, et un panneau de la barre des menus peut modifier l’étiquette partagée ou ouvrir une autre fenêtre. Tous les textes d’exemple appartiennent à l’app et utilisent des étiquettes fictives.
Déterminez quelles modifications doivent atteindre toutes les fenêtres
Commencez par définir le comportement attendu pour chaque valeur. Une étiquette partagée doit changer partout lorsqu’on la modifie. Un brouillon inachevé ne doit changer que dans la fenêtre où quelqu’un saisit du texte. Ces exigences déterminent où placer les valeurs.
Apple précise que chaque fenêtre créée par un WindowGroup dispose d’un stockage indépendant pour l’état instancié dans sa hiérarchie de vues. À l’inverse, les recommandations d’Apple sur les données de modèle montrent comment gérer une référence observable au niveau supérieur de l’App et la transmettre à une hiérarchie de vues. La combinaison de ces modèles donne le plan de propriété suivant. WindowGroup, Gérer les données de modèle dans votre app.
| Valeur | Propriétaire dans cet exemple | Comportement attendu |
|---|---|---|
| Étiquette partagée | App, avec @State et un modèle @Observable |
Toutes les fenêtres et le panneau de la barre des menus modifient la même valeur |
| Brouillon inachevé | Vue racine de chaque fenêtre, avec @State |
La saisie reste limitée à cette fenêtre |
| Commandes de la barre des menus | Reçoivent le modèle géré par l’app | Les modifications atteignent tous les consommateurs de cette instance |
| Action d’ouverture d’une fenêtre | Accède à l’environnement de la vue de la barre des menus | Ouvre une fenêtre du groupe identifié |
La distinction tient au propriétaire de l’instance. Créer deux objets distincts portant le même nom de classe ne partage pas leur contenu. De même, placer une référence vers un objet unique géré par l’app dans plusieurs fenêtres ne crée pas des copies indépendantes de cet objet. C’est pourquoi l’exemple crée son modèle partagé une seule fois, au niveau de l’app.
Définissez la configuration minimale de macOS avant d’ajouter les scènes
Utilisez un projet d’app SwiftUI pour macOS avec une cible de déploiement macOS 14 ou ultérieure et une chaîne d’outils Xcode prenant en charge Observation, comme Xcode 15 ou ultérieur. L’intégration d’Observation dans SwiftUI commence avec macOS 14. MenuBarExtra et son style de fenêtre sont disponibles à partir de macOS 13 ; Observation impose donc la version minimale plus récente pour cette implémentation. Gérer les données de modèle dans votre app, MenuBarExtra, fenêtre.
Le panneau de la barre des menus utilise .menuBarExtraStyle(.window) parce qu’il contient des champs de texte et une mise en page personnalisée. Apple décrit ce style comme une fenêtre semblable à un popover, dont les commandes sont stylisées comme celles d’une fenêtre ordinaire. Il s’agit d’une scène distincte du WindowGroup de l’éditeur. fenêtre.
L’exemple isole aussi explicitement son modèle et ses types d’interface avec @MainActor. Apple définit l’acteur principal comme ayant un exécuteur équivalent à la file d’attente principale de dispatch. Ici, toutes les modifications proviennent de commandes de l’interface ; aucun traitement en arrière-plan n’est nécessaire. MainActor.
Créez le modèle partagé et les brouillons indépendants des fenêtres
Remplacez le point d’entrée généré de l’app par l’exemple complet, contenu dans un seul fichier, ci-dessous. La cible ne doit contenir qu’une seule déclaration @main. L’exemple comprend les imports, le modèle, les vues et le texte initial. Il a été vérifié par rapport aux API documentées, mais n’a pas pu être compilé dans cet environnement, faute de chaîne d’outils 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)
}
}
Il y a deux injections explicites dans l’environnement : une pour le contenu de la fenêtre et une pour le contenu de la barre des menus. Toutes deux reçoivent store. Ne vous attendez pas à ce qu’un modificateur d’environnement appliqué au contenu d’une fenêtre fournisse une dépendance à une scène sœur. Apple documente le partage d’environnement au sein d’une hiérarchie de vues ; ces deux injections explicitent la dépendance de chaque hiérarchie. Gérer les données de modèle dans votre app.
Utilisez @Bindable lorsqu’une commande a besoin d’une liaison
SharedLabelControls lit le modèle avec @Environment(LabelStore.self). Dans son corps, la variable locale @Bindable fournit la liaison projetée requise par TextField. Elle fait référence à l’objet observable existant et ne crée pas un autre magasin. Apple documente exactement ce modèle de liaison entre l’environnement et une commande. Bindable.
Le champ de brouillon utilise plutôt $draft, car sa valeur appartient déjà au @State de la vue de la fenêtre. Le bouton copie le brouillon actuel dans l’étiquette partagée. Cette action est explicite : les modifications ultérieures de l’étiquette partagée ne réécrivent le brouillon d’aucune fenêtre. Ce sont les règles de modification choisies pour l’exemple.
Faites correspondre exactement l’identifiant du groupe de fenêtres
Le bouton de la barre des menus récupère openWindow dans son propre environnement et l’appelle avec "label-editor", ce qui correspond à la déclaration du groupe. Apple documente cette approche fondée sur un identifiant pour créer une fenêtre à partir d’un groupe. Le bouton sert à ouvrir un autre éditeur ; cet exemple ne garantit donc pas qu’il trouve et réutilise une fenêtre existante en particulier. WindowGroup.
Vérifiez la portée avec une séquence à deux fenêtres
La séquence suivante vérifie le comportement attendu de l’exemple. Elle distingue l’identité du modèle partagé du stockage indépendant des brouillons, sans instrumentation supplémentaire.
- Lancez l’app. Le premier éditeur affiche l’étiquette partagée
Sample Alphaet le brouillonSample Draft. - Ouvrez le panneau de la barre des menus et choisissez
Open Another Editor. - Remplacez le brouillon de la première fenêtre par
Draft One. Laissez celui de la deuxième fenêtre inchangé. - Dans le panneau de la barre des menus, remplacez l’étiquette partagée par
Sample Beta. Les deux fenêtres de l’éditeur doivent afficher cette étiquette, tout en conservant leurs brouillons respectifs. - Dans la première fenêtre, choisissez
Use Draft as Shared Label. L’étiquette partagée devientDraft Onedans les deux fenêtres et dans le panneau de la barre des menus. Le brouillon de la deuxième fenêtre resteSample Draft.
Cette séquence vérifie trois chemins distincts : la saisie locale, la modification depuis la barre des menus et la copie explicite d’une valeur locale vers l’état partagé. Si la saisie locale modifie le brouillon de l’autre fenêtre, vérifiez si le brouillon a été déplacé dans le magasin géré par l’app. Si les modifications faites dans la barre des menus n’atteignent pas les fenêtres, vérifiez si un autre LabelStore() a été créé dans la hiérarchie de la barre des menus.
| Symptôme | Premier élément à vérifier |
|---|---|
| Les étiquettes des fenêtres et de la barre des menus divergent | Des initialisations de modèle distinctes au lieu d’une instance partagée |
| Tous les brouillons changent ensemble | Brouillons stockés dans un modèle partagé |
| Une vue échoue lors de la lecture de son modèle | .environment(store) manquant dans cette hiérarchie |
| Un éditeur se réinitialise de manière inattendue | Modification de l’identité de la vue propriétaire ou remplacement de son modèle |
| L’ouverture d’un autre éditeur échoue | Incohérence entre l’identifiant de scène déclaré et celui demandé |
L’absence de l’environnement a une conséquence documentée : lire un objet observable non optionnel par type suppose qu’un ancêtre l’a fourni ; Apple précise que l’absence de cet objet provoque une exception. Fournissez aussi la dépendance dans les aperçus et les points d’entrée alternatifs. Gérer les données de modèle dans votre app.
L’exemple conserve toutes les données en mémoire et ne contient aucune logique de sauvegarde ou de restauration. La fermeture d’une fenêtre doit donc être considérée comme l’abandon de son brouillon inachevé, et quitter l’app comme l’abandon de l’étiquette partagée. Si les brouillons doivent survivre à ces actions, définissez une exigence distincte de persistance avant de prolonger cet exemple.
Séparez les commandes ciblées de l’état partagé des commandes de la barre des menus
Une étiquette partagée n’a pas besoin qu’on sélectionne la fenêtre active : tous les consommateurs reçoivent le même modèle. Une action qui modifie le brouillon de la fenêtre actuellement sélectionnée répond à une exigence différente, car elle doit identifier une fenêtre précise.
Pour les commandes standard du menu de l’app, Apple documente la publication d’un modèle géré par une fenêtre avec focusedSceneValue, puis son utilisation via @FocusedValue dans une implémentation de Commands. Son exemple désactive la commande lorsqu’aucun modèle n’est sélectionné. C’est une approche prise en charge pour les actions dont la cible suit la scène active. Créer et personnaliser la barre des menus avec SwiftUI.
Ne déduisez pas de cet exemple de commande que l’ouverture d’un MenuBarExtra lui donne une référence fiable vers l’éditeur qui était actif auparavant. Le panneau de cet article agit délibérément sur un état partagé par toute l’app. Si le panneau doit cibler un éditeur particulier, ajoutez une sélection explicite de la cible, par exemple une liste d’identifiants d’éditeur gérés par l’app, au lieu de supposer quelle fenêtre doit recevoir l’action.
N’ajoutez AppKit ou TextKit que si l’éditeur en a besoin
L’éditeur d’étiquettes n’a besoin que de commandes SwiftUI. Si ses exigences évoluent et nécessitent une vue de texte AppKit personnalisée, NSViewRepresentable est le pont pris en charge par Apple pour créer, mettre à jour et détruire une NSView dans une hiérarchie SwiftUI. Apple décrit aussi comment utiliser un coordinateur pour transmettre à SwiftUI les messages des délégués et des actions de cible. NSViewRepresentable.
Conservez le plan de propriété lorsque vous ajoutez ce pont : associez l’instance de vue et l’état de modification de chaque éditeur à sa fenêtre, et transmettez le contenu partagé via le modèle de l’app uniquement lorsqu’il est destiné à être partagé. Un coordinateur doit transmettre les modifications via une liaison ou une action du modèle. Par précaution lors de l’implémentation, comparez le contenu reçu avant de remplacer celui de la vue de texte, afin que des mises à jour partagées sans rapport ne réinitialisent pas répétitivement une modification en cours.
Pour une implémentation avec TextKit 2, Apple désigne textLayoutManager comme le point d’accès moderne au moteur de mise en page. Sa session TextKit explique que l’accès à layoutManager, propriété héritée, sur une vue de texte TextKit 2 peut déclencher le mode de compatibilité et faire basculer cette vue vers TextKit 1 sans retour automatique. Évitez d’utiliser cette propriété héritée simplement pour examiner une vue de texte. TextKit, Nouveautés de TextKit et des vues de texte.
Si macOS 13 doit être pris en charge, conservez la même organisation de propriété, mais utilisez l’ancien système d’observation : ObservableObject avec @Published, un @StateObject propriétaire et .environmentObject, avec des propriétés réceptrices @EnvironmentObject. Le guide de migration d’Apple explique la correspondance entre ces mécanismes et la nouvelle approche Observation. Un changement de système d’observation doit préserver les limites de partage voulues. Migration du protocole Observable Object vers la macro Observable.
Choisissez le propriétaire en fonction du comportement attendu de l’interface : une instance gérée par l’app pour l’étiquette partagée, une valeur gérée par chaque fenêtre pour chaque brouillon inachevé, et une action explicite pour les relier. Gardez la séquence à deux fenêtres comme vérification de régression à chaque évolution du panneau de la barre des menus ou de l’éditeur.
Sources
- Gérer les données de modèle dans votre app — Apple
- WindowGroup — Apple
- MenuBarExtra — Apple
- fenêtre — Apple
- MainActor — Apple
- Bindable — Apple
- Créer et personnaliser la barre des menus avec SwiftUI — Apple
- NSViewRepresentable — Apple
- TextKit — Apple
- Nouveautés de TextKit et des vues de texte — Apple
- Migration du protocole Observable Object vers la macro Observable — Apple