Si queremos crear contenedores personalizados, SwiftUI nos proporciona el protocolo Layout, que nos permite definir el tamaño de un contenedor de vistas y el sitio en donde estará ubicada cada una de ellas.
Por ejemplo, que pasaría si queremos que un grupo de vistas, que por defecto utilizan el tamaño intrínseco del contenido, tengan el mismo ancho. Ancho establecido por la de mayor tamaño de todas ellas.
¿Cómo notificamos ese ancho al contenedor y que éste reparta el espacio disponible? ¿Que pasa si queremos mostrar la vista que mejor se adapte a ese ancho?
A partir de iOS 16 tenemos a nuestra disposición el protocolo Layout y la vista ViewThatFits.
Layout
El protocolo Layout es un tipo que define la geometría de una colección de vistas. En SwiftUI, los contenedores imprescindibles HStack, VStack y Grid se conforman con este protocolo.
Al crear nuestro Stack personalizado conformado con Layout nos obligará a incluir dos funciones: sizeThatFits
y placeSubviews
.
sizeThatFits informa del tamaño de la vista
placeSubviews asigna la posición a cada una de las subvistas.
Para este ejemplo crearemos un struct EqualHStack
del tipo Layout que dará el mismo ancho y alto para cada una de las subvistas, en función a los valores máximos.
Comencemos con sizeThatFits, que devuelve un CGSize, el ancho y alto que ocupará cada vista.
struct EqualHStack: Layout {
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
Almacenamos en una variable los máximos ancho y el alto de las subvistas.
Mapeamos primero todos los tamaños. Después con la función reduce obtener el máximo comparando cada una de ellas, para devolver ese valor.
private func maxSize(of subviews: Subviews) -> CGSize {
let subviewSizes = subviews.map { $0.sizeThatFits(.unspecified) }
let maxSize = subviewSizes.reduce(.zero) { currentMax, subviewSize in
CGSize(
width: max(currentMax.width, subviewSize.width),
height: max(currentMax.height, subviewSize.height))
}
return maxSize
}
También necesitaremos añadir el espacio deseado entre cada subvista, así que creamos un array de espacios para todas menos la última . El spacing será un valor por defecto que puede ser dado al inicializar el struct.
//
var spacing: CGFloat = 8
let maxSize = maxSize(of: subviews)
let spaces = spaces(between: subviews)
//
private func spaces(between subviews: Subviews) -> [CGFloat] {
Array(repeating: spacing, count: max(0, subviews.count - 1))
}
ahora sumamos todos los espacios
let totalSpacing = spaces.reduce(0,+)
para así devolver el tamaño del contenedor, que es el ancho de todas las vistas más el espacio entre cada una de ellas y altura máxima.
return CGSize(
width: maxSize.width * CGFloat(subviews.count) + totalSpacing,
height: maxSize.height
)
Lo siguiente es posicionar las subvistas con placeSubviews
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
Evaluamos que haya subvistas, el tamaño máximo y un tamaño propuesto
guard !subviews.isEmpty else { return }
let maxSize = maxSize(of: subviews)
let sizeProposal = ProposedViewSize(
width: maxSize.width,
height: maxSize.height)
En el eje x iremos añadiendo cada vista con su espacio a la derecha. Partimos del limite mínimo de x y añadimos el ancho entre dos para que el anchor sea el centro.
var x = bounds.minX + maxSize.width / 2
Finalmente a cada vista la ubicaremos en el eje x sumando el ancho y el espacio por cada una de ellas en función a su índice una vez colocada.
for index in subviews.indices {
subviews[index].place(
at: CGPoint(x: x, y: bounds.midY),
anchor: .center,
proposal: sizeProposal)
x += maxSize.width + spacing
}
Ahora podemos utilizar nuestro EqualHStack
y todas las subvistas tendrán el mismo ancho.
EqualHStack(spacing:10) {
Text("Horizontal")
.font(.largeTitle)
//...
¿Y si creamos un EqualVStack
para la versión vertical?
Habrá que cambiar en el sizeThatFits el return para que la altura sea la suma del alto de la subvistas más el espacio entre ellas.
func sizeThatFits(
//...
return CGSize(
width: maxSize.width,
height: maxSize.height
* CGFloat(subviews.count)
+ totalSpacing)
en la función placeSubviews tendremos el valor de y, al que se le irá sumando el valor de altura por cada subsista y su espacio correspondiente.
func placeSubviews(
//..
var y = bounds.minY + maxSize.height / 2
for index in subviews.indices {
subviews[index].place(
at: CGPoint(x: bounds.midX, y: y),
anchor: .center,
proposal: sizeProposal)
y += maxSize.height + spacing
}
ViewThatFits
Este struct mostrará la primera vista que se adapte al tamaño disponible, así que tendríamos que poner en orden descendente las vistas más anchas.
En el caso de que haya algún cambio, por ejemplo el tamaño de la letra por accesibilidad, ViewThatFits se encargaría de poner la primera vista que encaje.
ViewThatFits {
EqualHStack {
ButtonsView()
}
EqualVStack {
ButtonsView()
}
El código completo lo tenéis aquí
struct EqualHStack: Layout {
var spacing: CGFloat = 8
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
let maxSize = maxSize(of: subviews)
let spaces = spaces(between: subviews)
let totalSpacing = spaces.reduce(0) { $0 + $1 }
return CGSize(
width: maxSize.width * CGFloat(subviews.count) + totalSpacing,
height: maxSize.height
)
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()) {
guard !subviews.isEmpty else { return }
let maxSize = maxSize(of: subviews)
let sizeProposal = ProposedViewSize(
width: maxSize.width,
height: maxSize.height)
var x = bounds.minX + maxSize.width / 2
for index in subviews.indices {
subviews[index].place(
at: CGPoint(x: x, y: bounds.midY),
anchor: .center,
proposal: sizeProposal)
x += maxSize.width + spacing
}
}
private func spaces(between subviews: Subviews) -> [CGFloat] {
Array(repeating: spacing, count: max(0, subviews.count - 1))
}
private func maxSize(of subviews: Subviews) -> CGSize {
let subviewSizes = subviews.map { $0.sizeThatFits(.unspecified) }
let maxSize = subviewSizes.reduce(.zero) { currentMax, subviewSize in
CGSize(
width: max(currentMax.width, subviewSize.width),
height: max(currentMax.height, subviewSize.height))
}
return maxSize
}
}
struct EqualVStack: Layout {
var spacing: CGFloat = 8
private func spaces(between subviews: Subviews) -> [CGFloat] {
Array(repeating: spacing, count: max(0, subviews.count - 1))
}
private func maxSize(of subviews: Subviews) -> CGSize {
let subviewSizes = subviews.map { $0.sizeThatFits(.unspecified) }
let maxSize = subviewSizes.reduce(.zero) {
currentMax, subviewSize in CGSize(
width: max(currentMax.width,subviewSize.width),
height: max(currentMax.height, subviewSize.height))
}
return maxSize
}
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
let maxSize = maxSize(of: subviews)
let spacing = spaces(between: subviews)
let totalSpacing = spacing.reduce(0) { $0 + $1 }
return CGSize(
width: maxSize.width,
height: maxSize.height
* CGFloat(subviews.count)
+ totalSpacing)
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
guard !subviews.isEmpty else { return }
let maxSize = maxSize(of: subviews)
let sizeProposal = ProposedViewSize(
width: maxSize.width,
height: maxSize.height)
var y = bounds.minY + maxSize.height / 2
for index in subviews.indices {
subviews[index].place(
at: CGPoint(x: bounds.midX, y: y),
anchor: .center,
proposal: sizeProposal)
y += maxSize.height + spacing
}
}
}
Para más información tenéis el enlace al vídeo de la WWDC 2022 Compose Custom layouts with SwiftUI
¿Quieres recibir posts, cheatCodes, enlaces y katas en Swift para practicar?
Quincenalmente recibirás en tu correo electrónico la newsletter, solo hace falta tu correo electrónico.