Créez un diagramme de tournoi à embranchements dans une app Mac en calculant une seule fois les centres des nœuds, en plaçant les cartes avec un Layout SwiftUI personnalisé et en dessinant les connecteurs derrière elles avec Canvas. Une vue de défilement sur les deux axes permet de lire le diagramme lorsque la fenêtre est plus petite que son contenu.
Ce tutoriel crée un diagramme synthétique à sept nœuds, avec des libellés neutres tels que Node 1. Les branches s’étendent vers la gauche à partir d’une racine située à droite. L’objectif concret est d’afficher des cartes reliées et espacées de manière régulière dans une fenêtre macOS redimensionnable, en faisant dépendre le placement et le dessin de la même géométrie.
Choisir une structure de mise en page qui aligne les cartes et les lignes
Pour cette implémentation, choisissez une cible de déploiement macOS 13 ou ultérieure. Apple indique que Layout est disponible à partir de macOS 13. Ses deux méthodes requises renvoient la taille du conteneur et positionnent ses sous-vues. Cette structure convient donc à la disposition de cartes à des positions calculées. Layout
Conservez les cartes des nœuds sous forme de vues SwiftUI ordinaires. Utilisez un Canvas en arrière-plan uniquement pour les traits des connecteurs. Apple explique que les éléments individuels dessinés dans un canevas ne disposent pas de leur propre interactivité ni de leur propre accessibilité, y compris les vues fournies comme symboles du canevas. Des cartes séparées permettent d’ajouter facilement des contrôles ordinaires par la suite. Canvas
L’implémentation comporte trois parties : un arbre qui décrit les branches, un calcul géométrique qui produit les centres des nœuds et les arêtes, et une vue qui combine les cartes et le dessin des connecteurs. Les deux couches de rendu reçoivent la même valeur géométrique. Ainsi, une modification de l’arbre ne peut pas mettre à jour les cartes tout en laissant derrière elle un tableau de connecteurs géré séparément et devenu obsolète.
La règle de dimensionnement est volontairement fixe : les cartes conservent la même taille lorsque la fenêtre change, et la zone d’affichage qui les entoure défile. Apple indique que ScrollView prend en charge le défilement horizontal, vertical ou les deux, mais pas le zoom. ScrollView
Calculer la position des branches à partir de l’espacement des feuilles
Commencez par les feuilles, c’est-à-dire les nœuds sans enfants. Attribuez à chaque feuille un emplacement vertical successif. Placez ensuite chaque parent à mi-chemin entre son premier et son dernier enfant. La position horizontale dépend de la profondeur : la racine occupe la colonne la plus à droite, et chaque niveau de profondeur supplémentaire déplace le nœud d’une colonne vers la gauche.
Pour cet exemple, utilisez des cartes de 132 sur 44 points, un espacement horizontal de 64 points et un espacement vertical de 28 points. Ces valeurs servent à illustrer les choix de conception. Avec trois colonnes et quatre feuilles, le contenu obtenu mesure 524 sur 260 points, sans compter la marge extérieure.
| Quantité | Calcul | Résultat |
|---|---|---|
| Pas entre les colonnes | Largeur de la carte + espacement horizontal | 196 points |
| Pas entre les feuilles | Hauteur de la carte + espacement vertical | 72 points |
| Largeur du contenu | 3 × 132 + 2 × 64 | 524 points |
| Hauteur du contenu | 4 × 44 + 3 × 28 | 260 points |
Les centres des quatre feuilles ont ainsi pour coordonnées verticales 22, 94, 166 et 238. Leurs parents directs se trouvent à 58 et 202, et la racine à 130. Ces coordonnées constituent un bon outil de diagnostic : un parent doit être centré entre les centres de ses enfants les plus éloignés, et chaque carte doit tenir dans le rectangle de contenu indiqué.
L’algorithme ci-dessous accepte aussi des branches de profondeurs inégales. Une feuille peu profonde reste dans sa colonne de profondeur au lieu d’être automatiquement déplacée jusqu’à la colonne la plus à gauche. C’est une règle intentionnelle de cet exemple. Avant de modifier la formule des coordonnées, déterminez si l’alignement par profondeur ou par feuille convient à votre diagramme.
Construire l’exemple macOS complet
Utilisez une cible d’app SwiftUI pour macOS dans Xcode, avec une cible de déploiement macOS 13 ou ultérieure. Remplacez le fichier d’entrée de l’app généré par les trois blocs de code suivants, dans l’ordre, au sein d’un seul fichier Swift. Ils constituent ensemble le programme complet. Supprimez l’ancienne déclaration de l’app si elle se trouve dans un autre fichier : Apple exige un seul point d’entrée d’app. App
Définir l’arbre et sa géométrie
Chaque nœud possède un identifiant entier et un tableau d’enfants. Le générateur de géométrie parcourt les enfants avant de calculer le centre de leur parent, puis rassemble les placements des nœuds et les arêtes. Un identifiant répété déclenche une erreur de précondition au lieu de produire une collection de vues ambiguë.
import SwiftUI
struct BranchNode {
let id: Int
let children: [BranchNode]
init(_ id: Int, _ children: [BranchNode] = []) {
self.id = id
self.children = children
}
var depth: Int {
(children.map { $0.depth + 1 }.max()) ?? 0
}
}
struct DiagramNode: Identifiable {
let id: Int
let center: CGPoint
}
struct DiagramEdge {
let child: CGPoint
let parent: CGPoint
}
struct DiagramGeometry {
static let cardSize = CGSize(width: 132, height: 44)
static let horizontalGap: CGFloat = 64
static let verticalGap: CGFloat = 28
let nodes: [DiagramNode]
let edges: [DiagramEdge]
let size: CGSize
static func make(root: BranchNode) -> DiagramGeometry {
let maximumDepth = root.depth
var nextLeaf = 0
var nodes: [DiagramNode] = []
var edges: [DiagramEdge] = []
var identifiers = Set<Int>()
func visit(_ node: BranchNode, depth: Int) -> CGPoint {
precondition(
identifiers.insert(node.id).inserted,
"Every node must have a unique identifier."
)
let x = cardSize.width / 2
+ CGFloat(maximumDepth - depth)
* (cardSize.width + horizontalGap)
let childCenters = node.children.map {
visit($0, depth: depth + 1)
}
let y: CGFloat
if let first = childCenters.first,
let last = childCenters.last {
y = (first.y + last.y) / 2
} else {
y = cardSize.height / 2
+ CGFloat(nextLeaf)
* (cardSize.height + verticalGap)
nextLeaf += 1
}
let center = CGPoint(x: x, y: y)
nodes.append(DiagramNode(id: node.id, center: center))
for child in childCenters {
edges.append(DiagramEdge(child: child, parent: center))
}
return center
}
_ = visit(root, depth: 0)
let size = CGSize(
width: CGFloat(maximumDepth + 1) * cardSize.width
+ CGFloat(maximumDepth) * horizontalGap,
height: CGFloat(nextLeaf) * cardSize.height
+ CGFloat(max(0, nextLeaf - 1)) * verticalGap
)
return DiagramGeometry(nodes: nodes, edges: edges, size: size)
}
}
Le modèle contient toujours une racine. Même un arbre sans enfants produit donc une carte avec une taille de contenu non nulle. Ce programme ne prévoit pas de cas distinct pour un diagramme vide. Si une application autorise l’absence de racine, gérez explicitement cette situation avant de construire cette géométrie.
Placer les cartes à l’aide d’une valeur de mise en page personnalisée
Transmettez le centre de chaque carte par l’intermédiaire d’un LayoutValueKey. Le modèle documenté par Apple consiste à définir une valeur avec layoutValue(key:value:), puis à la lire depuis le proxy de sous-vue à l’aide de la clé. La position est ainsi associée à sa carte au lieu de dépendre d’indices parallèles dans des tableaux. LayoutValueKey
struct DiagramCenterKey: LayoutValueKey {
static let defaultValue: CGPoint = .zero
}
struct BranchLayout: Layout {
let contentSize: CGSize
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
contentSize
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
let cardProposal = ProposedViewSize(
width: DiagramGeometry.cardSize.width,
height: DiagramGeometry.cardSize.height
)
for subview in subviews {
let center = subview[DiagramCenterKey.self]
subview.place(
at: CGPoint(
x: bounds.minX + center.x,
y: bounds.minY + center.y
),
anchor: .center,
proposal: cardProposal
)
}
}
}
La mise en page renvoie la même taille finie quelle que soit la proposition, car son contenu est inflexible. Apple montre explicitement qu’une mise en page inflexible peut ignorer la proposition. SwiftUI peut appeler la méthode de dimensionnement plusieurs fois avec différentes propositions pendant un passage de mise en page ; cette méthode renvoie donc simplement la géométrie sans mettre à jour le modèle. sizeThatFits(proposal:subviews:cache:)
Le placement ajoute bounds.minX et bounds.minY à chaque centre local. Les limites fournies sont exprimées dans le repère de coordonnées du parent. Supposer une origine nulle compromettrait donc le placement lorsque le conteneur se déplace. Apple exige également que les calculs de dimensionnement et de placement concordent sur les espacements et les dimensions. placeSubviews(in:proposal:subviews:cache:)
Avec .center, le point fourni représente le milieu de chaque carte. Chaque proxy est placé exactement une fois, dans la méthode de placement, conformément à l’API de placement des sous-vues. Les cadres explicites des cartes dans le bloc suivant garantissent que les cartes affichées correspondent aux dimensions utilisées par la géométrie. place(at:anchor:proposal:)
Dessiner les connecteurs et afficher la fenêtre
Chaque connecteur part du bord droit de la carte de l’enfant et se termine au bord gauche de celle du parent. Deux points intermédiaires créent un tracé à angle droit au milieu de l’espace. Path.addLines(_:) crée des segments droits reliés à partir d’un tableau de points. addLines(_:)
struct BranchDiagram: View {
let geometry: DiagramGeometry
var body: some View {
BranchLayout(contentSize: geometry.size) {
ForEach(geometry.nodes) { node in
Text("Node \(node.id)")
.font(.body)
.frame(
width: DiagramGeometry.cardSize.width,
height: DiagramGeometry.cardSize.height
)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color.gray.opacity(0.12))
)
.overlay {
RoundedRectangle(cornerRadius: 8)
.stroke(Color.secondary, lineWidth: 1)
}
.layoutValue(
key: DiagramCenterKey.self,
value: node.center
)
}
}
.background {
Canvas { context, _ in
var path = Path()
for edge in geometry.edges {
let start = CGPoint(
x: edge.child.x
+ DiagramGeometry.cardSize.width / 2,
y: edge.child.y
)
let end = CGPoint(
x: edge.parent.x
- DiagramGeometry.cardSize.width / 2,
y: edge.parent.y
)
let middleX = (start.x + end.x) / 2
path.addLines([
start,
CGPoint(x: middleX, y: start.y),
CGPoint(x: middleX, y: end.y),
end
])
}
context.stroke(
path,
with: .color(.secondary),
lineWidth: 1.5
)
}
}
}
}
@main
struct BranchDiagramApp: App {
let geometry = DiagramGeometry.make(
root: BranchNode(1, [
BranchNode(2, [BranchNode(4), BranchNode(5)]),
BranchNode(3, [BranchNode(6), BranchNode(7)])
])
)
var body: some Scene {
WindowGroup {
ScrollView([.horizontal, .vertical]) {
BranchDiagram(geometry: geometry)
.padding(24)
}
.frame(minWidth: 360, minHeight: 240)
}
}
}
ForEach reçoit des valeurs de nœud identifiables, conformément à l’exigence d’Apple qui impose soit des éléments identifiables, soit un argument d’identifiant explicite. Si vous rendez ensuite l’arbre modifiable, conservez les identifiants associés aux nœuds logiques. ForEach
Le canevas est directement rattaché à l’arrière-plan de la mise en page ; ses coordonnées locales correspondent donc à celles de la géométrie utilisée pour les cartes. La marge extérieure est appliquée une fois le diagramme assemblé et déplace les deux couches ensemble. Les portions communes des connecteurs frères se chevauchent pour former une jonction en forme de crochet.
WindowGroup fournit la hiérarchie de vues de la fenêtre ainsi que le comportement standard de gestion des fenêtres sous macOS. L’exemple stocke une géométrie synthétique immuable dans la structure de l’app ; il n’installe ni minuteur ni rappel de cycle de vie pour repositionner les vues. WindowGroup
Vérifier le diagramme et diagnostiquer les problèmes courants
Les calculs de cet exemple ont été vérifiés à l’aide d’un petit calcul Python déterministe : sept centres, six arêtes, un rectangle de contenu de 524 sur 260 points, et toutes les cartes à l’intérieur de ce rectangle. Le programme SwiftUI a été examiné, mais n’a pas pu être compilé dans l’environnement de recherche, car aucun compilateur Swift n’y était disponible.
Lorsque vous construisez l’app dans Xcode, vérifiez les résultats observables suivants :
- Quatre cartes apparaissent dans la colonne la plus à gauche, deux au milieu et une à droite.
- Chaque connecteur touche le milieu horizontal du bord correspondant de la carte.
- Chaque parent se situe verticalement entre ses enfants les plus éloignés.
- Le redimensionnement de la fenêtre conserve les dimensions des cartes et affiche le défilement au besoin.
- En remplaçant la racine de l’exemple par
BranchNode(1), une seule carte s’affiche sans connecteurs.
Si les cartes et les lignes sont décalées, vérifiez d’abord l’emplacement des modificateurs. Une marge ou un décalage appliqué uniquement aux cartes introduit un ajustement supplémentaire des coordonnées qui n’est pas répercuté dans la géométrie des connecteurs. Comme montré, gardez l’espacement global du diagramme en dehors de BranchDiagram.
Si une carte apparaît de façon inattendue à l’origine de la mise en page, vérifiez que sa vue finale possède bien la valeur DiagramCenterKey. La valeur par défaut de cette clé est .zero ; une affectation manquante place donc le centre de la carte à l’origine. Si le programme s’arrête sur la précondition des identifiants, attribuez un identifiant distinct à chaque nœud avant le rendu.
Les dimensions fixes des cartes conviennent aux libellés synthétiques courts de cet exemple. Pour des textes plus longs ou des polices plus grandes, ajoutez une phase de mesure : demandez leur taille aux proxys de sous-vues, calculez la largeur des colonnes et l’espacement des sous-arbres à partir de ces mesures, puis utilisez ces mêmes résultats pour les extrémités des connecteurs. Une proposition de taille ne force pas une sous-vue à accepter cette taille. place(at:anchor:proposal:)
Cet algorithme décrit un arbre : chaque enfant affiché appartient à un seul parent. Il ne trace pas de liens transversaux arbitraires et ne représente pas un même nœud partagé entre plusieurs parents. Ces besoins nécessitent un autre modèle géométrique et une règle de tracé explicite. De même, une animation devrait déplacer la géométrie des connecteurs en même temps que les positions des cartes ; cet exemple utilise un placement statique.
Confiez à un seul calcul géométrique la responsabilité de la taille du contenu, des centres des cartes et des extrémités des arêtes. Vous disposerez ainsi d’un point précis pour ajuster l’espacement, examiner les coordonnées et enrichir le diagramme, tout en préservant la cohérence entre la mise en page SwiftUI et le dessin.
Sources
- Layout — Apple
- Canvas — Apple
- ScrollView — Apple
- App — Apple
- LayoutValueKey — Apple
- sizeThatFits(proposal:subviews:cache:) — Apple
- placeSubviews(in:proposal:subviews:cache:) — Apple
- place(at:anchor:proposal:) — Apple
- addLines(_:) — Apple
- ForEach — Apple
- WindowGroup — Apple