Crea un diagrama de cuadro con ramas en una app para Mac calculando una sola vez los centros de los nodos, colocando las tarjetas con un Layout personalizado de SwiftUI y dibujando los conectores detrás con Canvas. Una vista de desplazamiento en ambos ejes mantiene el diagrama legible cuando la ventana es más pequeña que su contenido.
Este tutorial crea un diagrama sintético de siete nodos con etiquetas neutras como Node 1. Las ramas se extienden hacia la izquierda desde una raíz situada a la derecha. El objetivo concreto es mostrar tarjetas conectadas y espaciadas de manera uniforme en una ventana de macOS redimensionable, con la disposición y el dibujo basados en la misma geometría.
Elige una estructura de diseño que mantenga alineadas las tarjetas y las líneas
Para esta implementación, usa un destino de implementación de macOS 13 o posterior. Apple documenta que Layout está disponible a partir de macOS 13. Sus dos métodos obligatorios informan del tamaño del contenedor y colocan sus subvistas. Esto lo hace adecuado para organizar tarjetas en posiciones calculadas. Layout
Mantén las tarjetas de los nodos como vistas normales de SwiftUI. Usa un Canvas de fondo únicamente para los trazos de los conectores. Apple explica que los elementos individuales dibujados dentro de un lienzo no tienen interactividad ni accesibilidad propias, incluidas las vistas proporcionadas como símbolos del lienzo. Si las tarjetas están separadas, será más sencillo añadir controles normales más adelante. Canvas
La implementación tiene tres partes: un árbol que describe las ramas, un cálculo geométrico que produce los centros de los nodos y las aristas, y una vista que combina las tarjetas con el dibujo de los conectores. Ambas capas de representación reciben el mismo valor geométrico. Así, al cambiar el árbol, las tarjetas no se actualizan dejando accidentalmente atrás una matriz de conectores mantenida por separado.
La política de tamaño es deliberadamente fija: las tarjetas conservan el mismo tamaño al cambiar el tamaño de la ventana y se desplaza la vista que las contiene. Apple documenta que ScrollView admite desplazamiento horizontal, vertical o en ambos sentidos; no ofrece zoom. ScrollView
Calcula la posición de las ramas a partir del espacio entre hojas
Empieza por las hojas, es decir, los nodos que no tienen hijos. Asigna a cada hoja un espacio vertical sucesivo. Luego coloca cada padre a medio camino entre el primer y el último de sus hijos. La posición horizontal depende de la profundidad: la raíz ocupa la columna más a la derecha y cada nivel adicional se desplaza una columna hacia la izquierda.
Para este ejemplo, usa tarjetas de 132 por 44 puntos, una separación horizontal de 64 puntos y una separación vertical de 28 puntos. Estos valores son parámetros de diseño ilustrativos. Con tres columnas y cuatro hojas, el contenido resultante mide 524 por 260 puntos, sin contar el margen exterior.
| Cantidad | Cálculo | Resultado |
|---|---|---|
| Paso entre columnas | Ancho de tarjeta + separación horizontal | 196 puntos |
| Paso entre hojas | Alto de tarjeta + separación vertical | 72 puntos |
| Ancho del contenido | 3 × 132 + 2 × 64 | 524 puntos |
| Alto del contenido | 4 × 44 + 3 × 28 | 260 puntos |
Así, las cuatro hojas tienen coordenadas verticales centrales de 22, 94, 166 y 238. Sus padres inmediatos se sitúan en 58 y 202, y la raíz, en 130. Estas coordenadas son una referencia útil para el diagnóstico: un padre debe quedar centrado entre los centros de sus hijos extremos, y todas las tarjetas deben caber dentro del rectángulo de contenido informado.
El algoritmo siguiente también admite ramas de distintas profundidades. Una hoja poco profunda permanece en su columna de profundidad, en lugar de desplazarse automáticamente hasta la columna más a la izquierda. Esa es una regla intencional de este ejemplo. Antes de cambiar la fórmula de coordenadas, decide si tu diagrama debe alinearse por profundidad o por hojas.
Crea el ejemplo completo para macOS
Usa un destino de app macOS con SwiftUI en Xcode y un destino de implementación de macOS 13 o posterior. Sustituye el archivo de entrada de app generado por los tres bloques de código siguientes, en orden, dentro de un único archivo Swift. Juntos forman el programa completo. Elimina la declaración de app anterior si está en otro archivo: Apple exige exactamente un punto de entrada de app. App
Define el árbol y su geometría
Cada nodo tiene un identificador entero y una matriz de hijos. El constructor de geometría visita los hijos antes de calcular el centro de su padre y recopila tanto las posiciones de los nodos como las aristas. Un identificador repetido provoca un error de precondición en vez de generar una colección de vistas ambigua.
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)
}
}
El modelo siempre contiene una raíz, por lo que incluso un árbol sin hijos produce una tarjeta con un tamaño de contenido distinto de cero. Este programa no contempla un caso de diagrama vacío. Si una app permite que no haya raíz, gestiona esa condición explícitamente antes de construir esta geometría.
Coloca las tarjetas con un valor de diseño personalizado
Pasa el centro de cada tarjeta mediante un LayoutValueKey. El patrón documentado por Apple consiste en establecer un valor con layoutValue(key:value:) y luego leerlo de la vista proxy de la subvista con la clave. Esto asocia la posición a su tarjeta, en vez de depender de índices de matrices paralelas. 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
)
}
}
}
El diseño devuelve el mismo tamaño finito para cualquier propuesta, porque su contenido es inflexible. Apple muestra explícitamente que un diseño inflexible puede ignorar la propuesta. SwiftUI puede llamar varias veces al método de cálculo de tamaño con distintas propuestas durante una pasada de diseño, así que este método simplemente devuelve la geometría y no modifica el modelo. sizeThatFits(proposal:subviews:cache:)
La colocación suma bounds.minX y bounds.minY a cada centro local. Los límites proporcionados se expresan en el sistema de coordenadas del padre, así que asumir que el origen es cero rompería la colocación cuando el contenedor se mueva. Apple también exige que los cálculos de tamaño y colocación coincidan en cuanto al espaciado y las dimensiones. placeSubviews(in:proposal:subviews:cache:)
Usar .center hace que el punto proporcionado represente el centro de cada tarjeta. Cada vista proxy se coloca exactamente una vez, dentro del método de colocación, como exige la API de colocación de subvistas. Los marcos explícitos de las tarjetas en el siguiente bloque garantizan que coincidan con las dimensiones de la geometría. place(at:anchor:proposal:)
Dibuja los conectores y muestra la ventana
Cada conector empieza en el borde derecho de la tarjeta del hijo y termina en el borde izquierdo de la tarjeta del padre. Dos puntos intermedios crean un recorrido en ángulo recto por el centro del espacio. Path.addLines(_:) crea segmentos rectos conectados a partir de una matriz de puntos. 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 recibe valores de nodo identificables, de acuerdo con el requisito de Apple de usar elementos identificables o un argumento de identificador explícito. Si más adelante haces que el árbol sea editable, conserva los identificadores asociados a los nodos lógicos. ForEach
El lienzo se añade directamente al fondo del diseño, por lo que sus coordenadas locales coinciden con la geometría usada para las tarjetas. El margen exterior se aplica después de ensamblar el diagrama y desplaza ambas capas juntas. Las partes compartidas de los conectores de nodos hermanos se superponen para formar una unión con forma de corchete.
WindowGroup proporciona la jerarquía de vistas de la ventana y el comportamiento estándar de gestión de ventanas de macOS. El ejemplo guarda geometría sintética inmutable en la estructura de la app; no instala temporizadores ni funciones de ciclo de vida para recolocar las vistas. WindowGroup
Verifica el diagrama y diagnostica problemas comunes
La aritmética de este ejemplo se comprobó con un pequeño cálculo determinista en Python: siete centros, seis aristas, un rectángulo de contenido de 524 por 260 puntos y todas las tarjetas dentro de ese rectángulo. Se revisó el programa de SwiftUI, pero no se pudo compilar en el entorno de investigación porque no había un compilador de Swift disponible.
Al compilar la app en Xcode, comprueba estos resultados observables:
- Aparecen cuatro tarjetas en la columna más a la izquierda, dos en la del centro y una a la derecha.
- Cada conector toca el punto medio horizontal del borde correspondiente de la tarjeta.
- Cada padre se sitúa verticalmente entre sus hijos extremos.
- Al cambiar el tamaño de la ventana, las dimensiones de las tarjetas se mantienen y aparece el desplazamiento cuando hace falta.
- Al sustituir la raíz de ejemplo por
BranchNode(1), se muestra una tarjeta sin conectores.
Si las tarjetas y las líneas están desplazadas, revisa primero dónde aplicas los modificadores. El margen o los desplazamientos aplicados solo a las tarjetas introducen un segundo ajuste de coordenadas que no comparte la geometría de los conectores. Mantén el espaciado general del diagrama fuera de BranchDiagram, como se muestra.
Si una tarjeta aparece inesperadamente en el origen del diseño, comprueba que su vista final tenga el valor DiagramCenterKey. El valor predeterminado de la clave es .zero; por tanto, si no se asigna, el centro de la tarjeta queda en el origen. Si el programa se detiene en la precondición del identificador, asigna un identificador distinto a cada nodo antes de mostrarlo.
Las dimensiones fijas de las tarjetas son adecuadas para las etiquetas sintéticas breves de este ejemplo. Para texto más largo o fuentes más grandes, añade una fase de medición: solicita sus tamaños a las vistas proxy de las subvistas, calcula los anchos de columna y el espaciado de los subárboles a partir de esas medidas y usa esos mismos resultados para los extremos de los conectores. Una propuesta de tamaño por sí sola no obliga a una subvista a aceptar ese tamaño. place(at:anchor:proposal:)
Este algoritmo describe un árbol: cada hijo mostrado pertenece a un solo padre. No traza enlaces cruzados arbitrarios ni representa un nodo compartido por varios padres. Esos requisitos necesitan otro modelo geométrico y una política de trazado explícita. Del mismo modo, las animaciones requerirían que la geometría de los conectores se moviera al compás de las posiciones de las tarjetas; este ejemplo ofrece una disposición estática.
Deja que un único cálculo geométrico se encargue del tamaño del contenido, los centros de las tarjetas y los extremos de las aristas. Así tendrás un lugar concreto donde ajustar el espaciado, inspeccionar las coordenadas y ampliar el diagrama, manteniendo la coherencia entre el diseño y el dibujo de SwiftUI.
Fuentes
- 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