Esquinas concéntricas en SwiftUI: diferencias entre ConcentricRectangle y ContainerRelativeShape
Arturo Rivas Arias
Las esquinas redondeadas de una interfaz suelen parecer un detalle menor hasta que aparecen varias capas anidadas. Una tarjeta con radio de 28 puntos y un panel interior con radio de 20 pueden quedar razonablemente bien, pero esos valores no garantizan que ambas curvas mantengan la misma separación. Cuando cambia el padding, el tamaño del componente o la geometría del dispositivo, el desajuste se vuelve visible.
SwiftUI tiene dos formas que evitan calcular esos radios a mano: ContainerRelativeShape y, desde los SDK de la generación 26, ConcentricRectangle. Las dos se basan en la forma que contiene la vista que lo aplica, pero no expresan exactamente la misma intención. La primera obtiene una versión con márgen de la forma completa; la segunda trabaja con un rectángulo cuyas esquinas pueden resolverse individualmente respecto a las esquinas del contenedor.
El modelo mental: curvas que comparten centro
Dos esquinas son concéntricas cuando sus arcos comparten el mismo centro. Si el borde interior se separa algunos puntos (píxeles si queremos) del exterior por ambos ejes, su radio debe reducirse en consecuencia. No basta con escoger dos radios que «parezcan proporcionados»: la distancia entre las curvas debe mantenerse durante todo el arco.
Este modelo explica por qué codificar radios independientes resulta frágil:
VStack(alignment: .leading, spacing: 12) {
Text("Calidad del aire")
.font(.headline)
Text("Buena · 34 AQI")
.foregroundStyle(.secondary)
}
.padding(16)
.background(.mint.opacity(0.25), in: .rect(cornerRadius: 20))
.padding(8)
.background(.mint.opacity(0.12), in: .rect(cornerRadius: 28))
La relación 28 - 8 = 20 funciona para ese espaciado uniforme, pero queda incorporada al código como un número mágico que nunca cambia. Si el espacio cambia solo en un eje, si el contenedor procede del sistema o si el componente se desplaza dentro de él, mantener esa relación deja de ser trivial.
El mofidicador containerShape(_:) establece la referencia geométrica que pueden consultar las vistas descendientes. No recorta ni dibuja por sí solo el contenido: simplemente comunica cuál es la silueta del contenedor. Si se necesita recorte visual, hay que aplicarlo además con clipShape(_:) o dibujar un fondo adaptado a esa misma forma.
Qué hace ContainerRelativeShape
ContainerRelativeShape se sustituye por una versión espaciada de la forma contenedora actual. Si SwiftUI no encuentra ninguna, utiliza un rectángulo. Su forma abreviada es .containerRelative.
struct StorageSummary: View {
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Label("Archivo sincronizado", systemImage: "checkmark.icloud")
.font(.headline)
Text("Última actualización: ahora")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.blue.opacity(0.16), in: .containerRelative)
.padding(10)
.background(.blue.opacity(0.08))
.containerShape(.rect(cornerRadius: 30))
.clipShape(.rect(cornerRadius: 30))
}
}
La forma interior conserva la silueta de la forma exterior. Ese comportamiento resulta especialmente útil cuando una capa debe seguir la silueta completa de una tarjeta, un widget u otro contenedor proporcionado por el sistema. El componente no necesita conocer el radio ni imitar un cálculo que pertenece a su antecesor.
La posición también importa. ContainerRelativeShape deriva la geometría a partir del contenedor y del espaciado efectivo de la vista. Por eso no equivale a restar una constante al radio exterior: el resultado responde a la colocación del perfil en los ejes horizontal y vertical.
Qué aporta ConcentricRectangle
ConcentricRectangle representa siempre una forma rectangular de cuatro esquinas. Cuando el contenedor implementa RoundedRectangularShape, SwiftUI puede calcular qué radio corresponde a cada esquina para que sea concéntrica con su esquina adyacente. Entre los contenedores compatibles están Rectangle, RoundedRectangle, UnevenRoundedRectangle, Capsule y Circle.
Su inicializador vacío configura las cuatro esquinas como concéntricas de forma individual:
ConcentricRectangle()
.fill(.indigo.opacity(0.2))
«Individual» es la palabra decisiva. Una esquina situada cerca de la correspondiente curva exterior puede quedar redondeada, mientras que otra suficientemente alejada puede resolver un radio igual a cero y aparecer cuadrada. No es un error de renderizado: ya no existe una curva exterior próxima con la que mantener la concentricidad.
Este comportamiento hace que ConcentricRectangle sea más preciso que una forma relativa cuando el diseño necesita decidir esquina por esquina. También permite asignar a cada Edge.Corner.Style uno de estos criterios:
.concentric, para calcular el radio respecto al contenedor..concentric(minimum:), para mantener la concentricidad cuando sea posible sin bajar de un radio mínimo..fixed(_:), para utilizar un radio concreto; cero produce una esquina cuadrada.
Por ejemplo, un panel inferior puede conservar radios superiores estables y adaptar los inferiores a las curvas del dispositivo o del contenedor:
struct PlaybackPanel: View {
var body: some View {
HStack(spacing: 14) {
Image(systemName: "waveform")
.font(.title2)
VStack(alignment: .leading) {
Text("Paisaje sonoro")
.font(.headline)
Text("Reproduciendo")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button("Pausar", systemImage: "pause.fill") { }
.labelStyle(.iconOnly)
}
.padding(20)
.background(
.indigo.opacity(0.2),
in: ConcentricRectangle(
uniformTopCorners: .fixed(22),
uniformBottomCorners: .concentric
)
)
.padding(8)
}
}
Los inicializadores con parámetros uniform resuelven primero las esquinas afectadas y después aplican a ambas el mayor de los radios calculados. Así se conserva la simetría visual aunque la situación geométrica de cada esquina no sea idéntica. También existen variantes para agrupar las esquinas superiores, inferiores, de entrada o de salida.
Individual y uniforme no son sinónimos
Una confusión frecuente consiste en esperar que ConcentricRectangle() redondee siempre las cuatro esquinas por igual. Su objetivo predeterminado es respetar cada relación geométrica por separado. Si el diseño exige un único tratamiento para las cuatro, se puede indicar explícitamente:
ConcentricRectangle(corners: .concentric, isUniform: true)
.fill(.orange.opacity(0.25))
Con isUniform: false, cada esquina mantiene su resultado. Con isUniform: true, la forma adopta un radio común calculado a partir de esos resultados. Esta segunda opción ofrece una tarjeta simétrica que sigue respondiendo al contenedor; la primera es apropiada para paneles pegados a un borde o diseños que mezclan esquinas cuadradas y curvas.
También conviene introducir un mínimo cuando el lenguaje visual exige que una esquina nunca llegue a ser cuadrada:
.background(
.teal.opacity(0.18),
in: .rect(corners: .concentric(minimum: 12), isUniform: true)
)
El mínimo es una decisión de diseño, no una forma de hacer más exacta la geometría. Cuando el radio concéntrico calculado sería menor, SwiftUI prioriza el límite indicado y la curva ya no puede mantener una concentricidad perfecta.
Qué ocurre con un contenedor incompatible
El cálculo por esquinas requiere que la forma exterior describa una geometría rectangular redondeada mediante RoundedRectangularShape. Si se proporciona una forma arbitraria que no cumple ese protocolo, ConcentricRectangle no puede deducir radios para las cuatro esquinas. En ese caso adopta el comportamiento de ContainerRelativeShape: utiliza una versión con espacios respecto a la forma contenedora.
Este fallback evita que la vista deje de producir una forma, pero no convierte una silueta libre en un rectángulo de radios calculables. Cuando la distinción sea importante, el contenedor personalizado debería exponer una forma compatible o el componente debería elegir deliberadamente .containerRelative.
Disponibilidad y compatibilidad
ContainerRelativeShape existe desde versiones anteriores de SwiftUI. ConcentricRectangle y sus estilos de esquina concéntricos llegaron con los SDK de iOS 26, iPadOS 26, macOS 26, tvOS 26, watchOS 26 y visionOS 26. Un proyecto cuya versión mínima soportada sea anterior necesita proteger su uso con la comprobación de disponibilidad y mantener una alternativa.
@ViewBuilder
private var adaptiveBackground: some View {
if #available(iOS 26.0, macOS 26.0, *) {
Color.cyan.opacity(0.18)
.clipShape(
.rect(corners: .concentric(minimum: 14), isUniform: true)
)
} else {
Color.cyan.opacity(0.18)
.clipShape(.containerRelative)
}
}
La alternativa no reproduce necesariamente el control por esquina, pero conserva la intención principal de adaptarse al contenedor. Si el diseño requiere una apariencia idéntica en sistemas antiguos, será necesario emplear un RoundedRectangle con un radio conocido o implementar una forma propia que realice los cálculos.
Cómo elegir la forma adecuada
✅ ContainerRelativeShape es la elección natural cuando una capa interior debe ser una copia con espaciado de la silueta del contenedor y no necesita calcular el radio de esquinas concretas. Encaja bien en fondos de widgets, tarjetas anidadas y componentes que deben heredar una forma decidida de antemano por su contenedor.
ConcentricRectangle resulta más expresivo cuando el resultado debe seguir siendo rectangular y el diseño necesita controlar cada esquina: combinar radios fijos y adaptativos, conservar simetría por pares, imponer un mínimo o responder a las curvas del dispositivo físico. En ambos casos, es preferible declarar la relación mediante containerShape(_:) que repetir radios mágicos en distintos niveles de la jerarquía.
La diferencia esencial no está en que una API sea más moderna que la otra, sino en la geometría que cada una promete. ContainerRelativeShape sigue la forma exterior como un todo; ConcentricRectangle interpreta sus cuatro esquinas y permite decidir cómo debe resolverse cada una. Elegir desde ese modelo mental produce componentes adaptativos, coherentes y menos dependientes de cálculos codificados a mano.