Agrupar y ordenar el recorrido
Un árbol semántico correcto elemento a elemento puede seguir siendo una pantalla inutilizable si su granularidad y su orden no coinciden con la estructura mental del contenido. Esta lección estudia la fusión de nodos: qué hace exactamente `mergeDescendants`, cómo se combinan las propiedades al fusionar y cuáles no se propagan, por qué los componentes de Material fusionan solos y qué ocurre al anidar fusiones, cómo se decide la frontera de una unidad de lectura, y cómo se manipula el orden de recorrido con `isTraversalGroup` y `traversalIndex` cuando la geometría no basta para inferirlo.
Hay una clase de defecto de accesibilidad que ninguna herramienta automática detecta y que sin embargo arruina la experiencia por completo: la pantalla está perfectamente etiquetada y es insoportable de usar. Ocurre cuando la granularidad del árbol semántico no se corresponde con la granularidad del significado. Una tarjeta de producto con imagen, nombre, precio anterior tachado, precio actual, valoración, número de reseñas y botón de compra son siete paradas del recorrido si nadie dice lo contrario, y el usuario debe reconstruir mentalmente la unidad a partir de siete fragmentos inconexos, sin saber siquiera dónde empieza la siguiente tarjeta. Multiplicado por veinte productos son ciento cuarenta gestos para hojear un catálogo. La solución no es escribir mejores etiquetas: es decidir dónde están las fronteras de las unidades de lectura y en qué orden se recorren. Agrupar y ordenar es el trabajo estructural de la accesibilidad, y es el que separa una pantalla técnicamente conforme de una pantalla realmente usable.
- Explicar el mecanismo de fusión y qué propiedades se combinan, cuáles se descartan y cuáles no viajan nunca.
- Decidir dónde colocar la frontera de una unidad de lectura y justificarla con el modelo mental del contenido.
- Distinguir
mergeDescendantsdeclearAndSetSemanticsy elegir el correcto en cada situación. - Controlar el orden de recorrido con
isTraversalGroupytraversalIndexcuando la disposición visual engaña.
Qué hace exactamente fusionar
Modifier.semantics(mergeDescendants = true) marca un nodo como absorbente. En el árbol fusionado, ese nodo pasa a presentar una configuración que resulta de combinar la suya propia con la de todos sus descendientes, y esos descendientes dejan de aparecer como nodos independientes. El resultado es una sola parada del recorrido cuya locución concatena los textos encontrados en el subárbol.
La combinación no es una unión ingenua, y sus reglas importan. Las propiedades de texto se acumulan en el orden en que aparecen en el árbol. Las propiedades de valor único, como el rol o stateDescription, no se sobrescriben desde abajo: gana la declaración más cercana a la raíz de la fusión, y por eso puedes fijar el rol en el contenedor sin que un hijo lo pise. Las acciones de los descendientes sí se conservan y quedan disponibles en el nodo fusionado, lo cual es esencial: fusionar una tarjeta pulsable no la vuelve inerte.
flowchart TD A[Card con mergeDescendants] --> B[Imagen del producto] A --> C[Nombre] A --> D[Precio] A --> E[Valoracion] A --> F[Boton comprar con su propia fusion] B -.-> G[Absorbido] C -.-> G D -.-> G E -.-> G F ==> H[Sigue siendo parada propia]
El diagrama muestra la asimetría más importante y la menos conocida: una fusión no atraviesa otra fusión. Si un descendiente está a su vez marcado como absorbente, su subárbol no se vierte en el ancestro; ese nodo permanece como parada independiente. Esta regla es la que hace que el patrón habitual funcione sin esfuerzo: una tarjeta fusionada que contiene un botón de Material sigue produciendo dos paradas, la tarjeta y el botón, porque el botón ya fusionaba por su cuenta.
Fusión implícita y por qué casi nunca la ves
Los componentes interactivos de Material ya vienen fusionados. Button, Card con onClick, ListItem y los modificadores clickable, toggleable y selectable marcan el nodo como absorbente, porque un elemento interactivo es por definición una unidad indivisible: no tiene sentido que el usuario aterrice dentro de un botón.
Card(onClick = { abrir(producto.id) }) { // fusiona sus descendientes
Column {
Text(producto.nombre)
Text(producto.precioFormateado)
Text("${producto.resenas} resenas")
}
}
Esa tarjeta es una sola parada sin que hayas escrito nada. El problema aparece con los contenedores no interactivos, que son mayoría: una tarjeta puramente informativa, una fila de resumen, un bloque de metadatos. Ahí no hay fusión implícita y hay que declararla.
Column(
modifier = Modifier.semantics(mergeDescendants = true) {},
) {
Text(evento.titulo)
Text(evento.fechaLegible)
Text(evento.lugar)
}
El bloque vacío no es un descuido: la firma exige una lambda de configuración, y en este caso no queremos añadir propiedades, solo activar la absorción. El resultado se lee como una frase única en lugar de tres fragmentos.
Conviene además saber dónde no hay que fusionar aunque la tentación sea fuerte. Un contenedor con desplazamiento no debe absorber su contenido, porque el usuario perdería la capacidad de moverse dentro de él elemento a elemento y la lista entera se convertiría en una única locución interminable. Lo mismo vale para cualquier bloque de texto largo: fusionar un artículo completo impide navegar por párrafos, que es justamente la granularidad que el lector de pantalla ofrece para leer contenido extenso. La fusión sirve para unidades discretas y repetidas, no para masas de contenido continuo.
Al fusionar, los textos se acumulan en el orden en que aparecen en el árbol semántico, que deriva de la geometría y no del orden de escritura. Si una tarjeta coloca el precio antes que el nombre por razones de maquetación, la locución dirá el precio primero. Cuando ese orden resulta antinatural, la solución no es reordenar la maqueta sino reemplazar la semántica del bloque con una frase redactada por ti.
Antes de fusionar, formula la locución resultante en voz alta y pregúntate si es una unidad que alguien querría escuchar entera o saltarse entera. Si la respuesta es sí a ambas, la frontera está bien puesta. Si dentro de esa unidad hay información que el usuario podría querer alcanzar por separado —un precio que compara, una fecha que busca—, la fusión es demasiado gruesa. Y si la locución dura más de unos segundos, casi seguro estás fusionando una sección entera y no un elemento.
Fusionar frente a reemplazar
mergeDescendants y clearAndSetSemantics producen ambos una sola parada, y ahí acaba el parecido. La diferencia es qué ocurre con la información de los hijos.
mergeDescendants
Combina. Conserva textos, acciones y propiedades del subárbol, y las presenta unificadas. Es la opción por defecto y la que debes usar cuando el contenido de los hijos es exactamente lo que quieres que se oiga.
clearAndSetSemantics
Descarta. Elimina toda la semántica del subárbol, incluidas las acciones, y la sustituye por la que tú declares. Es la opción para composiciones cuya representación visual no se traduce bien a texto concatenado.
El criterio de elección es directo. Si la concatenación de los textos hijos produce una locución comprensible, fusiona. Si produce ruido —una barra de estrellas que se leería como cinco iconos y un número suelto, un gráfico de barras que se leería como una ristra de cifras sin contexto, una fecha partida en tres textos por razones de tipografía—, reemplaza y escribe tú la frase. Y antes de reemplazar, verifica siempre que dentro no hay nada pulsable, porque desaparecerá.
Existe una tercera situación, más sutil, en la que ninguna de las dos opciones basta: cuando el contenedor debe leerse como unidad y además ofrecer varias acciones que sus hijos implementaban por separado. Fusionar conserva esas acciones pero las presenta sin nombre distinguible, de modo que el usuario oye que hay acciones disponibles sin saber cuál es cuál. La herramienta adecuada aquí son las acciones personalizadas, que asocian una etiqueta legible a cada operación y las exponen en el menú de acciones del servicio.
Modifier.semantics {
customActions = listOf(
CustomAccessibilityAction("Archivar") { archivar(id); true },
CustomAccessibilityAction("Marcar como no leido") { marcar(id); true },
)
}
Este patrón resuelve de forma elegante el caso clásico de la fila con acciones al deslizar: el gesto lateral es invisible para el árbol, y sin acciones personalizadas esas operaciones sencillamente no existen para quien navega con el lector.
El orden de recorrido y cuándo hay que corregirlo
Compose infiere el orden de recorrido de la geometría de los nodos, no del orden de declaración en el código. Recorre de arriba abajo y, dentro de una misma banda, en el sentido de lectura del idioma configurado. Para una disposición convencional esto acierta casi siempre, y conviene resistir la tentación de tocarlo.
Falla, en cambio, en tres situaciones reconocibles. Cuando la disposición visual reordena elementos respecto a su significado, por ejemplo con un Modifier.offset o una superposición en un Box. Cuando hay columnas paralelas y el recorrido salta en zigzag entre ellas en lugar de agotar una y pasar a la siguiente. Y cuando un elemento flotante —una barra inferior, un botón de acción, un banner— aparece en el árbol lejos de donde el usuario espera encontrarlo.
Para el caso de las columnas y las secciones existe isTraversalGroup, que declara un subárbol como bloque cohesionado: el recorrido entra, lo agota entero y sale, en lugar de entremezclarlo con nodos vecinos.
Row {
Column(Modifier.semantics { isTraversalGroup = true }) { Filtros() }
Column(Modifier.semantics { isTraversalGroup = true }) { Resultados() }
}
Cuando además necesitas alterar la prioridad relativa, traversalIndex acepta un valor en coma flotante: los valores menores se visitan antes dentro del mismo grupo, y el valor por defecto es cero. Se usa sobre todo para adelantar un elemento crítico, como un mensaje de error, o para retrasar un adorno.
Modifier.semantics {
isTraversalGroup = true
traversalIndex = -1f // se visita antes que sus hermanos
}
Dos advertencias sobre estas herramientas. La primera es que traversalIndex solo ordena dentro de su grupo, así que sin isTraversalGroup en el ancestro adecuado su efecto será errático. La segunda es que reordenar el recorrido es una intervención agresiva sobre la expectativa del usuario, y un orden semántico que discrepe demasiado del orden visual desconcierta a quien usa aumento de pantalla y ve ambos a la vez.
Merece la pena distinguir estas dos propiedades de la fusión, porque se confunden con frecuencia. isTraversalGroup no fusiona nada: los nodos siguen siendo paradas independientes, solo que se visitan seguidos. Fusionar responde a la pregunta cuántas paradas hay; agrupar el recorrido responde a en qué orden se visitan. Puedes necesitar una, otra o ambas, y aplicarlas por separado es lo que permite tener una sección con seis elementos alcanzables individualmente que sin embargo nunca se entremezclan con la columna vecina.
Por último, una comprobación barata que detecta casi todos los defectos de orden: recorre la pantalla con el gesto de avance mirando la pantalla y anota la trayectoria del rectángulo de enfoque. Si el rectángulo salta hacia atrás, cruza en diagonal repetidamente o visita el pie antes que el cuerpo, tienes un problema de orden aunque cada nodo individual esté perfectamente etiquetado.
Vale la pena detenerse en lo que la agrupación revela sobre la naturaleza de las interfaces gráficas, porque explica por qué este es el trabajo más difícil de la accesibilidad y el que peor toleran las herramientas automáticas. Una pantalla comunica su estructura mediante recursos que no son lingüísticos: proximidad, alineación, encuadre, contraste de fondo, jerarquía tipográfica, espacio en blanco. Un usuario vidente no percibe una tarjeta como siete textos próximos, percibe una tarjeta, y lo hace antes de leer una sola palabra, porque el sistema visual agrupa por principios gestálticos en decenas de milisegundos y sin esfuerzo consciente. Toda esa información estructural —que es información real, que carga significado, que el diseñador colocó ahí deliberadamente— viaja por un canal que la representación lineal del habla no tiene. Cuando fusionas un subárbol no estás optimizando el número de gestos: estás transcribiendo la gestalt. Estás diciendo esto de aquí es una cosa, y esa afirmación es tan sustantiva como cualquier etiqueta que hayas escrito. Por eso ninguna herramienta puede hacerlo por ti: el Accessibility Scanner puede comprobar que un elemento tiene descripción y que un objetivo táctil mide lo suficiente, pero no puede saber si el borde que dibujaste alrededor de cinco textos significa una unidad o es un adorno. Solo tú conoces el modelo de contenido. Y por eso mismo, cuando una pantalla resiste todos los intentos de agrupación razonable, lo que suele estar diciendo es que su estructura visual no corresponde a ninguna estructura de datos, es decir, que agrupó por conveniencia de maquetación y no por significado. La accesibilidad, una vez más, actúa como revelador: obliga a poner en palabras una organización que hasta entonces solo existía en el espacio.
- Cuenta los deslizamientos necesarios para atravesar una lista de tu aplicación con
TalkBacky anota cuántos corresponden a información y cuántos a fragmentos. - Fusiona las unidades que el diseño trata como bloques y vuelve a contar. Justifica cada frontera con el modelo de datos, no con la maqueta.
- Localiza una composición cuya concatenación suene absurda y sustitúyela con
clearAndSetSemantics, comprobando antes que no contiene acciones. - Provoca deliberadamente un recorrido en zigzag con dos columnas y corrígelo con
isTraversalGroup. Verifica el cambio en el volcado del árbol. - Adelanta un mensaje de error con
traversalIndexy razona si la ganancia compensa la divergencia respecto al orden visual.