wandres.dev
ACCESIBILIDAD · apps para todos

Dynamic Type: texto que crece sin romper el layout

Dynamic Type no es una preferencia estética: es una escala de doce categorías cuyo extremo triplica el tamaño del cuerpo de texto y convierte cualquier altura fija en un recorte. Esta lección define la escala, distingue lo que escala solo de lo que hay que escalar a mano con `ScaledMetric`, y desarrolla las técnicas de layout adaptativo que permiten que una pantalla siga siendo utilizable en el tamaño más grande sin mantener dos diseños.

⏱ 17 min

Cuando alguien sube el tamaño del texto en los ajustes del sistema no está expresando una preferencia estética: está declarando el único tamaño con el que puede leer. Y sin embargo, el fallo de accesibilidad más frecuente en el ecosistema de Apple no es una etiqueta ausente, es una pantalla que se desmorona en cuanto el cuerpo de texto pasa de diecisiete a cincuenta y tres puntos. La causa es casi siempre la misma y es conceptual antes que técnica: se diseñó como si el tamaño del texto fuera una constante conocida en tiempo de diseño, cuando en realidad es una variable de entorno controlada por la persona usuaria, con un rango de más del trescientos por ciento. Un layout que asume constancia donde hay variabilidad no está mal ajustado: está mal formulado.

🎯 Al terminar esta lección sabrás
  • Enumerar las doce categorías de tamaño y calcular el factor real entre el valor por defecto y el mayor.
  • Distinguir qué escala automáticamente y qué exige ScaledMetric o una fuente relativa.
  • Aplicar layouts adaptativos que cambien de estructura en los tamaños de accesibilidad.
  • Incorporar la comprobación en el tamaño máximo al flujo diario de trabajo y a las previsualizaciones.

La escala real

El sistema define doce categorías. Siete pertenecen al rango estándar, desde extra pequeña hasta triple extra grande, con la categoría grande como valor por defecto; las cinco restantes forman el rango de accesibilidad y se numeran de uno a cinco. La diferencia entre ambos rangos no es de grado sino de naturaleza: los tamaños estándar producen variaciones que un diseño razonable absorbe sin cambios estructurales, mientras que los de accesibilidad rompen deliberadamente esa suposición. En la categoría por defecto, el estilo de cuerpo mide diecisiete puntos; en la quinta categoría de accesibilidad ronda los cincuenta y tres. El factor supera el tres, y ese número es el que conviene tener en la cabeza al mirar cualquier maqueta.

En SwiftUI ese estado vive en el entorno bajo dynamicTypeSize, es un tipo comparable y expone la propiedad isAccessibilitySize, que es el punto de corte natural para decidir un cambio de estructura. La disponibilidad del valor en el entorno tiene una consecuencia práctica importante: la adaptación al tamaño no es un truco de layout ocurrido en tiempo de dibujo, es una decisión que puedes tomar declarativamente en el mismo lugar donde compones la vista.

@Environment(\.dynamicTypeSize) private var tamano

var body: some View {
    if tamano.isAccessibilitySize {
        VStack(alignment: .leading) { contenido }
    } else {
        HStack { contenido }
    }
}

Conviene desmontar de paso un malentendido extendido: limitar el rango con dynamicTypeSize(...DynamicTypeSize.accessibility1) no es una solución, es una amputación. Existen casos legítimos y estrechos —una etiqueta dentro de una gráfica cuyo espacio es geométricamente fijo, un número en una insignia— pero aplicarlo al texto de lectura equivale a decidir por la persona que no necesita ver. Si una pantalla solo funciona con un tope, el problema está en la pantalla.

Lo que escala solo y lo que no

Los estilos de texto semánticos —cuerpo, titular, título, pie, subtítulo— escalan sin que hagas nada, porque son curvas de tamaño definidas por el sistema para cada categoría, no números. Los tamaños fijos declarados con un valor de puntos no escalan jamás. Y entre ambos extremos está el caso que más gente resuelve mal: la fuente personalizada de marca, que sí puede escalar si se declara relativa a un estilo semántico y se convierte en un tamaño congelado si se declara suelta.

Text("Cuerpo").font(.body)                                  // escala
Text("Marca").font(.custom("Inter", size: 17, relativeTo: .body))  // escala
Text("Roto").font(.system(size: 17))                        // no escala nunca

Lo que no es texto tampoco escala por su cuenta, y ahí entra ScaledMetric. Un icono de veinticuatro puntos junto a un texto de cincuenta y tres se ve ridículo y deja de cumplir su función; un espaciado de ocho puntos entre líneas que ahora miden sesenta produce un bloque ilegible; un objetivo táctil dimensionado a mano deja de guardar proporción con lo que lo rodea. ScaledMetric toma un valor base y lo multiplica por el mismo factor que aplica la categoría activa, opcionalmente relativo a un estilo concreto.

@ScaledMetric(relativeTo: .body) private var iconoLado: CGFloat = 24
@ScaledMetric(relativeTo: .body) private var separacion: CGFloat = 8

HStack(spacing: separacion) {
    Image(systemName: "bell")
        .frame(width: iconoLado, height: iconoLado)
    Text("Notificaciones")
}

Los símbolos del sistema son un caso feliz aparte: si se declaran dentro de un Label o se les asigna un estilo de texto, acompañan al texto de forma automática y conservan la alineación óptica. Esa es una razón sólida para preferirlos a mapas de bits siempre que sea posible, junto con el hecho de que soportan variantes de peso que responden al ajuste de texto en negrita del sistema mediante legibilityWeight.

⚠️
Las tres construcciones que garantizan un recorte

Primero, la altura fija: cualquier frame con altura constante que contenga texto acabará cortando ese texto. Segundo, el número de líneas limitado sin necesidad: lineLimit(1) en un nombre o una descripción convierte el crecimiento en truncamiento y la información desaparece sin aviso. Tercero, el factor de escala mínimo usado como red de seguridad: reducir la fuente para que quepa deshace exactamente lo que la persona pidió al sistema. Si algo debe caber, que crezca en vertical.

Layouts que aguantan

La regla estructural es que el eje de crecimiento debe estar libre. Una fila que en tamaños normales alinea etiqueta e icono horizontalmente debe convertirse en columna cuando el texto ocupa el ancho entero; una cuadrícula de tres columnas debe pasar a una; una barra de herramientas con cinco iconos y sus rótulos debe reorganizarse o mover los rótulos a un menú. SwiftUI ofrece tres herramientas de distinto nivel de abstracción para expresar esa transición sin duplicar la vista.

🔀

ViewThatFits

Declara varias alternativas por orden de preferencia y el sistema elige la primera que quepa. Ideal cuando la decisión depende del espacio disponible y no de una regla explícita.

🧱

AnyLayout

Intercambia el algoritmo de disposición conservando la identidad de las vistas hijas, de modo que la transición entre fila y columna puede animarse en lugar de saltar.

📐

Grid y fixedSize

Grid mantiene alineación entre filas sin anchos codificados, y fixedSize en el eje vertical impide que el texto se comprima cuando el contenedor aprieta.

let disposicion = tamano.isAccessibilitySize
    ? AnyLayout(VStackLayout(alignment: .leading))
    : AnyLayout(HStackLayout(alignment: .firstTextBaseline))

disposicion {
    Text(titulo).fixedSize(horizontal: false, vertical: true)
    Spacer(minLength: 0)
    Text(valor)
}

Hay un detalle de composición que casi nadie aplica y que resuelve una familia entera de defectos: fixedSize(horizontal: false, vertical: true) le dice al texto que puede ocupar el alto que necesite aunque el padre proponga menos. Sin esa indicación, un contenedor con restricciones ajustadas comprime el texto y provoca el truncamiento silencioso; con ella, el texto empuja y el problema se traslada al layout, donde es visible y arreglable. Prefiere siempre un defecto ruidoso a uno silencioso.

flowchart TB
a[La persona cambia el tamano en Ajustes] --> b[dynamicTypeSize en el entorno]
b --> c[Estilos semanticos recalculan su altura]
b --> d[ScaledMetric escala iconos y separaciones]
c --> e{isAccessibilitySize}
d --> e
e -->|no| f[Misma estructura con mas alto]
e -->|si| g[Cambio de eje o de columnas]
f --> h[Scroll vertical siempre disponible]
g --> h

Queda una salvaguarda global que conviene adoptar como norma: cualquier pantalla que muestre texto debe poder desplazarse verticalmente. No porque se espere que lo haga en el tamaño por defecto, sino porque en la quinta categoría de accesibilidad lo hará con certeza. Una vista sin desplazamiento es una apuesta a que el contenido siempre cabrá, y esa apuesta se pierde siempre.

Probarlo el primer día

La diferencia entre los equipos que sufren con Dynamic Type y los que no es de calendario. Si la comprobación llega al final, cada arreglo es un rediseño; si el diseño se valida desde el principio en el tamaño más grande, el resultado converge sin dolor porque las decisiones estructurales se toman con la restricción puesta. La técnica concreta es invertir el orden habitual: maquetar primero en la quinta categoría de accesibilidad y comprobar después que también se ve bien en la predeterminada, y no al revés.

#Preview("AX5") {
    FichaPersona(persona: .ejemplo)
        .dynamicTypeSize(.accessibility5)
}

#Preview("Rango completo") {
    ForEach(DynamicTypeSize.allCases, id: \.self) { t in
        FichaPersona(persona: .ejemplo).dynamicTypeSize(t)
    }
}
💡
Tres vías para cambiar el tamaño sin salir del trabajo

Las previsualizaciones con variantes te dan el ciclo más corto y deberían acompañar a toda vista con texto. El simulador permite alterar el ajuste en caliente desde las opciones de accesibilidad, útil para recorrer flujos completos. Y el Accessibility Inspector incorpora un deslizador que aplica el cambio sobre la app en ejecución en el dispositivo real, que es donde aparecen los problemas de fuentes personalizadas y de símbolos.

El tamaño del texto es una entrada del programa, no una constante del diseño

Todo el sufrimiento con Dynamic Type procede de un error de categoría que se comete en la fase de diseño y se hereda en el código: tratar el tamaño de la tipografía como parte de la especificación en lugar de como parte del estado. Una maqueta que dice que la tarjeta mide ochenta y ocho puntos de alto no está describiendo la interfaz, está describiendo una de sus infinitas instancias, exactamente igual que una captura de pantalla con datos de ejemplo no describe una lista. Cuando ese malentendido se traslada al código en forma de alturas fijas y saltos de línea limitados, el programa deja de ser una función del estado para convertirse en una fotografía con lógica pegada; y como toda fotografía, solo es correcta bajo las condiciones exactas en que se tomó. La formulación rigurosa es la contraria y es sorprendentemente liberadora: el layout es una función que recibe, entre otras entradas, la categoría de tamaño, y su trabajo es producir una disposición válida para cada valor del dominio, no para el que tenía el diseñador en su portátil. Formulado así, el tamaño máximo deja de ser un caso extremo molesto y se convierte en lo que realmente es: la prueba que demuestra que la función está bien definida en todo su dominio, el equivalente en interfaz de comprobar los límites de un algoritmo. Un equipo que interioriza esto no añade soporte de Dynamic Type a una app: escribe layouts que jamás asumieron un tamaño, y descubre como efecto colateral que esos mismos layouts ya funcionaban en iPad, en ventanas redimensionables de Mac, en pantalla dividida y en cualquier idioma cuyas palabras midan el doble que en inglés.

📝
Lo esencial

La escala tiene doce categorías y su extremo triplica el cuerpo de texto. Escalan los estilos semánticos y las fuentes declaradas relativas a uno de ellos; no escala nada declarado con puntos fijos, ni los iconos, ni los espaciados, para los que existe ScaledMetric. Evita alturas fijas, límites de línea innecesarios y factores de escala mínimos. Cambia de estructura cuando isAccessibilitySize sea cierto, deja siempre desplazamiento vertical y valida en el tamaño mayor desde el primer día en lugar del último.

⚔️ Diseñar desde el extremo
  1. Elige la pantalla más densa de tu app, fija la previsualización en la quinta categoría de accesibilidad y anota cada recorte, solapamiento y truncamiento antes de tocar nada.
  2. Sustituye toda fuente con tamaño en puntos por su estilo semántico o por una fuente personalizada relativa, y comprueba que el recuento de defectos baja sin cambiar el layout.
  3. Convierte los iconos y espaciados que hayas dimensionado a mano en propiedades con ScaledMetric y observa cómo se recupera la proporción visual.
  4. Implementa la transición de fila a columna con AnyLayout sobre la condición isAccessibilitySize y anímala para verificar que la identidad de las vistas se conserva.
  5. Añade a tu plantilla de vistas nuevas dos previsualizaciones fijas, la predeterminada y la máxima, y comprométete a maquetar primero la segunda durante una semana.