Semántica en Compose

Una composición describe la IU de la app y se produce mediante la ejecución de elementos que admiten composición. La composición es una estructura de árbol que consta de elementos que admiten composición y que describen la IU.

Junto a la composición, existe un árbol paralelo, que se denomina árbol semántico. En este árbol, se describe la IU de una manera alternativa que pueden comprender los servicios de accesibilidad y el marco de trabajo de prueba. Los servicios de accesibilidad usan el árbol para describirles la app a los usuarios con una necesidad específica. El marco de trabajo de prueba lo usa para interactuar con la app y realizar aserciones sobre esta. El árbol semántico no incluye información para dibujar elementos que admiten composición, pero contiene información sobre el significado semántico de estos elementos.

Figura 1: Una jerarquía de IU típica y su árbol semántico

Si la app se conforma de elementos que admiten composición y modificadores de la biblioteca base y Material de Compose, el árbol semántico se completará y generará automáticamente. Sin embargo, cuando agregues elementos personalizados que admitan composición de bajo nivel, deberás brindar su semántica de forma manual. También, en algunas situaciones, es posible que el árbol no represente de forma correcta o completa el significado de los elementos en la pantalla. En este caso, puedes adaptar el árbol.

Por ejemplo, ten en cuenta este calendario personalizado que admite composición:

Figura 2: Calendario personalizado que admite composición con elementos de día seleccionables

En este ejemplo, todo el calendario se implementa como un solo elemento que admite composición de bajo nivel si se usa el objeto Layout que admite composición y se dibuja directamente en Canvas. Si no realizas ninguna otra acción, los servicios de accesibilidad no recibirán suficiente información sobre el contenido del elemento que admite composición y la selección del usuario en el calendario. Por ejemplo, si un usuario hace clic en el día que contiene 17, el marco de trabajo de accesibilidad solamente recibe la información de descripción de todo el control de calendario. En este caso, el servicio de accesibilidad de TalkBack solo anunciaría "Calendario" o la variante "Calendario de abril" (solo ligeramente mejor), y el usuario no sabría qué día se seleccionó. Para que este elemento que admite composición sea más accesible, deberás agregar la información semántica de forma manual.

Propiedades semánticas

Todos los nodos en el árbol de IU con algún significado semántico tienen un nodo paralelo en el árbol semántico. El nodo en el árbol semántico incluye esas propiedades que transmiten el significado del elemento correspondiente que admite composición. Por ejemplo, el elemento Text que admite composición incluye una propiedad semántica text, ya que ese es el significado de ese tipo de elemento. Un objeto Icon tiene una propiedad contentDescription (si lo establece el desarrollador) que transmite, por medio de texto, cuál es el significado de Icon. Los elementos que admiten composición y los modificadores que se compilan sobre la biblioteca base de Compose ya establecen las propiedades relevantes por ti. De manera opcional, puedes configurar o anular las propiedades tú mismo con los modificadores semantics y clearAndSetSemantics. Por ejemplo, puedes agregar acciones de accesibilidad personalizadas a un nodo, brindar una descripción de estado alternativa para un elemento que se puede activar o desactivar, o indicar que un texto determinado que admite composición debe considerarse como un encabezado.

Para visualizar el árbol semántico, podemos usar la herramienta Inspector de diseño o el método printToLog() dentro de nuestras pruebas. De esta manera, se imprimirá el árbol semántico actual dentro de Logcat.

class MyComposeTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun MyTest() {
        // Start the app
        composeTestRule.setContent {
            MyTheme {
                Text("Hello world!")
            }
        }
        // Log the full semantics tree
        composeTestRule.onRoot().printToLog("MY TAG")
    }
}

El resultado de esta prueba sería el siguiente:

    Printing with useUnmergedTree = 'false'
    Node #1 at (l=0.0, t=63.0, r=221.0, b=120.0)px
     |-Node #2 at (l=0.0, t=63.0, r=221.0, b=120.0)px
       Text = '[Hello world!]'
       Actions = [GetTextLayoutResult]

Veamos un ejemplo de cómo se usan las propiedades semánticas para transmitir el significado de un elemento que admite composición. Pensemos en un objeto Switch. El usuario lo verá de la siguiente manera:

Figura 3: Un interruptor en su estado "Activado" y "Desactivado"

Puedes describir el significado de este elemento de la siguiente manera: "Este es un interruptor, el cual es un elemento que se puede activar o desactivar, y, en este momento, se encuentra en estado "Activado". Puedes hacer clic en el interruptor para interactuar con él".

Las propiedades semánticas se usan exactamente para este fin. El nodo semántico de este elemento Switch incluye las siguientes propiedades, como se visualizan con el Inspector de diseño:

Figura 4: El Inspector de diseño muestra las propiedades semánticas de un elemento Switch que admite composición

El objeto Role indica el tipo de elemento que observamos. El elemento StateDescription describe cómo se debe hacer referencia al estado "Activado". De forma predeterminada, esta es solo una versión localizada de la palabra "Activado", pero puede ser más específica (por ejemplo, "Habilitado") según el contexto. El objeto ToggleableState es el estado actual del interruptor. La propiedad OnClick hace referencia al método que se utiliza para interactuar con este elemento. Para obtener una lista completa de las propiedades semánticas, consulta el objeto SemanticsProperties. Para obtener una lista completa de las acciones de accesibilidad posibles, consulta el objeto SemanticsActions.

Hacer un seguimiento de las propiedades semánticas de cada elemento que admite composición en la app ofrece muchas posibilidades potentes. Estos son algunos ejemplos:

  • TalkBack usa las propiedades para leer en voz alta lo que se muestra en la pantalla y le permite al usuario interactuar con esta sin problemas. Se podría describir nuestro interruptor de la siguiente manera: "Activado; interruptor; presiona dos veces para activar o desactivar". El usuario puede presionar dos veces la pantalla a fin de desactivar el interruptor.
  • El marco de trabajo de prueba usa las propiedades para encontrar nodos, interactuar con ellos y realizar aserciones. Esta podría ser una muestra de prueba para nuestro Switch:
    val mySwitch = SemanticsMatcher.expectValue(
        SemanticsProperties.Role, Role.Switch
    )
    composeTestRule.onNode(mySwitch)
        .performClick()
        .assertIsOff()

Árbol semántico combinado y separado

Como se mencionó antes, es posible que cada elemento que admite composición en el árbol de IU no tenga propiedades semánticas establecidas o que sí las tenga. Cuando un elemento que admite composición no tiene propiedades semánticas establecidas, no se incluye como parte del árbol semántico. De esa manera, el árbol semántico solamente incluye los nodos que, en realidad, tienen significado semántico. Sin embargo, con frecuencia, para transmitir el significado correcto de lo que se muestra en la pantalla, también es útil combinar subárboles determinados de nodos y tratarlos como uno solo. De este modo, podemos pensar en un conjunto de nodos como un todo, en lugar de tratar cada nodo subordinado de forma individual. Como regla general, cada nodo de este árbol representa un elemento enfocable cuando se usan los servicios de accesibilidad.

Un ejemplo de este elemento que admite composición es Button. Nos gustaría pensar en el objeto Button como un elemento único, aunque es posible que incluya varios nodos secundarios:

Button(onClick = { /*TODO*/ }) {
    Icon(
        imageVector = Icons.Filled.Favorite,
        contentDescription = null
    )
    Spacer(Modifier.size(ButtonDefaults.IconSpacing))
    Text("Like")
}

En nuestro árbol semántico, se combinan las propiedades de los elementos subordinados de Button, y Button se presenta como un único nodo de hoja en el árbol:

Los elementos que admiten composición y los modificadores pueden indicar que quieren combinar las propiedades semánticas de sus elementos subordinados mediante una llamada a Modifier.semantics (mergeDescendants = true) {}. Establecer esta propiedad en true indica que se deben combinar las propiedades semánticas. En nuestro ejemplo de Button, el elemento Button que admite composición usa de forma interna el modificador clickable que incluye este modificador semantics. Por lo tanto, se combinarán los nodos subordinados de Button. Lee la documentación de accesibilidad para obtener más información sobre cuándo debes cambiar el comportamiento de combinación en el elemento que admite composición.

Varios modificadores y elementos que admiten composición en las bibliotecas base y material de Compose tienen esta propiedad establecida. Por ejemplo, los modificadores clickable y toggleable combinarán automáticamente sus elementos subordinados. El elemento ListItem que admite composición también los combinará.

Cómo inspeccionar los árboles

Cuando hablamos sobre el árbol semántico, en realidad, nos referimos a dos árboles diferentes. Hay un árbol semántico combinado, que une los nodos subordinados cuando mergeDescendants se establece en true. También hay un árbol semántico separado, que no aplica la combinación, pero mantiene todos los nodos intactos. Los servicios de accesibilidad usan el árbol separado y aplican sus propios algoritmos de combinación en función de la propiedad mergeDescendants. El marco de trabajo de prueba usa el árbol combinado de forma predeterminada.

Puedes inspeccionar ambos árboles con el método printToLog(). De forma predeterminada, y como en los ejemplos anteriores, se registrará el árbol combinado. Para imprimir el árbol separado, establece el parámetro useUnmergedTree del comparador onRoot() en true:

composeTestRule.onRoot(useUnmergedTree = true).printToLog("MY TAG")

El Inspector de diseño te permite mostrar el árbol semántico combinado y el separado. Para ello, selecciona el que prefieras en el filtro de vista:

Figura 5: Opciones de vista del Inspector de diseño que permiten mostrar el árbol semántico combinado y el separado

Para cada nodo del árbol, el Inspector de diseño muestra la semántica combinada y la que se establece en ese nodo en el panel de propiedades:

De forma predeterminada, los comparadores en el marco de trabajo de prueba usan el árbol semántico combinado. Por este motivo, puedes interactuar con un elemento Button si haces coincidir el texto que se muestra dentro de este:

composeTestRule.onNodeWithText("Like").performClick()

Puedes anular este comportamiento si configuras el parámetro useUnmergedTree de los comparadores en true, como lo hicimos antes con el comparador onRoot.

Comportamiento de combinación

Cuando un elemento que admite composición indica que sus elementos subordinados deben combinarse, ¿cómo ocurre exactamente la combinación?

Cada propiedad semántica tiene una estrategia de combinación definida. Por ejemplo, la propiedad ContentDescription agrega todos los valores subordinados de ContentDescription a una lista. Puedes comprobar la estrategia de combinación de una propiedad semántica si verificas su implementación mergePolicy en SemanticsProperties.kt. Las propiedades pueden elegir siempre el valor superior o el secundario; combinar los valores en una lista o string; no permitir la combinación en absoluto y, en su lugar, arrojar una excepción; o cualquier otra estrategia de combinación personalizada.

Como nota importante, ten en cuenta que los elementos subordinados que establecieron mergeDescendants = true por su cuenta no se incluyen en la combinación. Veamos un ejemplo:

Figura 6: Elemento de lista con imagen, un poco de texto y un ícono de favoritos

Este es un elemento de lista en el que se puede hacer clic. Cuando el usuario presiona la fila, la app navega a la página de detalles del artículo, en la que el usuario puede leerlo. Dentro del elemento de lista, hay un botón para agregar este artículo a favoritos. En este caso, tenemos un elemento anidado en el que se puede hacer clic, por lo que el botón aparecerá por separado en el árbol combinado. Se combina el resto del contenido en la fila:

Figura 7: El árbol combinado incluye varios textos en una lista dentro del nodo Row El árbol separado incluye nodos que no están combinados para cada elemento Text que admite composición.

Cómo adaptar el árbol semántico

Como se mencionó antes, puedes anular o borrar propiedades semánticas determinadas, o cambiar el comportamiento de combinación del árbol. En particular, es relevante cuando creas tus propios componentes personalizados. Sin configurar las propiedades y el comportamiento de combinación correctos, es posible que no se pueda acceder a la app y que las pruebas se comporten de manera diferente a la esperada. Para obtener más información sobre algunos casos de uso frecuentes en los que debes adaptar el árbol semántico, consulta la documentación de accesibilidad. Si deseas obtener más información sobre las pruebas, consulta la guía de pruebas.