Accesibilidad: la letra grande
Al terminar: Que la app aguante la letra grande en las dos plataformas.
Esta lección no se puede hacer leyendo código. Lo que se rompe en la Pokédex con la letra grande no está en ningún fichero: se ve poniendo el tamaño de letra al máximo y mirando la pantalla. Así que cada paso empieza rompiendo la app a propósito y termina arreglándola.
Al terminar, las tarjetas de la lista se leerán enteras con la letra al máximo, en Android y en iOS. Es la primera de dos lecciones: aquí, la letra grande; en la siguiente, Accesibilidad: el lector de pantalla, lo que oye quien usa TalkBack (Android) o VoiceOver (iOS), los programas que leen la pantalla en voz alta. No se instala nada y no cambia ningún número de tests: siguen en 127 / 89 / 92 en el núcleo, 13 en androidApp y 4 en la interfaz de iOS.
Paso 1 · Dos órdenes para romper la app
Con la app ya instalada y abierta en el emulador y en el simulador (como en Ejecutar en Android y Ejecutar en iOS), abre una Terminal (la de Android Studio sirve) y prueba cada una. Son reversibles y no hace falta compilar nada distinto. Si adb no se reconoce, falta el paso del PATH de Instalar Android Studio y el SDK; mientras tanto escribe la ruta completa (en un Mac, ~/Library/Android/sdk/platform-tools/adb) donde pone adb.
# Android: la letra del sistema al 180 %
adb shell settings put system font_scale 1.8
# Para dejarlo como estaba:
adb shell settings put system font_scale 1.0
# iOS: el mayor tamaño de letra de accesibilidad
xcrun simctl ui booted content_size accessibility-extra-extra-extra-large
# Para dejarlo como estaba:
xcrun simctl ui booted content_size largeCómo se lee cada orden: adb shell settings put system font_scale 1.8 le dice al móvil, a través de adb (el puente entre tu ordenador y el emulador), que guarde el ajuste font_scale («escala de letra») con el valor 1,8. xcrun simctl ui booted content_size … hace lo mismo con el simulador de iOS: xcrun simctl es la herramienta de Xcode que maneja los simuladores, booted significa «el que tengas encendido ahora» y content_size es el tamaño de letra. Las dos cosas necesitan un Mac, que ya es el que usas para iOS.
Deja las dos plataformas con la letra grande mientras haces los pasos 2 y 3, y vuelve a ponerlas en normal al terminar. Usa el Pixel que creaste al instalar Android Studio: en un móvil más pequeño (360 dp de ancho) la rejilla ya es de una sola columna y no se rompe nada, así que no verías el defecto.
Paso 2 · Android: la palabra partida por la mitad
Provócalo. Con font_scale 1.8 mira la lista. En la tarjeta de Bulbasaur, la etiqueta «Veneno» se parte en tres trozos («Ve», «ne», «no») dentro de una cápsula estrechísima, y el número de la tarjeta roza la ilustración (este último defecto es menor y aquí no se corrige; en iOS sí se le pone un tope).
Por qué. Las etiquetas de tipo están en un Row, que reparte el ancho que hay entre sus hijos y nunca baja de línea: con la letra grande no caben, y cada una se estrecha hasta partir la palabra. El contenedor que sí baja de línea cuando no queda sitio es FlowRow.
Abre androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/list/PokemonCard.kt. Primero, el import, junto a los demás de androidx.compose.foundation.layout:
import androidx.compose.foundation.layout.FlowRowDespués busca el Row que pinta las etiquetas (el que contiene pokemon.types.forEach { tipo -> TypeChip(…) }) y cámbialo:
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
pokemon.types.forEach { tipo -> TypeChip(tipo.etiqueta, tipo.color) }
} // `FlowRow` baja a la línea siguiente lo que no cabe; `Row` no.
// `verticalArrangement` es el hueco entre línea y línea.
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
pokemon.types.forEach { tipo -> TypeChip(tipo.etiqueta, tipo.color) }
}Además de FlowRow, la etiqueta necesita una red de seguridad por si una sola palabra no cabe ni sola en su línea. Está en TypeChip, la función de más abajo en el mismo fichero: el Text de la etiqueta, que hoy se ve así:
Text(
text = texto,
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)Añade al final de sus parámetros, tras color = …:
// Si una etiqueta de una palabra no cabe ni sola en su línea, se
// recorta con puntos suspensivos, que se entiende. Partida, no.
maxLines = 1,
overflow = TextOverflow.Ellipsis,(TextOverflow ya está importado en ese fichero: lo usa el nombre.) Vuelve a lanzar la app (Run) con la letra al 180 %: «Planta» y «Veneno» salen enteras, una debajo de otra, cada una en su cápsula.
Lo demás aguantó, y conviene saber por qué: la barra de navegación, el título grande y el buscador son componentes de Material 3, que ya traen resuelto el tamaño de letra. Se rompió lo que escribimos nosotros.
Paso 3 · iOS: la rejilla que se vuelve ilegible
Provócalo. Con accessibility-extra-extra-extra-large la tarjeta es inservible: el nombre queda en «Bul…», las etiquetas se parten en trozos («Pl / a / nt / a», «Ve / ne / no») y el número «N.º 0001» se escapa a una segunda línea que tapa la ilustración y el corazón.
Tres arreglos, todos con el vocabulario de SwiftUI. Los tres necesitan saber qué tamaño de letra ha elegido la persona. En iOS ese ajuste se llama Dynamic Type («tipografía dinámica»): el usuario elige en Ajustes cuánto crece el texto de todas las apps, y las apps bien hechas lo respetan. SwiftUI da el tamaño elegido en el entorno.
Una sola columna
En iosApp/iosApp/List/PokemonListView.swift sustituye la propiedad columnas (una línea, private let columnas = [GridItem(.adaptive(minimum: 164), spacing: 12)]) por esto:
Hasta ahora columnas era una constante: un valor fijo, escrito una vez. Ahora será una propiedad calculada: en vez de guardar un valor, lleva entre llaves un trozo de código que se ejecuta cada vez que alguien la lee y devuelve el valor que toque en ese momento. Así, si el usuario cambia el tamaño de letra, la rejilla se recalcula.
// El tamaño de letra de la persona, siempre actualizado.
@Environment(\.dynamicTypeSize) private var tamano
/// Con la letra de accesibilidad, una sola columna. Con dos, cada tarjeta
/// tiene unos 164 puntos de ancho para una palabra a cuerpo gigante y no
/// hay forma de que quepa: se renuncia a la retícula antes que a leer.
/// `.flexible()` es una columna que se estira hasta ocupar todo el ancho
/// (una sola columna); `.adaptive(minimum: 164)` mete tantas columnas como
/// quepan con 164 puntos como mínimo cada una.
private var columnas: [GridItem] {
tamano.isAccessibilitySize
? [GridItem(.flexible(), spacing: 12)]
: [GridItem(.adaptive(minimum: 164), spacing: 12)]
}Un tope donde el texto flota sobre una imagen, y dos líneas antes que recortar
Ahora iosApp/iosApp/List/PokemonCardView.swift. Primero, la lectura del tamaño, justo debajo de private var tinte:
private var tinte: Color { pokemon.types.first?.tinte ?? .accentColor }
@Environment(\.dynamicTypeSize) private var tamanoEl número es una etiqueta encima de la ilustración: si crece, la tapa. Se le pone un tope añadiendo un modificador al Text(String(format: "N.º %04d", …)), tras .foregroundStyle(.secondary):
Text(String(format: "N.º %04d", pokemon.id))
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
// Tope visual: el texto crece como mucho hasta
// `xxLarge`. VoiceOver lo lee igual de bien.
// `...DynamicTypeSize.xxLarge` es un rango «hasta»:
// desde el tamaño más pequeño hasta `xxLarge`, que
// es mayor que el normal pero menor que los de
// accesibilidad.
.dynamicTypeSize(...DynamicTypeSize.xxLarge)Después, el bloque del nombre y las etiquetas (el VStack que sigue al .aspectRatio(1.15, …)) pasa de esto a esto:
Text(pokemon.name.capitalized)
.font(.system(.headline, design: .rounded, weight: .bold))
.lineLimit(1)
HStack(spacing: 6) {
ForEach(pokemon.types, id: \.name) { tipo in
TypeChip(tipo: tipo)
}
} Text(pokemon.name.capitalized)
.font(.system(.headline, design: .rounded, weight: .bold))
// Dos líneas antes que recortar: «Bul…» no le dice nada a
// nadie y un nombre en dos líneas se lee perfectamente.
.lineLimit(2)
// Si aun así no cabe, SwiftUI puede reducir la letra hasta
// el 80 % (`0.8`) antes de recortar.
.minimumScaleFactor(0.8)
// Con letra de accesibilidad las etiquetas van una debajo de
// otra; con la normal, en fila.
if tamano.isAccessibilitySize {
VStack(alignment: .leading, spacing: 6) {
ForEach(pokemon.types, id: \.name) { tipo in
TypeChip(tipo: tipo)
}
}
} else {
HStack(spacing: 6) {
ForEach(pokemon.types, id: \.name) { tipo in
TypeChip(tipo: tipo)
}
}
}Y en TypeChip, más abajo en el mismo fichero, el texto de la etiqueta no debe estrecharse nunca:
Text(tipo.etiqueta)
.font(.caption2.weight(.medium))
.foregroundStyle(.secondary)
// Una línea, y que pida el ancho que necesita: la cápsula se
// adapta a la palabra, no al revés.
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)Compila con Cmd + R y mira con el tamaño de accesibilidad: una columna, el nombre entero, las etiquetas en vertical y el número sin tapar nada. Pon content_size large otra vez y comprueba que con la letra normal la rejilla sigue siendo de dos columnas.
Al terminar, deja las dos plataformas como estaban (font_scale 1.0 y content_size large).
Punto de control
- Con
font_scale 1.8, ninguna etiqueta de tipo aparece partida: «Planta» y «Veneno» salen enteras, una bajo otra. - Con el tamaño de accesibilidad de iOS, la rejilla pasa a una columna, el nombre se lee completo y el número no tapa la ilustración.
- Con la letra normal, la rejilla de iOS sigue siendo de dos columnas.
- Los dos dispositivos vuelven a su tamaño normal al terminar.
- Los tests no han cambiado: las tres tareas sin filtro (
./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test) siguen en 127 en la JVM, 89 en Android host y 92 en iOS,./gradlew :androidApp:testDebugUnitTestcuenta 13 tests, y en Xcode (Cmd + U) salen 4 tests de interfaz sin fallos.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.Con la letra al 180 % en Android, «Veneno» se partía en «Ve / ne / no». ¿Por qué?
2.Con el mayor tamaño de accesibilidad en iOS, la rejilla pasa a una columna. ¿Por qué es la solución correcta?
3.Poner
.dynamicTypeSize(...DynamicTypeSize.xxLarge)al número de la tarjeta, ¿qué limita exactamente?4.Pones
font_scale 1.8en un móvil pequeño de 360 dp de ancho y la lista no se rompe. ¿Por qué?5.En iOS, el texto de
TypeChiplleva.lineLimit(1)y.fixedSize(horizontal: true, vertical: false). ¿Qué consiguen juntos?