Posts

Márgenes del sistema en SwiftUI con ContentMarginGuide

Arturo Rivas Arias

Añadir .padding(.horizontal, 16) parece una forma razonable de separar el contenido de los bordes. Hasta que abrimos la misma pantalla en otro dispositivo y el texto deja de alinearse con el título de navegación.

El problema está en asumir que el margen del sistema es una constante. Apple utiliza valores que dependen del contenedor y de la posición de la vista. En el iPhone Duo, además, ambos lados pueden necesitar márgenes distintos.

Desde iOS 27.1, SwiftUI permite expresar esa intención directamente con ContentMarginGuide: queremos seguir los márgenes del sistema sin escribir sus valores a mano.

Dejar que SwiftUI resuelva el margen

La guía .container representa los márgenes del contenedor donde aparece la vista. Para aplicarla utilizamos contentMargins(for:edges:alignment:).

Por ejemplo, una pantalla con el resumen de un entrenamiento:

import SwiftUI

@available(iOS 27.1, *)
struct WorkoutSummary: View {
    var body: some View {
        NavigationStack {
            ScrollView {
                VStack(alignment: .leading, spacing: 12) {
                    Text("Carrera suave")
                        .font(.title2.bold())

                    Label("6,4 km", systemImage: "figure.run")
                    Text("Un ritmo cómodo para recuperar piernas.")
                }
                .frame(maxWidth: .infinity, alignment: .leading)
                .contentMargins(for: .container, edges: .horizontal)
            }
            .navigationTitle("Entrenamiento")
        }
    }
}

Aplicamos el modificador al contenido del ScrollView. Así, la vista desplazable conserva su extensión y el resumen respeta los márgenes laterales del contenedor.

edges vale .all por defecto. Especificar .horizontal evita introducir también los márgenes superior e inferior. El parámetro alignment permite colocar vistas que no ocupan todo el espacio propuesto dentro de los límites delimitados por la guía.

Mismo nombre, dos APIs diferentes

Aquí hay una confusión fácil: SwiftUI ya tenía un modificador llamado contentMargins desde iOS 17.

// Elegimos una distancia para el contenido desplazable.
.contentMargins(.horizontal, 24, for: .scrollContent)

// Solicitamos los márgenes que determine el sistema.
.contentMargins(for: .container, edges: .horizontal)

El primero configura márgenes mediante un ContentMarginPlacement, como .scrollContent o .scrollIndicators. El segundo utiliza una ContentMarginGuide para resolver el espaciado según el contexto.

.container y .scrollContent pertenecen a tipos distintos. No son opciones intercambiables de una misma enumeración.

Leer los valores para un carrusel

A veces necesitamos obtener los márgenes como datos. Por ejemplo, para que una fila horizontal de planes de entrenamiento comience alineada con el resto de la pantalla, manteniendo el desplazamiento hasta los bordes.

GeometryProxy.contentMargins(for:edges:) devuelve un EdgeInsets que podemos entregar al modificador de iOS 17:

@available(iOS 27.1, *)
struct TrainingPlans: View {
    private let plans = ["Recuperación", "Resistencia", "Velocidad"]

    var body: some View {
        GeometryReader { proxy in
            ScrollView(.horizontal) {
                LazyHStack(spacing: 12) {
                    ForEach(plans, id: \.self) { plan in
                        Text(plan)
                            .font(.headline)
                            .frame(width: 180, height: 110)
                            .background(
                                .teal.opacity(0.15),
                                in: RoundedRectangle(cornerRadius: 16)
                            )
                    }
                }
            }
            .contentMargins(
                .horizontal,
                proxy.contentMargins(for: .container, edges: .horizontal),
                for: .scrollContent
            )
        }
        .frame(height: 110)
    }
}

La guía proporciona las distancias y .scrollContent indica dónde aplicarlas. Los indicadores mantienen su configuración independiente. Los bordes que no solicitamos al GeometryProxy se devuelven con valor cero.

Importa dónde medimos

Los valores de la guía son locales: representan el margen pendiente desde la posición del lector. Si un contenedor ya ha aplicado parte del espacio, el margen que queda puede ser menor o directamente cero. Conviene hacer la medición cerca de la vista que consumirá el resultado.

Tampoco debemos equiparar estos valores directamente con UIView.layoutMargins: los márgenes leídos en SwiftUI no incluyen el área segura. En un ScrollView, el margen del contenido se combina con el tratamiento que la vista ya hace de esa área. Sumarla de nuevo puede duplicar el espacio.

Mantener compatibilidad

Para un proyecto iOS que también soporte versiones anteriores, podemos encapsular la decisión:

struct SystemHorizontalMargins: ViewModifier {
    func body(content: Content) -> some View {
        if #available(iOS 27.1, *) {
            content.contentMargins(for: .container, edges: .horizontal)
        } else {
            content.padding(.horizontal)
        }
    }
}

La alternativa con padding conserva una separación convencional, aunque no reproduce todas las adaptaciones de la nueva guía. Además, necesitamos compilar con un SDK que conozca estas APIs: #available controla su ejecución, pero no incorpora símbolos a un SDK antiguo.

Cuando el objetivo es seguir la alineación del sistema, ContentMarginGuide permite describir esa relación directamente. Los valores fijos siguen teniendo sentido para decisiones propias del diseño, como el espacio interior de una tarjeta.