ContentBuilder en SwiftUI: un único nombre para construir distintos tipos de contenido
Arturo Rivas Arias
SwiftUI lleva años apoyándose en una idea muy potente: describir interfaces mediante pequeños bloques declarativos. Escribimos VStack, List, ToolbarItem, CommandMenu o Tab, y dentro de sus closures vamos declarando el contenido que queremos mostrar. Esa sintaxis parece natural, pero detrás hay una pieza del lenguaje Swift que hace mucho trabajo por nosotros: los result builders.
Durante mucho tiempo, el nombre más visible dentro de SwiftUI ha sido @ViewBuilder. Lo hemos usado para crear vistas compuestas, propiedades calculadas, inicializadores de componentes reutilizables y bloques de código que devuelven some View. Sin embargo, SwiftUI ya no se limita a construir simples vistas. También construye barras de herramientas, comandos de menú, pestañas, escenas, animaciones y otros tipos de contenido declarativo.
Ahí aparece ContentBuilder, una nueva forma de nombrar algo que ya estaba ocurriendo: SwiftUI necesita un lenguaje común para decir “este bloque construye contenido”, sin que el atributo tenga que revelar siempre el tipo concreto de contenido que se está generando.
Qué es realmente ContentBuilder
ContentBuilder no es un nuevo motor mágico de SwiftUI ni una alternativa independiente a ViewBuilder. Apple lo documenta como un alias de ViewBuilder, por lo que su declaración conceptual puede entenderse así:
typealias ContentBuilder = ViewBuilder
La parte importante no está tanto en la implementación como en el nombre. ViewBuilder arrastra una asociación mental muy fuerte con View, y eso tiene sentido cuando el resultado del bloque es some View. Pero esa asociación empieza a quedarse corta cuando SwiftUI utiliza el mismo estilo declarativo para construir otro tipo de contenido.
Un closure puede construir vistas, sí, pero también puede construir elementos de una toolbar, comandos de una app de macOS o contenido especializado de una API de SwiftUI. Usar ContentBuilder permite expresar esa intención de forma más amplia: no estamos diciendo necesariamente “construye una vista”, sino “construye contenido SwiftUI válido para este contexto”.
Por qué el nombre importa
En SwiftUI, los nombres importan mucho porque casi toda la API se lee como una descripción de interfaz. Cuando vemos esto:
init(@ViewBuilder content: () -> Content)
entendemos rápidamente que content representa una jerarquía de vistas. Es una firma perfecta para un contenedor visual, por ejemplo una tarjeta, una sección o un layout personalizado.
Pero en APIs más generales, ese nombre puede resultar menos preciso. Imaginemos una abstracción que no quiere exponer si por dentro recibirá vistas, elementos de una toolbar u otro contenido de SwiftUI. En ese caso, una firma como esta se lee de forma más natural:
init(@ContentBuilder content: () -> Content)
El cambio parece pequeño, pero tiene una consecuencia interesante: el atributo deja de ser el lugar donde explicamos todo. La responsabilidad de indicar qué se puede devolver queda en el tipo de retorno del bloque o en el genérico del componente.
Es decir, el builder dice cómo se agrupan varias expresiones. El tipo esperado dice qué expresiones son válidas.
Un ejemplo sencillo con vistas
El caso más básico sigue siendo el de siempre: construir una vista compuesta sin tener que envolver manualmente cada pieza en un contenedor explícito.
import SwiftUI
struct StatusPanel<Content: View>: View {
let title: String
private let content: Content
init(
_ title: String,
@ContentBuilder content: () -> Content
) {
self.title = title
self.content = content()
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(title)
.font(.headline)
content
}
.padding()
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16))
}
}
El uso es exactamente el que esperaríamos en SwiftUI:
struct ServerOverviewView: View {
var body: some View {
StatusPanel("Estado del sistema") {
Label("API disponible", systemImage: "checkmark.circle")
Label("Última sincronización: 08:42", systemImage: "clock")
Label("3 tareas pendientes", systemImage: "tray")
}
}
}
En este ejemplo, ContentBuilder funciona igual que ViewBuilder. El resultado debe ser una View, porque el genérico Content está limitado por Content: View. El alias no hace que el bloque acepte cualquier cosa; simplemente nos da un nombre más general para el mecanismo de construcción.
ContentBuilder no elimina la seguridad de tipos
Uno de los malentendidos más fáciles es pensar que ContentBuilder convierte cualquier contenido SwiftUI en algo intercambiable. No es así.
Un ToolbarItem no pasa a ser una View por estar dentro de un closure marcado con @ContentBuilder. Un comando de menú no puede colocarse en una VStack. Una pestaña no se convierte en contenido de toolbar. Swift sigue necesitando que el resultado final encaje con el tipo esperado por la API.
Por ejemplo, este contenedor espera vistas:
struct InlinePanel<Content: View>: View {
private let content: Content
init(@ContentBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
HStack(spacing: 8) {
content
}
}
}
Dentro de su bloque podemos devolver Text, Image, Button o cualquier composición válida de View:
InlinePanel {
Image(systemName: "externaldrive.connected.to.line.below")
Text("Backup completado")
Button("Ver detalles") {
// Abrir panel de actividad
}
}
Pero no tendría sentido intentar devolver contenido de toolbar ahí:
// ❌ No encaja: ToolbarItem no es una View normal
InlinePanel {
ToolbarItem {
Button("Exportar") { }
}
}
El atributo @ContentBuilder solo participa en la transformación del bloque. La validación final sigue dependiendo del tipo esperado. Esa separación es precisamente lo que hace que el nuevo nombre sea útil sin volver la API más laxa.
Result builders: la pieza de Swift que lo permite
Para entender mejor el cambio, conviene recordar qué hacen los result builders. Swift Evolution formalizó esta característica en SE-0289, después de haberla utilizado en SwiftUI bajo el nombre inicial de function builders. La idea es permitir que un bloque de código produzca un valor compuesto a partir de varias expresiones escritas de forma secuencial.
Cuando escribimos algo como esto:
VStack {
Text("Facturación")
Text("12.450 €")
Text("+8% respecto al mes anterior")
}
no estamos devolviendo tres valores independientes en un closure normal. El builder transforma esas expresiones en una estructura que SwiftUI puede entender como contenido de la VStack.
Conceptualmente, el compilador puede convertir ese bloque en llamadas a métodos como buildBlock, buildEither, buildOptional o buildArray, dependiendo de si hay varias expresiones, condicionales, opcionales o bucles.
Por eso podemos escribir código declarativo con condiciones:
struct BillingSummary: View {
let isOverdue: Bool
var body: some View {
VStack(alignment: .leading) {
Text("Próxima factura")
.font(.headline)
Text("24,99 €")
.font(.title.bold())
if isOverdue {
Label("Pago pendiente", systemImage: "exclamationmark.triangle")
.foregroundStyle(.orange)
}
}
}
}
Sin result builders, ese tipo de composición tendría que expresarse con arrays, estructuras intermedias, condicionales fuera del árbol o mucho código auxiliar. SwiftUI perdería gran parte de su legibilidad.
La diferencia entre el builder y el resultado
Una forma práctica de pensar en ContentBuilder es separar dos conceptos.
- El builder define cómo se interpreta el cuerpo del bloque.
- El tipo de retorno define qué puede salir de ese bloque.
Por ejemplo:
@ContentBuilder
func makeHeader() -> some View {
Text("Panel de control")
.font(.largeTitle.bold())
Text("Resumen de actividad")
.foregroundStyle(.secondary)
}
Aquí el builder permite escribir dos expresiones Text seguidas. Pero el resultado sigue siendo some View, así que Swift espera contenido de vista.
En cambio, en un contexto de toolbar, el resultado esperado sería otro:
@ContentBuilder
var documentToolbar: some ToolbarContent {
ToolbarItem(placement: .primaryAction) {
Button("Compartir", systemImage: "square.and.arrow.up") {
// Compartir documento
}
}
ToolbarItem(placement: .secondaryAction) {
Button("Duplicar", systemImage: "doc.on.doc") {
// Duplicar documento
}
}
}
La sintaxis del atributo es la misma, pero el tipo de contenido no. La API no se vuelve ambigua porque some ToolbarContent marca el terreno de juego.
Este detalle es importante para diseñar APIs propias. Si el componente que estamos creando solo acepta vistas, limitar el genérico a View sigue siendo correcto. Si estamos exponiendo una API más conceptual, ContentBuilder puede ayudar a que la firma suene menos acoplada al término View.
Cuándo usar ContentBuilder en código propio
Para componentes visuales clásicos, @ViewBuilder sigue siendo perfectamente válido. De hecho, en mucho código existente puede ser incluso más expresivo porque deja claro que el bloque construye vistas.
struct EmptyState<Actions: View>: View {
let message: String
let actions: Actions
init(
message: String,
@ViewBuilder actions: () -> Actions
) {
self.message = message
self.actions = actions()
}
var body: some View {
VStack(spacing: 16) {
Text(message)
.foregroundStyle(.secondary)
actions
}
.padding()
}
}
No hay nada malo en esta versión. El closure actions devuelve vistas, así que ViewBuilder describe bien la intención.
Ahora bien, si estamos creando APIs que quieren alinearse con la sintaxis más moderno de SwiftUI, o si el concepto de “contenido” es más relevante que el de “vista”, ContentBuilder encaja mejor:
struct SettingsGroup<Content: View>: View {
let title: LocalizedStringKey
private let content: Content
init(
_ title: LocalizedStringKey,
@ContentBuilder content: () -> Content
) {
self.title = title
self.content = content()
}
var body: some View {
Section {
content
} header: {
Text(title)
}
}
}
Y el uso queda limpio:
struct PrivacySettingsView: View {
@State private var analyticsEnabled = false
@State private var crashReportsEnabled = true
var body: some View {
Form {
SettingsGroup("Privacidad") {
Toggle("Enviar analíticas", isOn: $analyticsEnabled)
Toggle("Enviar informes de fallos", isOn: $crashReportsEnabled)
}
}
}
}
El ejemplo sigue devolviendo vistas, pero la API se expresa en términos de contenido de una sección. Es un matiz de diseño, no una obligación técnica.
Migrar o no migrar código existente
No parece conveniente comenzar una migración masiva para reemplazar todos los @ViewBuilder por @ContentBuilder. Si el código actual es claro, compila correctamente y el bloque construye vistas, @ViewBuilder sigue siendo una opción válida y reconocible para cualquier desarrollador SwiftUI.
Donde sí tiene sentido adoptar ContentBuilder es en código nuevo, especialmente en inicializadores y helpers que conceptualmente reciben “contenido” en sentido amplio. También puede ser interesante en librerías internas donde quieras una sintaxis más estable de cara al futuro de SwiftUI.
Una regla sencilla sería esta:
// Bloque claramente orientado a vistas
@ViewBuilder content: () -> Content
// Bloque expresado como contenido SwiftUI de forma más general
@ContentBuilder content: () -> Content
La diferencia no está en que una versión sea más potente que la otra, sino en lo que comunica el contrato de la API.
Qué no soluciona ContentBuilder
ContentBuilder tampoco debe interpretarse como una solución a todos los problemas de inferencia de tipos en SwiftUI. Los errores clásicos de “the compiler is unable to type-check this expression in reasonable time” pueden seguir apareciendo cuando una vista mezcla demasiados genéricos, condicionales complejos, overloads y modificadores encadenados.
En esos casos, las soluciones siguen siendo las de siempre: extraer subviews, dividir expresiones grandes, añadir tipos explícitos cuando ayuden al compilador y evitar que una sola propiedad body concentre demasiada lógica.
Por ejemplo, esta separación sigue siendo una buena práctica:
struct MonitoringDashboard: View {
let incidents: [Incident]
var body: some View {
ScrollView {
VStack(spacing: 20) {
summary
incidentList
}
.padding()
}
}
@ContentBuilder
private var summary: some View {
Text("Monitorización")
.font(.largeTitle.bold())
Text("Servicios activos: 18")
.foregroundStyle(.secondary)
}
private var incidentList: some View {
ForEach(incidents) { incident in
Text(incident.title)
}
}
}
Aquí ContentBuilder mejora la lectura de summary, pero la mejora real para el compilador y para el mantenimiento viene de haber dividido la pantalla en componentes más pequeños.
Una limpieza pequeña con implicaciones grandes
ContentBuilder es una API discreta, casi invisible si solo miramos su implementación. Pero apunta a una dirección importante en SwiftUI: el framework cada vez construye más tipos de contenido declarativo y no todos encajan cómodamente bajo el nombre ViewBuilder.
El cambio ayuda a separar mejor las responsabilidades. El builder se encarga de convertir un bloque declarativo en un resultado compuesto. El tipo esperado mantiene la seguridad y decide si ese resultado es una vista, contenido de toolbar, comandos u otra pieza de SwiftUI.
Para quienes escribimos SwiftUI a diario, la conclusión práctica es sencilla: @ViewBuilder sigue siendo correcto cuando hablamos claramente de vistas, pero @ContentBuilder ofrece un nombre más amplio y alineado con la evolución del framework. No rompe el modelo mental anterior; lo ordena un poco mejor.
Y en un framework donde gran parte de la claridad depende de leer bien las firmas, un nombre más preciso también es una mejora de diseño.