VoiceOver: nombrar, describir y ordenar la interfaz
VoiceOver no lee tu pantalla: recorre un árbol de elementos que SwiftUI construye a partir de lo que declaraste. Esta lección desmonta ese árbol nodo a nodo, define con precisión los cuatro atributos que describen a cada elemento —etiqueta, valor, sugerencia y rasgos—, y explica las dos operaciones estructurales que separan una app tolerable de una excelente: agrupar lo que significa una sola cosa y controlar el orden en que se recorre.
La metáfora habitual dice que VoiceOver lee la pantalla. Es una metáfora mala y produce código malo, porque sugiere que existe un proceso que mira los píxeles y los narra. Lo que ocurre es otra cosa: SwiftUI construye, en paralelo a la jerarquía de vistas, un árbol de elementos de accesibilidad en el que cada nodo tiene un marco, una etiqueta, un valor, una sugerencia y un conjunto de rasgos. VoiceOver no ve nada: navega ese árbol con gestos, y lo que anuncia es la lectura de esos campos en un orden fijo. Entender la interfaz auditiva es, por tanto, entender un problema de estructura de datos: qué nodos existen, qué contiene cada uno y en qué secuencia se recorren.
- Describir cómo SwiftUI deriva el árbol de accesibilidad a partir de la jerarquía de vistas.
- Distinguir con precisión etiqueta, valor, sugerencia y rasgos, y saber qué escribir en cada uno.
- Aplicar las tres estrategias de agrupación y decidir cuándo fusionar, contener u ocultar un subárbol.
- Controlar el orden de recorrido, el foco programático y las acciones alternativas del rotor.
El árbol que nadie ve
Cada vista de SwiftUI aporta cero, uno o varios nodos a ese árbol paralelo. Un Text aporta un nodo cuya etiqueta es su contenido; un Button aporta un nodo con el rasgo de botón y la etiqueta que hereda de su rótulo; un Toggle aporta un nodo con rasgo de botón, etiqueta y un valor que alterna entre activado y desactivado. Los contenedores —VStack, HStack, ZStack— no aportan nodo propio por defecto: son transparentes y dejan pasar a sus hijos. Y las vistas puramente decorativas, como un Rectangle o un degradado, tampoco aportan nada, que es justo lo correcto.
Ese comportamiento por defecto es generoso y resuelve la mayor parte del trabajo, pero se rompe en dos direcciones opuestas y ambas son problemas reales. Se rompe por defecto de información cuando dibujas algo que el sistema no puede interpretar: una imagen sin texto, una barra de progreso hecha a mano con dos rectángulos, un control de valoración construido con cinco estrellas táctiles. Y se rompe por exceso de fragmentación cuando lo que para la vista es una tarjeta con seis textos apilados se convierte en seis paradas independientes del cursor, obligando a la persona a reconstruir mentalmente una unidad que visualmente era obvia.
// Lo que el sistema resuelve solo
Button("Guardar") { save() } // etiqueta Guardar, rasgo boton
Toggle("Silencio", isOn: $mute) // etiqueta Silencio, valor activado
// Lo que el sistema no puede adivinar
Image(systemName: "trash") // se anuncia como imagen sin nombre util
.onTapGesture { delete() } // y ni siquiera como boton
El segundo ejemplo merece un comentario más severo de lo que suele recibir. Un onTapGesture sobre una vista cualquiera produce un elemento que responde al tacto pero que carece de rasgo de botón; para VoiceOver no es accionable de forma evidente, para Voice Control no tiene nombre pronunciable y para Full Keyboard Access no es un objetivo de tabulación. La regla operativa es simple: si algo se puede pulsar, se declara como Button, y si su apariencia no encaja se cambia el estilo, no el tipo.
Los cuatro atributos
Un nodo bien formado responde a cuatro preguntas distintas, y la fuente de casi todos los errores es contestar una en el campo de otra. La etiqueta responde a qué es esto: un sustantivo corto, en el idioma de la app, sin mencionar el tipo de control y sin puntuación final. El valor responde a en qué estado está: cambia con el tiempo y solo existe si hay algo variable que comunicar. La sugerencia responde a qué pasará si actúo: una frase con verbo, en tercera persona, que amplía sin ser imprescindible. Y los rasgos responden a de qué naturaleza es: botón, cabecera, seleccionado, enlace, imagen, ajustable, actualización frecuente.
Image(systemName: "heart.fill")
.accessibilityLabel("Favorito") // que es
.accessibilityValue(esFavorito ? "activado" : "desactivado") // como esta
.accessibilityHint("Quita el articulo de la lista de favoritos") // que hara
.accessibilityAddTraits(.isButton) // de que naturaleza es
.accessibilityRemoveTraits(.isImage)
Un caso que conviene tratar aparte, porque se resuelve mal casi siempre, es el de las imágenes. La pregunta correcta no es qué se ve en la imagen sino qué información aporta en este contexto. Una foto de perfil en una fila de chat no necesita descripción, porque el nombre ya identifica a la persona y describirla añade ruido: se oculta. Una gráfica sí necesita una descripción que resuma la tendencia, no una enumeración de puntos. Y una imagen que es el contenido principal de la pantalla merece una descripción cuidada, idealmente redactada por quien produjo el contenido y no generada automáticamente.
Escribir la etiqueta como Botón favorito produce el anuncio redundante boton favorito boton, porque el rasgo ya aporta esa palabra: el campo describe la cosa, no su widget. Meter el estado en la etiqueta —Favorito activado— rompe la caché del lector y desperdicia el rotor de valores. Y usar la sugerencia para información esencial es un error silencioso pero grave: la persona usuaria puede desactivar las sugerencias por completo en los ajustes del sistema, de modo que todo lo que pongas ahí debe ser prescindible por definición.
Los rasgos no son adjetivos decorativos: modifican el comportamiento del lector. isHeader habilita la navegación por cabeceras con el rotor y permite saltar de sección en sección. isSelected alimenta el anuncio de estado en listas y segmentados. updatesFrequently evita que el lector interrumpa a la persona con cada cambio de un cronómetro. isModal hace que el cursor quede confinado dentro de un diálogo y no se escape al fondo, que es la causa número uno de sensación de laberinto en hojas y alertas personalizadas.
Agrupar, contener y ocultar
Aquí se decide la calidad real de la experiencia auditiva. Una tarjeta con avatar, nombre, cargo, insignia y hora es visualmente una unidad; sin intervención, es cinco paradas del cursor. accessibilityElement con children: .combine colapsa el subárbol en un solo nodo cuya etiqueta concatena las de los hijos y que hereda sus rasgos accionables. Con children: .contain se conserva a los hijos como nodos individuales pero se declara un contenedor navegable, que es lo apropiado para una sección larga que conviene poder saltar entera. Y con children: .ignore, el valor por defecto de la llamada sin argumentos, se descarta todo lo que hay dentro y se describe el conjunto a mano.
combine
Una sola parada. Ideal para tarjetas, filas de lista y unidades de significado pequeñas. Concatena las etiquetas hijas en el orden del árbol y conserva la acción principal.
contain
Contenedor navegable con hijos intactos. Para secciones, barras de herramientas y regiones largas que la persona debe poder recorrer o saltar en bloque.
ignore
Silencia el subárbol y te obliga a describirlo tú. Reservado a gráficas, controles dibujados a mano y composiciones visuales cuya suma de partes no significa nada.
VStack(alignment: .leading) {
Text(persona.nombre).font(.headline)
Text(persona.cargo).font(.subheadline)
Text(persona.hora).font(.caption)
Image(systemName: "checkmark.seal.fill")
}
.accessibilityElement(children: .combine)
.accessibilityLabel("\(persona.nombre), \(persona.cargo), verificado")
.accessibilityValue("Ultima conexion \(persona.hora)")
El complemento de agrupar es podar. Todo elemento cuyo contenido ya está dicho por otro nodo debe salir del árbol con accessibilityHidden(true): el icono decorativo que acompaña a un texto, la sombra, el separador, el fondo con textura. La medida de una buena jerarquía auditiva no es cuántos elementos describes, sino cuántos has conseguido eliminar sin perder información. Para los controles dibujados a mano existe además una herramienta más elegante que describirlos campo a campo: accessibilityRepresentation permite decirle al sistema que tu deslizador personalizado se comporte, a efectos de accesibilidad, exactamente como un Slider estándar, heredando de golpe sus rasgos, sus gestos de ajuste y su comportamiento en el rotor.
Orden, foco y acciones
Antes de tocar el orden conviene recordar que la mejor corrección es casi siempre estructural. Si el cursor salta de forma extraña, lo habitual es que la jerarquía esté describiendo mal la pantalla: un ZStack que superpone capas independientes, una fila cuya etiqueta vive lejos de su control, un contenedor decorativo que quedó en medio. Reordenar prioridades sobre una jerarquía incorrecta funciona hasta el siguiente cambio; corregir la jerarquía funciona siempre.
Por defecto el recorrido sigue el orden de lectura del idioma —arriba abajo, izquierda a derecha en español— derivado de la geometría de los marcos. Cuando esa heurística falla, y falla en cuadrículas, superposiciones y diseños con columnas asimétricas, se corrige con accessibilitySortPriority: a mayor número, antes se visita. Es preferible ajustar prioridades a reordenar la jerarquía visual, porque desacopla el orden auditivo del orden de composición.
flowchart TB
a[Jerarquia de vistas de SwiftUI] --> b[Arbol de accesibilidad]
b --> c[Nodo con etiqueta valor sugerencia y rasgos]
c --> d{Agrupacion}
d -->|combine| e[Una parada del cursor]
d -->|contain| f[Contenedor con hijos navegables]
d -->|ignore| g[Descripcion escrita a mano]
e --> h[Orden de recorrido por prioridad]
f --> h
g --> h
h --> i[Sintesis de voz o braille]Las cabeceras merecen una mención propia dentro de esta sección porque son el mecanismo de navegación rápida más infravalorado. Marcar con accessibilityAddTraits el rasgo de cabecera los títulos de sección convierte una pantalla larga, que a ciegas exige decenas de deslizamientos, en un índice recorrible con un gesto. Es un cambio de una línea por sección y multiplica la velocidad de uso; su ausencia es una de las razones por las que muchas apps se perciben como interminables con lector de pantalla aunque todas sus etiquetas sean correctas.
El foco programático se gestiona con AccessibilityFocusState, la contraparte auditiva de FocusState. Tras enviar un formulario con un error, mover el foco al campo culpable evita que la persona tenga que recorrer la pantalla entera buscando qué ha fallado; tras abrir una hoja, colocarlo en el título orienta de inmediato. Para los cambios que no merecen robar el foco existe el anuncio directo mediante AccessibilityNotification, y para los que sí alteran la estructura, la notificación de pantalla o de capa que fuerza al lector a releer la jerarquía.
@AccessibilityFocusState private var foco: Campo?
TextField("Correo", text: $correo)
.accessibilityFocused($foco, equals: .correo)
// Tras validar
if !correoValido { foco = .correo }
// Cambio sin robar el foco
AccessibilityNotification.Announcement("Tres resultados encontrados").post()
Hay una advertencia importante sobre los anuncios: son interrupciones y compiten con lo que el lector esté diciendo en ese momento. Anunciar cada cambio menor produce una experiencia insoportable en la que la persona nunca termina de oír una frase. La regla práctica es anunciar solo lo que un usuario vidente percibiría sin buscarlo —un resultado que aparece, un error que surge, una operación que termina— y dejar el resto al recorrido normal del cursor.
Queda la pieza que más iguala la experiencia entre el tacto y la vista: las acciones alternativas. Un gesto de deslizar sobre una fila no existe para VoiceOver; lo que existe es el rotor de acciones, y todo lo que sea accesible mediante un gesto oculto debe estar también expuesto ahí con accessibilityAction. La regla general es que ninguna función debe depender de un gesto que solo se descubre viendo, y las acciones personalizadas son el mecanismo canónico para cumplirla sin duplicar interfaz.
Cierra los ojos, activa el atajo de accesibilidad con tres pulsaciones del botón lateral e intenta completar la tarea principal de tu app deslizando a derecha e izquierda, sin tocar nada más. No hace falta ser experto: en medio minuto aparecen la etiqueta que dice imagen, la tarjeta que exige cinco deslizamientos y el botón que nadie sabe qué hace. Ese ejercicio detecta más problemas que cualquier informe automático.
Hay una razón por la que escribir una buena etiqueta cuesta tanto y no tiene nada que ver con la accesibilidad: escribir la etiqueta obliga a decidir qué es la cosa. Mientras el control solo se ve, su ambigüedad es sostenible; un icono puede significar tres cosas a la vez y nadie protesta, porque la vista tolera la vaguedad y el usuario la resuelve por contexto. La voz no la tolera. La voz es lineal, no admite mirar dos cosas al mismo tiempo ni recuperar el contexto de un vistazo, y por eso exige que cada nodo se baste a sí mismo. Cuando un equipo se atasca al redactar una etiqueta, casi nunca es un problema de redacción: es que ese elemento hace dos cosas, o su nombre depende de un estado que no está representado, o la jerarquía visual codifica una relación que jamás se declaró en el código. La accesibilidad actúa entonces como un verificador de tipos aplicado al diseño: rechaza las expresiones mal formadas y las señala una a una. Y hay un corolario que conviene interiorizar: ese árbol no es un artefacto interno, es una API pública de tu app. Por ella entran VoiceOver, Voice Control, Full Keyboard Access, XCUITest, Switch Control, las pantallas braille, y cada vez más los agentes automáticos que operan interfaces sin verlas. Un equipo que trata el árbol de accesibilidad con el mismo rigor con que trata el esquema de su base de datos descubre que ha construido, sin proponérselo, la interfaz programable de su producto.
VoiceOver recorre un árbol de nodos, no la pantalla. Cada nodo se describe con etiqueta —qué es—, valor —cómo está—, sugerencia —qué hará— y rasgos —de qué naturaleza es—; nunca mezcles esos campos ni pongas información imprescindible en la sugerencia. Agrupa con children: .combine lo que significa una unidad, usa contain para regiones y ignore para lo dibujado a mano, y oculta lo decorativo. Corrige el recorrido con accessibilitySortPriority, mueve el foco con AccessibilityFocusState y expón todo gesto oculto como acción personalizada.
- Toma la pantalla más densa de tu app, actívala con
VoiceOvery transcribe literalmente lo que anuncia cada parada del cursor; cuenta cuántas necesitas para entender la pantalla. - Reduce ese número al menos a la mitad agrupando con
children: .combiney ocultando decoración, sin perder ni un dato relevante. - Sustituye un control dibujado a mano por su equivalente declarado con
accessibilityRepresentationy compara los dos anuncios y los gestos disponibles. - Expón como acción personalizada del rotor cada gesto de deslizamiento de tu lista y verifica que se puede completar la tarea sin conocer el gesto.
- Provoca un error de validación y comprueba que el foco aterriza en el campo culpable; añade un anuncio para el caso en que el error sea global y no de un campo concreto.