La organización del contenido visual en aplicaciones como Unsplash o Pinterest exige un diseño adaptable y atractivo que permita ver imágenes en distintos tamaños y distribuciones. Aquí entra en juego el Masonry Layout, una estructura de diseño que permite organizar elementos de forma dinámica y aprovechar mejor el espacio en pantalla. Inspirado en un tutorial de Hacking With Swift, exploraremos cómo construir este tipo de layout en SwiftUI, utilizando la API de Unsplash.
Este artículo tiene como objetivo entender la importancia de un layout personalizado en SwiftUI y cómo aprovechar sus características para construir un diseño adaptable. También exploraremos cómo optimizar el uso de imágenes con la API de Unsplash, cargándolas en un grid flexible.
El Poder de los Layouts en SwiftUI
Un layout adecuado mejora la experiencia de usuario, permitiendo una visualización organizada del contenido sin importar el tamaño o la resolución de la pantalla. SwiftUI, con su flexibilidad y adaptabilidad, permite construir layouts multiplataforma, facilitando la organización y presentación del contenido.
Un layout como el que vamos a construir se adapta dinámicamente y distribuye los elementos en columnas, logrando una apariencia similar al diseño que Unsplash y Pinterest usan en sus plataformas. Esta estructura optimiza el uso del espacio y hace que la visualización sea coherente y atractiva.
iOS
Mac
¿Qué Vamos a Construir?
Este proyecto se centra en cuatro pasos principales:
- Crear el modelo de datos para las imágenes de Unsplash.
- Configurar la API de Unsplash para obtener las imágenes.
- Desarrollar el layout personalizado
UnsplashLayout
que organiza las imágenes en columnas. - Integrar el layout en
ContentView
, permitiendo personalizar columnas, orientación y espaciado.
Paso 1: Creación del Modelo de Datos
Nuestro primer paso es crear un modelo de datos llamado UnsplashImage
, que representará cada imagen recibida desde la API de Unsplash. Este modelo tiene dos propiedades clave:
id
: el identificador único de la imagen.urls
: una estructura interna que almacena la URL de la imagen en resolución regular.
Este modelo simplifica la deserialización de los datos recibidos y permite que las imágenes se representen correctamente en SwiftUI.
struct UnsplashImage: Codable, Identifiable {
let id: String
let urls: URLS
struct URLS: Codable {
let regular: String
}
}
Paso 2: Configuración de la API de Unsplash
La API de Unsplash nos permite acceder a imágenes de alta calidad. Antes de continuar, asegúrate de obtener una clave de acceso desde el sitio para desarrolladores de Unsplash.
Para gestionar las solicitudes a la API, crearemos la clase UnsplashAPI
. Esta clase se encarga de:
- Configurar la solicitud HTTP con nuestra clave de acceso.
- Realizar la solicitud para obtener un conjunto de imágenes.
- Decodificar los datos de respuesta y pasar las imágenes a nuestra vista a través de un bloque de finalización (
completion
).
¿Por qué usar una clase para la API? Esto nos permite encapsular la lógica de red y mantener la estructura del proyecto modular, facilitando futuras ampliaciones.
class UnsplashAPI {
private let accessKey = "YOUR_ACCESS_KEY" // Sustituye por tu clave de acceso
func fetchImages(completion: @escaping ([UnsplashImage]?) -> Void) {
guard let url = URL(string: "https://api.unsplash.com/photos/random?client_id=\(accessKey)&count=30") else {
completion(nil)
return
}
URLSession.shared.dataTask(with: url) { data, response, error in
guard let data = data, error == nil else {
completion(nil)
return
}
let images = try? JSONDecoder().decode([UnsplashImage].self, from: data)
completion(images)
}.resume()
}
}
Paso 3: Creación de UnsplashLayout para un Diseño Dinámico
La clave de este proyecto es el layout personalizado UnsplashLayout
, que organiza las imágenes en un mosaico fluido. A diferencia de un layout estático, este diseño ajusta automáticamente las imágenes a la columna con menor altura, creando una distribución visual uniforme.
Propiedades Principales del Layout
- Número de Columnas (
columns
): Define cuántas columnas tendrá el layout. - Espaciado (
spacing
): Determina la separación entre las imágenes. - Orientación (
axis
): Configura si el layout será horizontal o vertical.
Este layout sigue el mismo principio que un CollectionView
en UIKit, pero con la ventaja de la adaptabilidad multiplataforma de SwiftUI. El método placeSubviews
organiza cada subvista en su columna y calcula la posición para garantizar la alineación adecuada.
El posicionamiento de las vistas, aunque parezca mucho código, es sencillo. Itera por todas las subvistas, y va revisando donde va la siguiente, dependiendo del espaciado y de la columna o fila en la que está. Así se puede posicionar relativamente a la vista anterior. La potencia del Layout, es que ante cualquier cambio que no sea de su fuente de datos, no se repinta y no tiene que volver a calcularse. Podemos incluso mejorar esto haciendo uso de la caché (los métodos de layout utilizan la cache), afrontaremos esa explicación en otro artículo. Pero básicamente sería una estructura donde almacenar los viewFrames
para no tener que realizar el cálculo cada vez, si no leerlos de esa estructura.
Control de la Orientación del Layout
Dentro del UnsplashLayout
, hemos añadido una propiedad llamada axis
que permite cambiar la orientación del layout de vertical a horizontal. Esto permite flexibilidad para crear un layout en forma de grid vertical u horizontal, ideal para adaptar el diseño según la orientación de la pantalla o el tipo de dispositivo.
struct UnsplashLayout: Layout {
private var layoutProperties: LayoutProperties
private var rowsOrColumns: Int
private var spacing: Double
private let axis: Axis
init (rowsOrColumns: Int = 3, spacing: Double = 10, axis: Axis = .vertical) {
self.rowsOrColumns = rowsOrColumns
self.spacing = spacing
self.axis = axis
self.layoutProperties = .init()
layoutProperties.stackOrientation = axis
}
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize {
let size = proposal.replacingUnspecifiedDimensions()
let viewFrames = frames(for: subviews, in: size)
let width = axis == .vertical ? size.width : (viewFrames.max { $0.maxX < $1.maxX } ?? .zero).maxX
let height = axis == .horizontal ? size.height : (viewFrames.max { $0.maxY < $1.maxY } ?? .zero).maxY
return CGSize(width: width, height: height)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) {
let viewFrames = frames(for: subviews, in: bounds.size)
for index in subviews.indices {
let frame = viewFrames[index]
let position = CGPoint(x: bounds.minX + frame.minX, y: bounds.minY + frame.minY)
subviews[index].place(at: position, proposal: ProposedViewSize(frame.size))
}
}
func frames(for subviews: Subviews, in size: CGSize) -> [CGRect] {
if axis == .vertical {
let totalSpacing = spacing * Double(rowsOrColumns - 1)
let columnWidth = (size.width - totalSpacing) / Double(rowsOrColumns)
let columnWidthWithSpacing = columnWidth + spacing
let proposedSize = ProposedViewSize(width: columnWidth, height: nil)
var viewFrames = [CGRect]()
var columnHeights = Array(repeating: 0.0, count: rowsOrColumns)
for subview in subviews {
var selectedColumn = 0
var selectedHeight = Double.greatestFiniteMagnitude
for (columnIndex, height) in columnHeights.enumerated() {
if height < selectedHeight {
selectedColumn = columnIndex
selectedHeight = height
}
}
let x = Double(selectedColumn) * columnWidthWithSpacing
let y = columnHeights[selectedColumn]
let size = subview.sizeThatFits(proposedSize)
let frame = CGRect(x: x, y: y, width: size.width, height: size.height)
columnHeights[selectedColumn] += size.height + spacing
viewFrames.append(frame)
}
return viewFrames
} else {
let totalSpacing = spacing * Double(rowsOrColumns - 1)
let rowHeight = (size.height - totalSpacing) / Double(rowsOrColumns)
let rowHeightWithSpacing = rowHeight + spacing
let proposedSize = ProposedViewSize(width: nil, height: rowHeight)
var viewFrames = [CGRect]()
var rowWidths = Array(repeating: 0.0, count: rowsOrColumns)
for subview in subviews {
var selectedRow = 0
var selectedWidth = Double.greatestFiniteMagnitude
for (rowIndex, width) in rowWidths.enumerated() {
if width < selectedWidth {
selectedRow = rowIndex
selectedWidth = width
}
}
let y = Double(selectedRow) * rowHeightWithSpacing
let x = rowWidths[selectedRow]
let size = subview.sizeThatFits(proposedSize)
let frame = CGRect(x: x, y: y, width: size.width, height: size.height)
rowWidths[selectedRow] += size.width + spacing
viewFrames.append(frame)
}
return viewFrames
}
}
}
Paso 4: Integración en ContentView
La última parte de nuestro proyecto es integrar UnsplashLayout
en ContentView
. Aquí:
- Creamos un
ScrollView
que permite desplazarse a través de las imágenes. - Configuramos
UnsplashLayout
y definimos parámetros como el número de columnas, espaciado y orientación. - Usamos
AsyncImage
para cargar cada imagen, una herramienta de SwiftUI que facilita el manejo de imágenes de red sin necesidad de gestionar manualmente la descarga y almacenamiento en caché.
¿Por Qué AsyncImage?
AsyncImage
optimiza la carga de imágenes, especialmente en aplicaciones que consumen contenido visual de la web. Esto nos permite evitar retrasos en la carga de imágenes y asegurar que el diseño sea dinámico y fluido.
Por defecto, SwiftUI ofrece AsyncImage, que es sencillo de usar, pero carece de una gestión avanzada de la caché. Con una solución personalizada como LazyAsyncImageView
que utiliza una ImageCache, podemos optimizar la carga de imágenes al reutilizarlas y almacenarlas en caché localmente.
Cada vez que se solicita una imagen de la API de Unsplash, sin una caché adecuada, la aplicación volvería a descargar la imagen cada vez que se vuelve a renderizar, afectando tanto el rendimiento como el consumo de datos. Con una caché personalizada, la imagen se guarda localmente, y las subsecuentes solicitudes pueden obtenerla de la memoria sin requerir otra descarga.
La clase ImageCache es un ObservableObject que contiene un diccionario ([URL: Image])
para almacenar las imágenes descargadas con la URL correspondiente como clave. Al ser un singleton (shared)
, ImageCache permite que todas las instancias de LazyAsyncImageView
usen la misma caché, evitando la duplicación de imágenes en memoria.
Aunque es cierto, que nuestro Layout no está realizando carga Lazy actualmente, podríamos mejorar esta parte. Pero habría que dedicarle un artículo a ello.
struct ContentView: View {
@State private var layouts: [AnyLayout] = [AnyLayout(UnsplashLayout(rowsOrColumns: 3, spacing: 4))
, AnyLayout(UnsplashLayout(rowsOrColumns: 3, spacing: 4, axis: .horizontal))]
@State private var photos: [UnsplashPhoto.Photo] = []
private let api: UnsplashAPI = UnsplashAPI()
@State private var alertMessage: String = ""
@State private var isAlertMessagePresent: Bool = false
var body: some View {
VStack {
ScrollView {
AnyLayout(layouts[0]) {
ForEach(photos, id: \.id) { photo in
LazyAsyncImageView(url: URL(string: photo.urls.small))
.aspectRatio(contentMode: .fill)
}
}
}
.scrollIndicators(.hidden, axes: .vertical)
.border(.indigo, width: 1)
.clipShape(.rect(cornerRadius: 8))
ScrollView(.horizontal) {
AnyLayout(layouts[1]) {
ForEach(photos, id: \.id) { photo in
LazyAsyncImageView(url: URL(string: photo.urls.small))
.aspectRatio(contentMode: .fill)
}
}
}
.scrollIndicators(.hidden, axes: .horizontal)
.border(.indigo, width: 1)
.clipShape(.rect(cornerRadius: 8))
}
.padding()
.task {
do {
photos = try await api.randomPhotos()
} catch {
alertMessage = error.localizedDescription
isAlertMessagePresent = true
}
}
.refreshable {
do {
photos = try await api.randomPhotos()
} catch {
alertMessage = error.localizedDescription
isAlertMessagePresent = true
}
}
.alert(alertMessage,
isPresented: $isAlertMessagePresent) {
Button("Ok") {
isAlertMessagePresent = false
}
}
}
}
struct LazyAsyncImageView: View {
let url: URL?
@StateObject private var cache = ImageCache.shared
var body: some View {
if let url,
let image = cache.image(for: url) {
image
.resizable()
.scaledToFit()
} else {
AsyncImage(url: url) { newPhase in
imageView(from: newPhase)
}
}
}
@ViewBuilder
private func imageView(from phase: AsyncImagePhase) -> some View {
switch phase {
case .empty:
ProgressView()
case .success(let image):
image
.resizable()
.scaledToFit()
.onAppear {
if let url {
cache.cacheImage(image, for: url)
}
}
case .failure(_):
EmptyView()
@unknown default:
EmptyView()
}
}
}
class ImageCache: ObservableObject {
public static let shared = ImageCache()
@Published var cache: [URL: Image] = [:]
func image(for url: URL) -> Image? {
return cache[url]
}
func cacheImage(_ image: Image, for url: URL) {
cache[url] = image
}
}
Conclusión
SwiftUI nos brinda una gran flexibilidad para crear layouts personalizados y adaptativos. Este proyecto muestra cómo implementar un Masonry Layout que ajusta las imágenes de forma fluida, similar al diseño de Unsplash o Pinterest. Además de ofrecer una estructura visual sólida, este tipo de layout optimiza la experiencia de usuario en aplicaciones que requieren mostrar imágenes o contenido visual.
Con un poco de trabajo adicional, podríamos personalizar aún más el layout para adaptarlo a diferentes estilos y resoluciones, incluyendo una variante horizontal que refleje el diseño utilizado en la web de Unsplash para desarrolladores.
Puedes encontrar el código completo en este repositorio