Composición en SwiftUI: cómo construir vistas pequeñas sin perder identidad ni rendimiento
Arturo Rivas Arias
🧩 La composición es una de las ideas que mejor explica por qué SwiftUI permite construir interfaces complejas con relativamente poco código. Una pantalla no se trata como un bloque indivisible, sino como una jerarquía de piezas pequeñas que se combinan entre sí. Un texto, un botón, una fila de una tabla y una pantalla completa… participan en el sistema a través de la misma abstracción: todos conforman el protocolo View.
Los elementos simples actúan como piezas finales de la interfaz, mientras que contenedores como VStack, HStack, ZStack, List o NavigationStack reúnen otros elementos. Para SwiftUI no hace falta una API distinta para trabajar con un text o con una tabla completa: ambas son vistas y pueden recibir modificadores, formar parte de otra composición y producir contenido desde su propiedad body.
Una vista no almacena una pantalla terminada
🌳 Es habitual imaginar que una vista SwiftUI es un objeto visual equivalente a UIView o NSView, pero el modelo es diferente. Una estructura que conforma View es una descripción ligera de una parte de la interfaz. Su propiedad body devuelve otra vista y esa descomposición continúa hasta llegar a elementos que el framework puede representar.
struct HomeStatusView: View {
let temperature: Measurement<UnitTemperature>
let lightsOn: Int
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Estado de la vivienda")
.font(.title2.bold())
HStack {
Label(
temperature.formatted(),
systemImage: "thermometer.medium"
)
Spacer()
Label("\(lightsOn) encendidas", systemImage: "lightbulb.fill")
}
.foregroundStyle(.secondary)
}
.padding()
}
}
El árbol conceptual comienza en HomeStatusView, continúa por un VStack y se divide en un Text y un HStack, que a su vez contiene dos Label y un Spacer. Cada nodo conserva información sobre su tipo y su posición dentro de la estructura. SwiftUI utiliza esa información para entender qué parte de la interfaz representa cada valor cuando vuelve a evaluar la vista.
Qué hacen some View y @ViewBuilder
⚙️ La propiedad body no devuelve cualquier tipo que conforme View. La expresión some View indica que devuelve un tipo concreto y estable, aunque su nombre quede oculto. Esto es importante porque una composición aparentemente sencilla puede generar un tipo genérico demasiado largo como para escribirlo o mantenerlo a mano.
@ViewBuilder es el result builder que permite declarar varios elementos dentro de un bloque de contenido. El compilador transforma esa sintaxis declarativa en un único resultado compuesto. Por eso es posible definir varios componentes dentro de un VStack sin crear un array, borrar sus tipos con any View ni escribir un return para cada elemento.
struct StatusPanel<Content: View>: View {
let title: String
@ViewBuilder let content: Content
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(title)
.font(.headline)
content
}
.padding()
.background(.quaternary, in: .rect(cornerRadius: 16))
}
}
Este contenedor puede recibir contenido heterogéneo manteniendo su información de tipos:
StatusPanel(title: "Seguridad") {
Label("Puerta principal cerrada", systemImage: "lock.fill")
Label("Alarma activada", systemImage: "shield.checkered")
if hasOpenWindow {
Label("Hay una ventana abierta", systemImage: "exclamationmark.triangle")
.foregroundStyle(.orange)
}
}
La documentación de ViewBuilder de Apple describe precisamente esta función: construir contenido desde bloques capaces de producir uno o varios elementos e incorporar ramas condicionales. El if no se resuelve almacenando vistas sin tipo en una colección; el builder representa las distintas posibilidades dentro del tipo compuesto.
Componer no significa extraer cada línea
🧱 Dividir una pantalla mejora la lectura cuando cada componente expresa una responsabilidad acotada y reconocible. Una tarjeta de consumo energético, una fila de dispositivo o un control de climatización son buenos candidatos porque tienen entradas claras, pueden probarse de manera aislada y quizá se reutilicen en otros contextos.
struct DeviceRow: View {
let name: String
let symbol: String
let isOnline: Bool
var body: some View {
HStack(spacing: 12) {
Image(systemName: symbol)
.frame(width: 28)
.foregroundStyle(isOnline ? .green : .secondary)
Text(name)
Spacer()
Text(isOnline ? "Disponible" : "Sin conexión")
.font(.caption)
.foregroundStyle(.secondary)
}
.accessibilityElement(children: .combine)
}
}
La vista padre solo necesita organizar esas piezas:
struct DevicesView: View {
let devices: [SmartDevice]
var body: some View {
List(devices) { device in
DeviceRow(
name: device.name,
symbol: device.symbol,
isOnline: device.isOnline
)
}
.navigationTitle("Dispositivos")
}
}
No obstante, crear una estructura independiente para cada Text o cada pareja de modificadores añade nombres y saltos entre archivos sin aportar una frontera conceptual. Una buena extracción de componentes reduce la carga mental: si obliga a recorrer más código para comprender una sección trivial, probablemente la composición se ha fragmentado demasiado.
La composición también controla las dependencias
🚀 Extraer subviews no es únicamente una decisión estética. Apple explica en Demystify SwiftUI performance que SwiftUI actualiza las vistas a partir de sus dependencias y recomienda reducir los valores que recibe cada componente a los datos que realmente utiliza.
Pasar un modelo enorme a todas las piezas de una pantalla amplía el número de dependencias. Aunque una fila solo muestre el nombre y la conectividad de un dispositivo, cualquier cambio en el modelo completo puede producir un nuevo valor para esa fila. Una interfaz compuesta con entradas pequeñas hace explícito qué puede afectar a cada sección y permite al framework limitar mejor el trabajo de actualización.
// Dependencias demasiado amplias para lo que la vista representa
struct DeviceRow: View {
let dashboard: HomeDashboard
let device: SmartDevice
var body: some View {
Text(device.name)
}
}
// La vista recibe únicamente los valores que necesita
struct DeviceName: View {
let name: String
var body: some View {
Text(name)
}
}
Esto no significa que haya que convertir cada propiedad en un parámetro independiente. El objetivo es escoger un contrato (o interfaz) coherente. Si una fila utiliza casi todo el valor SmartDevice, recibir ese modelo es razonable; si solo necesita un texto, pasar la casa completa no lo es.
Identidad estructural: la forma del árbol importa
🪪 La sesión de Apple Demystify SwiftUI distingue entre identidad explícita e identidad estructural. La primera aparece, por ejemplo, al proporcionar identificadores estables a un ForEach. La segunda procede del tipo y la posición que ocupa una vista dentro de la jerarquía.
Esta relación explica por qué dos fragmentos visualmente idénticos pueden no representar la misma vista. En el siguiente ejemplo, cada rama condicional tiene una identidad estructural distinta:
if isEcoModeEnabled {
ClimateBadge(title: "Modo eficiente")
.foregroundStyle(.green)
} else {
ClimateBadge(title: "Modo normal")
.foregroundStyle(.primary)
}
Al cambiar la condición, SwiftUI pasa de una rama a otra. Si ambas expresiones representan realmente dos estados del mismo elemento, resulta más preciso conservar una única vista y variar sus propiedades mediante modificadores:
ClimateBadge(title: isEcoModeEnabled ? "Modo eficiente" : "Modo normal")
.foregroundStyle(isEcoModeEnabled ? .green : .primary)
Mantener la identidad puede conservar el estado asociado, producir animaciones más naturales y evitar trabajo innecesario. Las ramas siguen siendo correctas cuando representan interfaces conceptualmente diferentes, como una pantalla de configuración frente a una pantalla de error. La regla útil no consiste en eliminar todos los if, sino en distinguir entre dos vistas diferentes y dos estados de la misma vista.
Group organiza contenido, pero no crea diseño
📦 Group reúne varios elementos en una sola unidad lógica y permite aplicarles modificadores comunes, pero no introduce por sí mismo un eje, espaciado o alineación. Apple lo define como una agrupación que no afecta al diseño del contenido del modo en que lo harían HStack, VStack o Section.
Group {
Text("Consumo actual: 2,4 kW")
Text("Estimación mensual: 61 €")
}
.font(.callout)
.foregroundStyle(.secondary)
Por tanto, Group no debe utilizarse como sustituto automático de un contenedor visual. Es adecuado para compartir modificadores o envolver contenido condicional. Si los hijos necesitan colocarse verticalmente, participar en un diseño adaptativo o compartir espaciado, hace falta un contenedor que exprese esa intención.
AnyView no es la herramienta habitual para componer
🎭 El borrado de tipos con AnyView puede resultar tentador cuando distintas ramas producen tipos diferentes. Sin embargo, @ViewBuilder, los genéricos y some View ya resuelven la mayoría de esas situaciones conservando la información estática que SwiftUI utiliza para entender la jerarquía.
@ViewBuilder
private var connectionStatus: some View {
if isConnected {
Label("Conectado", systemImage: "wifi")
.foregroundStyle(.green)
} else {
Label("Sin conexión", systemImage: "wifi.slash")
.foregroundStyle(.red)
}
}
AnyView tiene casos legítimos, sobre todo cuando una API necesita almacenar valores heterogéneos tras una frontera dinámica, pero usarlo de forma preventiva oculta tipos y puede dificultar los cálculos que SwiftUI realiza sobre la identidad y las actualizaciones. En una jerarquía declarada de forma normal, preservar los tipos suele ofrecer código más claro y mejores oportunidades de optimización.
Una guía práctica para decidir dónde dividir
✅ Una composición saludable suele cumplir varias condiciones: la propiedad body se puede leer como un esquema de la pantalla, cada subview tiene un propósito que puede acotarse, el estado vive cerca del componente que lo posee y las dependencias fluyen mediante entradas pequeñas y explícitas. Las piezas reutilizables no tienen por qué ser genéricas; reutilización y claridad son beneficios posibles, no requisitos obligatorios.
También conviene mantener body libre de efectos secundarios y cálculos costosos. Componer vistas no sustituye la separación de la lógica de negocio: cargar datos, ordenar grandes colecciones o transformar modelos complejos debe realizarse fuera de la fase de construcción de la jerarquía. La vista describe el resultado y delega las acciones en métodos o modelos adecuados.
🔍 Cuando una pantalla crece, el criterio más útil es observar sus límites conceptuales. Si una sección puede explicarse con un nombre, tiene datos propios y puede evolucionar sin obligar a comprender toda la pantalla, ya existe una buena frontera para convertirla en componente. SwiftUI se apoya precisamente en esa forma de trabajar: piezas con tipos concretos, identidades estables y dependencias acotadas que se combinan hasta formar la interfaz completa.