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()
        }
    }
}