Accesibilidad: el lector de pantalla
Al terminar: Recorrer la app con TalkBack y VoiceOver y arreglar lo que no se puede usar.
En la lección anterior, Accesibilidad: la letra grande, arreglaste lo que se ve. Esta arregla lo que se oye. Un lector de pantalla es el programa que lee en voz alta lo que hay en la pantalla y cambia los gestos con que se maneja el teléfono: TalkBack en Android, VoiceOver en iOS. Ya los conoces de Favoritos en Android y Favoritos en iOS.
Al terminar, en Android quien use TalkBack podrá quitar un favorito sin deslizar, la pestaña Favoritos dirá cuántos hay, y la fila de «Ordenar favoritos por número» será un solo interruptor en vez de dos cosas sueltas. En iOS comprobarás por qué la fila de favoritos ya se lee entera sin tocar nada. Habrá un test nuevo en androidApp, que pasa de 13 a 14.
El concepto · el lector no ve la pantalla, ve un árbol
Un lector de pantalla no mira píxeles. Mira el árbol de semántica: «elementos» (un botón, un texto, una fila) con lo que cada uno dice de sí mismo (su nombre, si está activado, qué acciones ofrece), colocados unos dentro de otros como las carpetas de un disco: una fila contiene sus textos y su imagen. Cada elemento del árbol se llama nodo. Es el mismo árbol que consultan los tests de Compose que escribiste en Pruebas de interfaz en Android: por eso esta lección se puede comprobar con un test, sin encender TalkBack.
Dos reglas mandan en todo lo que sigue:
- TalkBack enfoca un elemento a la vez, y solo ofrece las acciones de ese elemento. Si una acción está en otro sitio del árbol, para el lector no existe.
- Una fila, un elemento. Si una fila tiene dos cosas que se pueden enfocar (la fila entera y, dentro, un interruptor), la persona tiene que pasar por las dos y oye el nombre repetido.
Paso 1 · Quitar un favorito sin deslizar
Quitar un favorito se hace deslizando la fila. Un lector de pantalla se traga el deslizamiento (lo usa para moverse entre elementos), así que para quien usa TalkBack esa acción no existe. La solución es declararla de otra manera: una acción personalizada de accesibilidad, que TalkBack ofrece en su menú de acciones del elemento enfocado. Es lo mismo que hiciste en iOS con .accessibilityAction(named:) para el corazón de la tarjeta (Favoritos en iOS): una orden que el lector ofrece sin gesto. En Android se llama acción personalizada.
Los gestos del menú de TalkBack no se pueden hacer con el ratón en el emulador. Un test sí puede pedir la acción por su nombre, como la pediría el lector. Así que empieza por el test, que te dirá cuándo está bien.
El test, primero
Crea el fichero con el método que ya usaste: clic derecho sobre el nodo androidApp → New → File, y la ruta desde la carpeta del módulo:
src/test/kotlin/com/sebastiannarvaez/pokedex/android/favorites/FilaDeslizableTest.ktEscríbelo. Usa las mismas piezas de Pruebas de interfaz en Android: la regla createComposeRule, setContent y los buscadores. Lo nuevo es la última pieza, performCustomAccessibilityActionWithLabel, que hace lo que haría TalkBack: pedirle a un elemento una de sus acciones por el nombre que se oye.
package com.sebastiannarvaez.pokedex.android.favorites
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performCustomAccessibilityActionWithLabel
import com.sebastiannarvaez.pokedex.domain.FavoritePokemon
import com.sebastiannarvaez.pokedex.domain.PokemonType
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import kotlin.test.assertEquals
/**
* La accion «Quitar de favoritos» de TalkBack, comprobada sin TalkBack.
*
* Los gestos del menu de TalkBack no se pueden hacer con el raton en el
* emulador; un test si puede pedir la accion por su nombre, como la pide el
* lector de pantalla, al elemento que este enfoca.
*/
@RunWith(RobolectricTestRunner::class)
class FilaDeslizableTest {
@get:Rule
val compose = createComposeRule()
// `performCustomAccessibilityActionWithLabel` es todavia experimental:
// `@OptIn` le dice al compilador que lo sabes y lo aceptas.
@OptIn(ExperimentalTestApi::class)
@Test
fun `la fila ofrece quitar de favoritos sin deslizar`() {
var quitados = 0 // cuenta cuantas veces se ha pedido quitar
compose.setContent {
FilaDeslizable(
favorito = FavoritePokemon(1, "bulbasaur", PokemonType.GRASS),
alPulsar = {},
alQuitar = { quitados++ },
)
}
// El elemento que enfoca TalkBack es la fila que se pulsa, la que tiene
// el nombre. Si la accion estuviera en la caja de fuera, este nodo no la
// tendria y el test fallaria con «Key not present: … CustomActions».
compose.onNodeWithText("Bulbasaur").performCustomAccessibilityActionWithLabel("Quitar de favoritos")
assertEquals(1, quitados)
}
}El test llama a FilaDeslizable, que hoy es private en FavoritesScreen.kt (solo se puede usar dentro de su fichero). Para que el test la vea, cámbiala a internal (visible en todo el módulo androidApp, pero no fuera). Abre androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/favorites/FavoritesScreen.kt:
@Composable
private fun FilaDeslizable(favorito: FavoritePokemon, alPulsar: () -> Unit, alQuitar: () -> Unit) {// `internal` y no `private`: FilaDeslizableTest la pinta sola, sin la pantalla.
@Composable
internal fun FilaDeslizable(favorito: FavoritePokemon, alPulsar: () -> Unit, alQuitar: () -> Unit) {Provócalo. Lanza los tests sin haber añadido todavía la acción:
./gradlew :androidApp:testDebugUnitTestFalla (su mensaje está en inglés porque es el de Compose; dice, más o menos, «la clave no está presente: acciones personalizadas»), y el informe (androidApp/build/reports/tests/testDebugUnitTest/index.html, ábrelo en el navegador) cuenta 14 tests con uno en rojo. El mensaje acaba así:
java.lang.IllegalStateException: Key not present: AccessibilityKey: CustomActions - consider getOrElse or getOrNullSe lee fácil: la fila no tiene ninguna acción personalizada, y el test lo dice. Ahora la tiene.
La acción, en la fila que se pulsa
Los imports nuevos, con los demás de androidx.compose.ui:
import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semanticssemantics { } es el modificador que describe un elemento a los lectores de pantalla; customActions es la lista de acciones extra que ofrece. Dentro de FilaDeslizable, la fila vive al final, en la llamada Fila(favorito, alPulsar). Cámbiala por:
) {
// Una accion que solo existe como gesto **no existe** para quien usa
// TalkBack: el lector de pantalla se traga el deslizamiento. Declararla
// como accion personalizada la pone en el menu de acciones, que es
// donde TalkBack la busca. Va en la fila que TalkBack enfoca (la que se
// pulsa), no en la caja de fuera: TalkBack solo ofrece las acciones
// del elemento enfocado. iOS hace esto solo con `.swipeActions`.
Fila(
favorito,
alPulsar,
modifier = Modifier.semantics {
customActions = listOf(
CustomAccessibilityAction("Quitar de favoritos") {
alQuitar()
true // `true`: la accion se hizo
},
)
},
)
}Para eso, Fila (más abajo en el mismo fichero) necesita aceptar un modifier. Cámbiale la firma y la primera línea de su Row:
@Composable
private fun Fila(favorito: FavoritePokemon, alPulsar: () -> Unit, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.fillMaxWidth()
// … el resto de la cadena, igual que estaba …modifier: Modifier = Modifier es el parámetro que lleva casi todo composable de Compose: el que llama puede añadirle cosas (aquí, la semántica), y si no añade nada vale Modifier, el vacío. Que la Row empiece por modifier y no por Modifier es lo que deja pasar lo que traes de fuera.
Vuelve a lanzar ./gradlew :androidApp:testDebugUnitTest: termina en BUILD SUCCESSFUL y el informe cuenta 14 tests, los 13 de antes y el nuevo.
Provócalo tú. Prueba la versión que parece la lógica. Más arriba en FilaDeslizable está el SwipeToDismissBox(, la caja que envuelve a la fila y reacciona al deslizamiento. Quita el modifier = Modifier.semantics { … } de la llamada a Fila (déjala como Fila(favorito, alPulsar)) y escribe ese mismo bloque como un parámetro más del SwipeToDismissBox, por ejemplo justo antes de backgroundContent. Parece correcto: la acción queda en el sitio que describe «deslizar para quitar». Lanza los tests: vuelve a fallar con el mismo Key not present. Esa caja es solo el contenedor del deslizamiento; el elemento que TalkBack enfoca es la fila que se pulsa, y la acción está en otro nodo. Deshaz el cambio, deja la acción en la Fila y comprueba que vuelve a pasar.
Si quieres oírlo de verdad, enciende TalkBack en un móvil o emulador (Ajustes → Accesibilidad → TalkBack; su tutorial enseña los gestos de tu versión), enfoca una fila de la pestaña Favoritos y abre el menú de acciones: debe ofrecer «Quitar de favoritos». Es opcional: el test ya lo comprueba.
Paso 2 · La insignia que no se oía
En la pestaña Favoritos hay un círculo con el número de favoritos. A los ojos, lo dice todo; con TalkBack, la pestaña solo dice «Favoritos». El motivo está en el propio componente: cuando una pestaña (NavigationBarItem) tiene label, Compose borra la semántica del icono para no leer dos veces lo mismo, y el número vive dentro del icono. La solución es repetir el número como estado de la pestaña. stateDescription es un texto que el lector dice después del nombre del elemento, para describir cómo está: aquí, cuántos favoritos hay.
Abre androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/navigation/PokedexApp.kt. Los imports nuevos:
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescriptionDentro de NavigationBar { … }, antes del primer NavigationBarItem, calcula el texto; y en el item de Favoritos añade un modifier (el de insignia ya existe, es el que pinta el círculo):
NavigationBar {
// El numero del circulo no llega a TalkBack: con `label`, la
// pestana borra la semantica del icono. Se repite como estado
// del elemento («3 favoritos»). El texto se calcula aqui
// porque dentro de `semantics { }` no se pueden llamar
// funciones composable (más adelante hará falta).
val estadoInsignia = insignia?.let { "$it favoritos" }
// … el item de «Pokédex», sin cambios …
NavigationBarItem(
selected = actual == FavoritosKey,
onClick = { irAPestana(pila, FavoritosKey) },
// Sin favoritos es null y no se dice nada.
modifier = Modifier.semantics { estadoInsignia?.let { stateDescription = it } },
icon = {
// … lo que ya tenías: el `BadgedBox` con el círculo …
},
label = { Text("Favoritos") },
)insignia?.let { … } es la forma de Kotlin de decir «si hay número, haz esto con él; si no, nada» (Nulabilidad): sin favoritos, insignia es null, estadoInsignia también, y la pestaña no dice nada de más. Con «99+», dice «99+ favoritos».
Compila y lanza la app (Run): no cambia nada de lo que se ve. Para oírlo, enciende TalkBack, marca un par de favoritos y enfoca la pestaña: después de «Favoritos» debe añadir «2 favoritos». Esto no tiene test; es la prueba que pide un lector de pantalla de verdad.
Paso 3 · Un interruptor, una fila, un elemento
En Ajustes, la fila «Ordenar favoritos por número» tiene dos cosas que se pueden enfocar: la fila entera (con clickable) y, dentro, el Switch, que además tiene su propio onCheckedChange. TalkBack pasa por las dos y la persona oye el mismo ajuste dos veces.
Abre androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/settings/SettingsScreen.kt. Dos imports:
import androidx.compose.foundation.selection.toggleable
import androidx.compose.ui.semantics.RolePrimero la función Fila de ese fichero (no es la de favoritos, es otra con el mismo nombre en otro fichero), igual que antes: acepta un modifier, y lo pone delante de alPulsar. Fíjate también en alPulsar: (() -> Unit)? = null: el ? del final significa «puede ser null» (Nulabilidad), y () -> Unit es una función sin parámetros que no devuelve nada. Es decir: la fila puede recibir una acción para el toque o no recibir ninguna. La convención de Compose es que modifier sea el primer parámetro opcional, y Android Studio avisa (ModifierParameter) si va detrás.
@Composable
private fun Fila(
titulo: String,
detalle: String,
icono: @Composable () -> Unit,
modifier: Modifier = Modifier,
alPulsar: (() -> Unit)? = null,
) {
Row(
modifier = modifier
.fillMaxWidth()
// `.then(…)` añade a la cadena lo que le pases. Aquí: si hay algo
// que hacer al pulsar, la fila es pulsable; si no, se añade un
// `Modifier` vacio (que no cambia nada).
.then(if (alPulsar != null) Modifier.clickable(onClick = alPulsar) else Modifier)
.padding(horizontal = 24.dp, vertical = 16.dp),
// … el resto, igual que estaba …Y la fila del orden pasa de esto:
Fila(
titulo = "Ordenar favoritos por número",
detalle = if (ajustes.favoritosPorNumero) "Del 1 al último" else "Lo más reciente primero",
icono = {
Switch(
checked = ajustes.favoritosPorNumero,
onCheckedChange = viewModel::cambiarOrden,
)
},
alPulsar = { viewModel.cambiarOrden(!ajustes.favoritosPorNumero) },
)a esto:
Fila(
titulo = "Ordenar favoritos por número",
detalle = if (ajustes.favoritosPorNumero) "Del 1 al último" else "Lo más reciente primero",
icono = {
// `null`: el interruptor ya no se pulsa por su cuenta; lo hace la fila.
Switch(checked = ajustes.favoritosPorNumero, onCheckedChange = null)
},
// Toda la fila es el interruptor: un solo elemento para TalkBack,
// con su papel (`Role.Switch`) y su estado (activado o no). Antes
// eran dos: la fila pulsable y el Switch con su propio toque.
modifier = Modifier.toggleable(
value = ajustes.favoritosPorNumero,
role = Role.Switch,
onValueChange = viewModel::cambiarOrden,
),
)Fíjate en lo que desaparece: el alPulsar de esa fila. Pulsar ahora lo gestiona toggleable, que además recibe el valor nuevo (onValueChange), así que ya no hace falta calcular !ajustes.favoritosPorNumero.
Compruébalo. Lanza la app y ve a Ajustes: la fila se ve y se toca igual. Con la app abierta en esa pestaña, vuelca el árbol de la pantalla desde la Terminal:
adb exec-out uiautomator dump /dev/ttyadb exec-out le pide al móvil que ejecute una orden y te devuelva el resultado, uiautomator dump describe en texto cada elemento de la pantalla, y /dev/tty hace que lo escriba aquí, en la Terminal, en vez de guardarlo en un fichero. (Si adb no se reconoce, usa su ruta completa, ~/Library/Android/sdk/platform-tools/adb, como en Instalar Android Studio y el SDK.) El resultado es mucho texto, con un <node …> por cada elemento. Busca en él (Cmd + F en la Terminal) el texto «Ordenar favoritos por número»: el nodo que contiene ese texto está dentro de otro, la fila entera, y es esa fila la que lleva checkable="true". Lo que no debe aparecer es un segundo nodo de la clase del interruptor, android.widget.Switch. Mira también el atributo checked: dice checked="false" si el ajuste estaba apagado. Toca la fila y vuelve a volcar: ahora dice checked="true". Con TalkBack encendido, esa fila se oye como un solo interruptor, con su título, su detalle y si está activado.
Paso 4 · iOS: lo que ya estaba bien
Hay decisiones que parecen descuidos y no lo son. Empieza por iOS: la fila de favoritos tiene el nombre, el número y el tipo como tres textos, y parece que VoiceOver los leería uno por uno. No lo hace. La fila es un Button, y un botón de SwiftUI ya junta el contenido de su etiqueta en un solo elemento. En iosApp/iosApp/Favorites/FavoritesView.swift no hay que escribir nada para eso:
Button {
camino.append(favorito.id)
} label: {
HStack(spacing: 16) {
// … la imagen, el nombre, el número y el tipo …
}
}
.buttonStyle(.plain)
.swipeActions(edge: .trailing) {
Button("Quitar", systemImage: "heart.slash", role: .destructive) {
// … quitar el favorito …
}
}Compruébalo. Necesitas al menos un favorito (en la pestaña Pokédex, toca el corazón de uno). No estropea los tests de interfaz de Pruebas de interfaz en iOS: con -uiTests la app abre una base en memoria y unos ajustes aparte, así que esos tests empiezan siempre con Favoritos vacío, aunque tú hayas marcado favoritos. Abre el inspector, elige el simulador y pasa el ratón por una fila de la pestaña Favoritos: es un solo elemento, cuyo Label (lo que lee VoiceOver) dice algo como «Bulbasaur, N.º 0001, Planta».
Provócalo. Añade .accessibilityElement(children: .combine) tras .buttonStyle(.plain), la línea que junta los textos de dentro, y mira el inspector otra vez: el elemento es el mismo, con la misma etiqueta. La línea no hace nada aquí; quítala. Sirve cuando los textos están sueltos en un HStack que no es un botón, y no es el caso.
Tampoco hace falta una acción para quitar el favorito: .swipeActions ya se ofrece a VoiceOver como acciones del elemento. Es lo que dice la documentación de Apple; el simulador no tiene VoiceOver, así que en un iPhone real se comprueba con el rotor: un gesto de VoiceOver (girar dos dedos sobre la pantalla, como si giraras una rueda) que cambia qué hace el deslizamiento vertical, y una de sus opciones es «Acciones».
Paso 5 · Lo que ya estaba bien en Android
La ilustración de la fila de favoritos de Android lleva contentDescription = null, y es a propósito: la imagen repite el nombre que hay justo al lado, y describirla haría que TalkBack dijera «Bulbasaur, imagen de Bulbasaur». Una imagen decorativa se marca como decorativa. La pregunta para decidirlo es: si quitaras la imagen, ¿se perdería información? En la fila no: el nombre y el número siguen ahí. Donde sí se perdería (un gráfico que es el único portador del dato) es donde la descripción hace falta de verdad. Describir de más es tan malo como describir de menos, y se nota más: en una lista larga duplica el tiempo de recorrerla.
La tarjeta de la lista, en cambio, se lee como una sola cosa con semantics { contentDescription = … }, como viste en Refresco, barra y accesibilidad en Android.
Punto de control
FilaDeslizableTest.ktexiste yFilaDeslizableesinternal../gradlew :androidApp:testDebugUnitTesttermina enBUILD SUCCESSFULy el informe (androidApp/build/reports/tests/testDebugUnitTest/index.html) cuenta 14 tests: los 13 de antes y el nuevo.- El núcleo no ha 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; en Xcode (Cmd + U) siguen los 4 tests de interfaz sin fallos. - La acción «Quitar de favoritos» cuelga de la
Fila, no delSwipeToDismissBox. - La pestaña Favoritos lleva
stateDescription(«3 favoritos») cuando hay favoritos, y nada cuando no hay. Comprobado con TalkBack, si lo tienes. - La fila «Ordenar favoritos por número» es un solo nodo (la fila entera) con
checkable="true"en el volcado, yFilade Ajustes recibemodifierdelante dealPulsar. FavoritesView.swiftno tiene.accessibilityElement(children: .combine): el inspector enseña una sola fila por favorito.
Comprueba lo que entendiste
6 preguntas · necesitas 5 para completar la lección
1.Quitar un favorito solo se podía deslizando. ¿Qué problema tenía eso para quien usa TalkBack?
2.La acción «Quitar de favoritos» puesta en el
SwipeToDismissBoxhacía fallar el test (y TalkBack no la ofrecería). ¿Por qué había que ponerla en laFila?3.La pestaña Favoritos mostraba el círculo con «3», pero TalkBack solo decía «Favoritos». ¿Qué lo arregla?
4.La fila «Ordenar favoritos por número» tenía el
Switchy la fila pulsables por separado. ¿Qué cambia contoggleable(role = Role.Switch)en la fila yonCheckedChange = nullen elSwitch?5.En iOS, ¿por qué no hace falta
.accessibilityElement(children: .combine)en la fila de favoritos?6.¿Por qué la ilustración de la fila de favoritos de Android lleva
contentDescription = null?