contentDescription — la descripción verbal
// Imagen informativa — necesita descripción
Image(
painter = painterResource(R.drawable.foto_perfil),
contentDescription = "Foto de perfil de ${usuario.nombre}"
// TalkBack anuncia: "Foto de perfil de Carlos, imagen"
)
// Imagen decorativa — no necesita descripción (null la oculta del árbol)
Image(
painter = painterResource(R.drawable.fondo_decorativo),
contentDescription = null // TalkBack la ignora completamente
)
// IconButton — SIEMPRE necesita descripción
IconButton(onClick = { viewModel.eliminar(item) }) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Eliminar ${item.nombre}"
// Incluir el contexto del item — "Eliminar" solo es ambiguo en una lista
)
}
// ❌ contentDescription vacío — peor que nada
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "" // TalkBack dice "Sin etiqueta" — confunde más que ayuda
)
// ❌ contentDescription redundante con el texto visible
Button(onClick = { }) {
Text("Guardar")
}
// NO agregar contentDescription = "Guardar" — el texto ya es la descripción
// Compose fusiona automáticamente el texto de los hijos con el nodo padre
Modifier.semantics {} — control fino
// semantics {} permite agregar o modificar propiedades semánticas custom
Box(
modifier = Modifier
.clickable { onSeleccionar() }
.semantics {
// Reemplazar la descripción que Compose generaría automáticamente
contentDescription = "Producto: ${producto.nombre}, precio: ${producto.precio}"
// Declarar el role para que TalkBack lo anuncie correctamente
role = Role.Button
}
) {
ProductoCardContent(producto)
}
// Custom action — acción que TalkBack puede ejecutar sin que sea un click
Box(
modifier = Modifier.semantics {
customActions = listOf(
CustomAccessibilityAction("Agregar al carrito") {
onAgregarAlCarrito(producto)
true
},
CustomAccessibilityAction("Ver detalles") {
onVerDetalles(producto)
true
}
)
}
) { /* contenido */ }
// Con TalkBack, el usuario puede deslizar arriba o abajo para elegir la acción
// sin tener que navegar a botones separados dentro de la card
Roles — qué tipo de control es este
// Los roles le dicen a TalkBack cómo anunciar el elemento
// y qué comportamiento esperar
// Role.Button → "Botón" al final de la descripción
Box(modifier = Modifier.semantics { role = Role.Button }) { }
// Role.Checkbox → TalkBack anuncia "marcado" o "desmarcado"
Box(modifier = Modifier.semantics {
role = Role.Checkbox
stateDescription = if (seleccionado) "Seleccionado" else "No seleccionado"
}) { }
// Role.Switch → igual que Checkbox pero con "activado/desactivado"
Box(modifier = Modifier.semantics { role = Role.Switch }) { }
// Role.Tab → para tabs de navegación
Box(modifier = Modifier.semantics { role = Role.Tab }) { }
// Role.RadioButton → para radio buttons en un grupo
Box(modifier = Modifier.semantics { role = Role.RadioButton }) { }
// Role.Image → para imágenes informativas
Box(modifier = Modifier.semantics { role = Role.Image }) { }
// Los componentes de Material3 ya tienen el role correcto automáticamente:
// Button → Role.Button
// Checkbox → Role.Checkbox
// Switch → Role.Switch
// Tab → Role.Tab
// Solo necesitás asignar role manualmente en componentes custom
mergeDescendants — agrupar para TalkBack
Por defecto, cada composable es un nodo separado en el árbol de accesibilidad. En una card con imagen, título y descripción, TalkBack navegaría por tres elementos separados. mergeDescendants = true los fusiona en uno solo:
// ❌ Sin mergeDescendants — TalkBack navega a cada elemento por separado:
// Foco 1: "foto de Carlos, imagen"
// Foco 2: "Carlos Pensa, texto"
// Foco 3: "Desarrollador Android, texto"
Row {
Image(painter = ..., contentDescription = "foto de Carlos")
Column {
Text("Carlos Pensa")
Text("Desarrollador Android")
}
}
// ✓ Con mergeDescendants — TalkBack lo lee todo de una vez:
// Foco único: "foto de Carlos, imagen. Carlos Pensa. Desarrollador Android."
Row(
modifier = Modifier.semantics(mergeDescendants = true) {
// También podés agregar una descripción personalizada para el grupo:
contentDescription = "Carlos Pensa, Desarrollador Android"
}
) {
Image(painter = ..., contentDescription = null) // null porque el grupo tiene descripción
Column {
Text("Carlos Pensa")
Text("Desarrollador Android")
}
}
// Cuándo usar mergeDescendants:
// ✓ Cards que representan un único item (usuario, producto, noticia)
// ✓ Filas de tabla con múltiples celdas relacionadas
// ✗ NO cuando los elementos tienen acciones individuales (botones dentro de la card)
stateDescription — describir el estado actual
// stateDescription describe el estado actual del componente
// TalkBack lo anuncia junto con la descripción
@Composable
fun FavoritoBoton(esFavorito: Boolean, onToggle: () -> Unit) {
IconButton(
onClick = onToggle,
modifier = Modifier.semantics {
contentDescription = "Favorito"
stateDescription = if (esFavorito) "Marcado como favorito" else "No es favorito"
// TalkBack anuncia: "Favorito, Marcado como favorito, botón"
// o: "Favorito, No es favorito, botón"
}
) {
Icon(
imageVector = if (esFavorito) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
contentDescription = null // null porque el semantics del padre ya tiene la descripción
)
}
}
// Para texto de error en campos de formulario:
OutlinedTextField(
value = email,
onValueChange = { email = it },
isError = emailError != null,
label = { Text("Email") },
supportingText = emailError?.let { { Text(it) } },
modifier = Modifier.semantics {
if (emailError != null) {
error(emailError) // TalkBack anuncia el error automáticamente
}
}
)
Elementos decorativos — ocultarlos del árbol
// Separadores, fondos decorativos, íconos puramente visuales:
Divider(
modifier = Modifier.semantics { invisibleToUser() }
// TalkBack lo ignora completamente
)
// Texto decorativo que no agrega información:
Text(
text = "•", // bullet puramente visual
modifier = Modifier.semantics { invisibleToUser() }
)
// Contenedor decorativo que no debería enfocarse:
Box(
modifier = Modifier.semantics { hideFromAccessibility() }
) {
// Todo el contenido dentro también queda oculto
}
Errores comunes en semántica de Compose
// ❌ Error 1: Describir el tipo en lugar de el contenido
Image(contentDescription = "Imagen") // inútil — TalkBack ya dice "imagen"
Icon(contentDescription = "Ícono") // inútil
// ✓ Describir QUÉ es o QUÉ hace
Image(contentDescription = "Mapa del centro de Buenos Aires")
Icon(contentDescription = "Menú principal")
// ❌ Error 2: Descripciones que empiezan con el tipo
contentDescription = "Botón de buscar" // TalkBack dice "Botón de buscar, botón"
// ✓
contentDescription = "Buscar" // TalkBack dice "Buscar, botón"
// ❌ Error 3: Box clickable sin role ni descripción
Box(modifier = Modifier.clickable { }) {
Text("Ver más")
}
// TalkBack dice el texto "Ver más" pero no sabe que es un botón
// ✓
Box(
modifier = Modifier
.clickable { }
.semantics { role = Role.Button }
) {
Text("Ver más")
}
// TalkBack dice "Ver más, botón"