Orden de foco — por qué importa
TalkBack y Switch Access navegan los elementos en el orden en que aparecen en el árbol de accesibilidad. Por defecto, ese orden es de arriba a izquierda hacia abajo a derecha — que es el correcto para la mayoría de los layouts lineales. Los problemas aparecen con layouts absolutos, overlays, o cuando el orden visual no coincide con el orden lógico.
# Orden correcto de foco para una pantalla de formulario:
# 1. Título de la pantalla
# 2. Campo "Nombre"
# 3. Campo "Email"
# 4. Campo "Contraseña"
# 5. Botón "Registrarse"
# 6. Link "Ya tengo cuenta"
# Orden incorrecto (Layout incorrecto):
# 1. Botón "Registrarse" ← el botón apareció primero en el layout XML
# 2. Título
# 3. Campo "Nombre"
# ...
# El usuario con TalkBack llega al botón antes de llenar el formulario
traversalIndex en Compose
// traversalIndex controla el orden de foco en Compose
// Valor más bajo = se enfoca primero
// Default: 0f para todos los composables → orden visual
// Ejemplo: dos columnas side-by-side donde el orden lógico es top-to-bottom
// (primero todos los elementos de la izquierda, luego los de la derecha)
Row {
Column {
Text("Precio original", modifier = Modifier.semantics { traversalIndex = 1f })
Text("$100", modifier = Modifier.semantics { traversalIndex = 3f })
}
Column {
Text("Descuento", modifier = Modifier.semantics { traversalIndex = 2f })
Text("20%", modifier = Modifier.semantics { traversalIndex = 4f })
}
}
// Con traversalIndex: TalkBack lee "Precio original" → "Descuento" → "$100" → "20%"
// Sin traversalIndex: leería en orden visual izq-der, fila por fila
// Mover el FAB al final del orden de foco (aunque visualmente esté abajo):
FloatingActionButton(
onClick = { },
modifier = Modifier.semantics { traversalIndex = Float.MAX_VALUE }
) {
Icon(Icons.Default.Add, "Agregar nuevo elemento")
}
// Así el FAB se enfoca después de todos los items de la lista, no intercalado
Grupos de foco
// isTraversalGroup = true hace que TalkBack navegue todo el contenido del grupo
// antes de salir a los elementos siguientes
// Caso de uso: dos secciones en la pantalla que deberían navegarse completas
// antes de pasar a la otra
Column {
// Sección de información (navegar toda antes de ir a las acciones)
Column(modifier = Modifier.semantics { isTraversalGroup = true }) {
Text("Producto: Mouse Logitech")
Text("Precio: $5.999")
Text("Stock: 12 unidades")
}
// Sección de acciones
Row(modifier = Modifier.semantics { isTraversalGroup = true }) {
Button(onClick = { }) { Text("Agregar al carrito") }
Button(onClick = { }) { Text("Ver más") }
}
}
// TalkBack navega: info del producto completa → luego las acciones
// Sin isTraversalGroup podría intercalar: "Precio" → "Agregar al carrito" → "Stock"
Diálogos y bottom sheets — foco correcto
Cuando aparece un diálogo, TalkBack debe mover el foco dentro del diálogo y no permitir que el usuario navegue fuera hasta cerrarlo. Los componentes de Material3 manejan esto automáticamente. Para diálogos custom:
// AlertDialog de Material3 — foco correcto automáticamente
AlertDialog(
onDismissRequest = { mostrarDialogo = false },
title = { Text("Confirmar eliminación") },
text = { Text("¿Estás seguro de que querés eliminar este elemento?") },
confirmButton = {
TextButton(onClick = { onConfirmar(); mostrarDialogo = false }) {
Text("Eliminar")
}
},
dismissButton = {
TextButton(onClick = { mostrarDialogo = false }) {
Text("Cancelar")
}
}
)
// TalkBack: al abrir el diálogo, el foco va al título automáticamente
// No puede navegar fuera del diálogo mientras está abierto
// Dialog custom — manejar el foco manualmente si es necesario:
if (mostrarDialogo) {
Dialog(onDismissRequest = { mostrarDialogo = false }) {
val focusTitulo = remember { FocusRequester() }
LaunchedEffect(Unit) {
// Dar foco al primer elemento cuando aparece el diálogo
focusTitulo.requestFocus()
}
Surface(shape = RoundedCornerShape(16.dp)) {
Column {
Text(
"Título del diálogo",
modifier = Modifier.focusRequester(focusTitulo)
.focusable()
)
// Resto del contenido...
}
}
}
}
Foco al cambiar de pantalla
// Cuando el usuario navega a una pantalla nueva, TalkBack debe
// mover el foco al primer elemento relevante (típicamente el título)
@Composable
fun PantallaDetalle(producto: Producto) {
val focusTitulo = remember { FocusRequester() }
// Dar foco al título cuando la pantalla aparece
LaunchedEffect(Unit) {
focusTitulo.requestFocus()
}
Column {
Text(
text = producto.nombre,
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier
.focusRequester(focusTitulo)
.focusable()
.semantics { heading() } // marcar como encabezado
)
// Resto del contenido...
}
}
// En Views — mover el foco programáticamente:
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// Anunciar que la pantalla cambió
ViewCompat.setAccessibilityDelegate(binding.root,
object : AccessibilityDelegateCompat() {
override fun onInitializeAccessibilityEvent(host: View, event: AccessibilityEvent) {
super.onInitializeAccessibilityEvent(host, event)
if (event.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
event.className = Fragment::class.java.name
}
}
}
)
// Mover el foco al título
binding.tvTitulo.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED)
}
Foco atrapado — el bug más frustrante
El foco "atrapado" ocurre cuando TalkBack no puede salir de un elemento o grupo. Esto pasa con overlays que bloquean visualmente la UI pero no bloquean el árbol de accesibilidad, o con componentes que capturan el foco sin forma de salir.
// ❌ Loading overlay que no bloquea el foco de los elementos detrás
Box {
ListaContenido() // TalkBack puede llegar acá aunque no sea visible
if (cargando) {
Box( // overlay visual pero el foco sigue yendo a ListaContenido
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.5f))
) {
CircularProgressIndicator()
}
}
}
// ✓ Bloquear el foco en el overlay cuando está activo
Box {
Box(modifier = Modifier.semantics {
if (cargando) invisibleToUser() // ocultar del árbol cuando hay overlay
}) {
ListaContenido()
}
if (cargando) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.5f))
.semantics { contentDescription = "Cargando, por favor espera" }
) {
CircularProgressIndicator()
}
}
}