Acessibilidade no Compose

Apps programados no Compose precisam oferecer acessibilidade para usuários com diferentes necessidades. Os serviços de acessibilidade são usados para transformar o que é mostrado na tela em um formato mais adequado para usuários com uma necessidade específica. Para que um app seja compatível com serviços de acessibilidade, é necessário usar APIs no framework do Android para expor informações semânticas sobre os elementos da IU. Em seguida, o framework do Android transmitirá essas informações semânticas aos serviços de acessibilidade; Cada serviço de acessibilidade pode escolher a melhor forma de descrever o app para o usuário. O Android oferece diversos serviços de acessibilidade, incluindo o Talkback e o acesso com interruptor.

Semântica

O Compose usa propriedades semânticas para transmitir informações aos serviços de acessibilidade. As propriedades semânticas fornecem informações sobre elementos da IU que são exibidos ao usuário. A maioria dos elementos combináveis integrados, como Text e Button preenchem essas propriedades semânticas com informações relacionadas ao elemento e aos filhos dele. Alguns modificadores, como toggleable e clickable, também definem algumas propriedades de semântica. No entanto, às vezes o framework precisa de mais informações para entender como descrever um elemento de IU para o usuário.

Este documento descreve várias situações em que é necessário adicionar informações complementares, de forma explícita a um elemento combinável para que ele possa ser descrito corretamente para o framework do Android. Ele também explica como substituir completamente as informações de semântica por um elemento combinável específico. É necessário ter conhecimento básico sobre a acessibilidade no Android.

Casos de uso comuns

Para ajudar pessoas com necessidades de acessibilidade a usar seu app, é necessário seguir as práticas recomendadas descritas nesta página.

Considerar os tamanhos mínimos de área de toque

Todos os elementos mostrados na tela que possam ser clicados, tocados ou com os quais é possível interagir de alguma outra forma precisam ser grandes o suficiente para uma interação confiável. Ao dimensionar esses elementos, defina o tamanho mínimo como 48 dp para seguir corretamente as Diretrizes de acessibilidade do Material Design (link em inglês).

Os componentes do Material Design (como Checkbox, RadioButton, Switch, Slider e Surface) definem esse tamanho mínimo internamente, mas apenas quando o componente pode receber ações do usuário. Por exemplo, quando um Checkbox tem o parâmetro onCheckedChange definido como um valor não nulo, ele inclui o padding com uma largura e uma altura de pelo menos 48 dp.

@Composable
private fun CheckableCheckbox() {
    Checkbox(checked = true, onCheckedChange = {})
}

Quando o parâmetro onCheckedChange é definido como nulo, o padding não é incluído, porque não é possível interagir com o componente de forma direta.

@Composable
private fun NonClickableCheckbox() {
    Checkbox(checked = true, onCheckedChange = null)
}

Ao implementar controles de seleção, como Switch, RadioButton ou Checkbox, você normalmente aumenta o comportamento clicável para um contêiner pai, define o callback de clique no elemento combinável como null e adiciona um modificador toggleable ou selectable ao elemento pai.

@Composable
private fun CheckableRow() {
    MaterialTheme {
        var checked by remember { mutableStateOf(false) }
        Row(
            Modifier
                .toggleable(
                    value = checked,
                    role = Role.Checkbox,
                    onValueChange = { checked = !checked }
                )
                .padding(16.dp)
                .fillMaxWidth()
        ) {
            Text("Option", Modifier.weight(1f))
            Checkbox(checked = checked, onCheckedChange = null)
        }
    }
}

Quando o tamanho de um elemento clicável é menor que o tamanho mínimo da área de toque, o Compose aumenta o tamanho dessa área. Isso é feito expandindo o tamanho da área de toque para fora dos limites do elemento combinável.

No exemplo a seguir, criamos um elemento Box clicável muito pequeno. A área de toque é expandida automaticamente para além dos limites do elemento Box. Então, tocar ao lado de Box ainda acionará o evento de clique.

@Composable
private fun SmallBox() {
    var clicked by remember { mutableStateOf(false) }
    Box(
        Modifier
            .size(100.dp)
            .background(if (clicked) Color.DarkGray else Color.LightGray)
    ) {
        Box(
            Modifier
                .align(Alignment.Center)
                .clickable { clicked = !clicked }
                .background(Color.Black)
                .size(1.dp)
        )
    }
}

Para evitar possíveis sobreposições entre áreas de toque de diferentes elementos e composição é importante sempre usar um tamanho mínimo grande o suficiente. No nosso exemplo, isso significa usar o modificador sizeIn para definir o tamanho mínimo da caixa interna:

@Composable
private fun LargeBox() {
    var clicked by remember { mutableStateOf(false) }
    Box(
        Modifier
            .size(100.dp)
            .background(if (clicked) Color.DarkGray else Color.LightGray)
    ) {
        Box(
            Modifier
                .align(Alignment.Center)
                .clickable { clicked = !clicked }
                .background(Color.Black)
                .sizeIn(minWidth = 48.dp, minHeight = 48.dp)
        )
    }
}

Adicionar etiquetas de clique

Você pode usar uma etiqueta de clique para adicionar um significado semântico ao comportamento de clique de uma função de composição. Os rótulos de clique descrevem o que acontece quando o usuário interage com o elemento de composição. Os serviços de acessibilidade usam rótulos de clique para ajudar a descrever o app para usuários com necessidades específicas.

Defina a etiqueta de clique transmitindo um parâmetro no modificador clickable:

@Composable
private fun ArticleListItem(openArticle: () -> Unit) {
    Row(
        Modifier.clickable(
            // R.string.action_read_article = "read article"
            onClickLabel = stringResource(R.string.action_read_article),
            onClick = openArticle
        )
    ) {
        // ..
    }
}

Como alternativa, se você não puder acessar o modificador "clickable", é possível definir a etiqueta de clique no modificador de semântica:

@Composable
private fun LowLevelClickLabel(openArticle: () -> Boolean) {
    // R.string.action_read_article = "read article"
    val readArticleLabel = stringResource(R.string.action_read_article)
    Canvas(
        Modifier.semantics {
            onClick(label = readArticleLabel, action = openArticle)
        }
    ) {
        // ..
    }
}

Descrever elementos visuais

Quando você define um elemento combinável Image ou Icon, não há como o framework do Android entender de forma automática o que está sendo mostrado. É necessário fornecer uma descrição textual do elemento visual.

Imagine uma tela em que o usuário pode compartilhar a página atual com amigos. Essa tela contém um ícone de compartilhamento clicável:

Uma faixa de ícones clicáveis, com o

Tendo somente o ícone como base, o framework do Android não consegue encontrar uma forma de descrevê-lo para um usuário com deficiência visual. O framework do Android precisa de uma descrição textual complementar do ícone.

O parâmetro contentDescription é usado para descrever um elemento visual. Use uma string localizada, porque isso será comunicada ao usuário.

@Composable
private fun ShareButton(onClick: () -> Unit) {
    IconButton(onClick = onClick) {
        Icon(
            imageVector = Icons.Filled.Share,
            contentDescription = stringResource(R.string.label_share)
        )
    }
}

Alguns elementos visuais são apenas decorativos, e você pode optar por não os descrever ao usuário. Ao definir o parâmetro contentDescription como null, você indica ao framework do Android que esse elemento não contém ações ou estados associados.

@Composable
private fun PostImage(post: Post, modifier: Modifier = Modifier) {
    val image = post.imageThumb ?: painterResource(R.drawable.placeholder_1_1)

    Image(
        painter = image,
        // Specify that this image has no semantic meaning
        contentDescription = null,
        modifier = modifier
            .size(40.dp, 40.dp)
            .clip(MaterialTheme.shapes.small)
    )
}

Cabe a você decidir se um determinado elemento visual precisa de uma contentDescription. Analise se o elemento transmite informações necessárias para que o usuário realize uma tarefa. Se esse não for o caso, é melhor não incluir uma descrição.

Mesclar elementos

Os serviços de acessibilidade, como o TalkBack e o acesso com interruptor, permitem que os usuários coloquem o foco em diferentes elementos na tela. É importante que o foco nos elementos tenha a granularidade correta. Se cada função de baixo nível na tela tiver um foco independente, o usuário precisará fazer muitas interações para navegar pela tela. Se os elementos forem combinados de forma exagerada, os usuários podem não compreender quais elementos estão agrupados.

Ao aplicar um modificador clickable a um elemento combinável, o Compose mescla automaticamente todos os elementos que ele contém. Isso também funciona para ListItem. Os elementos de um item de lista vão ser mesclados, e os serviços de acessibilidade verão um único elemento.

É possível definir um conjunto de funções que podem ser compostas que formem um grupo lógico, mas esse grupo não será clicável nem fará parte de um item de lista. Ainda seria preferível que os serviços de acessibilidade vissem esses elementos como um só. Por exemplo, imagine um elemento combinável que mostre o avatar de um usuário, o nome e algumas informações complementares:

Um grupo de elementos da IU, incluindo o nome de um usuário. O nome está selecionado.

Você pode instruir o Compose a mesclar esses elementos usando o parâmetro mergeDescendants no modificador semantics. Dessa forma, os serviços de acessibilidade selecionarão apenas o elemento mesclado, e todas as propriedades semânticas dos descendentes serão mescladas.

@Composable
private fun PostMetadata(metadata: Metadata) {
    // Merge elements below for accessibility purposes
    Row(modifier = Modifier.semantics(mergeDescendants = true) {}) {
        Image(
            imageVector = Icons.Filled.AccountCircle,
            contentDescription = null // decorative
        )
        Column {
            Text(metadata.author.name)
            Text("${metadata.date} • ${metadata.readTimeMinutes} min read")
        }
    }
}

Agora, os serviços de acessibilidade terão como foco o contêiner todo, mesclando os conteúdos:

Um grupo de elementos da IU, incluindo o nome de um usuário. Todos os elementos estão selecionados juntos.

Adicionar ações personalizadas

Veja o seguinte item de lista:

Item de lista típico, contendo um título de artigo, autor e ícone "adicionar aos favoritos".

Quando você usa um leitor de tela, como o Talkback, para ouvir o que é exibido na tela, ele primeiro selecionará o item todo e, depois, o ícone "adicionar aos favoritos".

Item de lista, com todos os elementos selecionados juntos.

Item de lista, com apenas o ícone "adicionar aos favoritos" selecionado.

Em uma lista longa, isso pode ficar muito repetitivo. Uma abordagem melhor seria definir uma ação personalizada que permita que o usuário adicione o item aos favoritos. Lembre-se de que também será necessário remover explicitamente o comportamento do ícone "adicionar aos favoritos" para garantir que ele não será selecionado pelo serviço de acessibilidade. Isso pode ser mais bem feito com o clearAndSetSemantics :

@Composable
private fun PostCardSimple(
    /* ... */
    isFavorite: Boolean,
    onToggleFavorite: () -> Boolean
) {
    val actionLabel = stringResource(
        if (isFavorite) R.string.unfavorite else R.string.favorite
    )
    Row(
        modifier = Modifier
            .clickable(onClick = { /* ... */ })
            .semantics {
                // Set any explicit semantic properties
                customActions = listOf(
                    CustomAccessibilityAction(actionLabel, onToggleFavorite)
                )
            }
    ) {
        /* ... */
        BookmarkButton(
            isBookmarked = isFavorite,
            onClick = onToggleFavorite,
            // Clear any semantics properties set on this node
            modifier = Modifier.clearAndSetSemantics { }
        )
    }
}

Descrever o estado de um elemento

Um elemento combinável pode definir uma stateDescription como semântica, que é usada pelo framework do Android para ler o estado em que o elemento se encontra. Por exemplo, uma função alternável pode se encontrar no estado "Marcado" ou "Desmarcado". Em alguns casos, convém substituir as etiquetas padrão de descrição de estado usados pelo Compose. Isso pode ser feito especificando explicitamente as etiquetas de descrição de estado, antes de definir a função como alternável:

@Composable
private fun TopicItem(itemTitle: String, selected: Boolean, onToggle: () -> Unit) {
    val stateSubscribed = stringResource(R.string.subscribed)
    val stateNotSubscribed = stringResource(R.string.not_subscribed)
    Row(
        modifier = Modifier
            .semantics {
                // Set any explicit semantic properties
                stateDescription = if (selected) stateSubscribed else stateNotSubscribed
            }
            .toggleable(
                value = selected,
                onValueChange = { onToggle() }
            )
    ) {
        /* ... */
    }
}

Definir cabeçalhos

Algumas vezes, apps exibem muito conteúdo em uma tela, em um contêiner de rolagem. Por exemplo, uma tela pode mostrar todo o conteúdo de um artigo que o usuário está lendo:

Captura de tela de uma postagem de blog, com o texto de um artigo em um contêiner de rolagem.

Para usuários com necessidades de acessibilidade, será difícil navegar nessa tela. Para auxiliar na navegação, indique quais elementos são os cabeçalhos. No exemplo acima, cada título de subseção poderia ser definido como um cabeçalho para fins de acessibilidade. Alguns serviços de acessibilidade, como o Talkback, permitem que os usuários naveguem diretamente de cabeçalho a cabeçalho.

No Compose, para indicar que uma função que pode ser composta é um cabeçalho, defina a propriedade semântica:

@Composable
private fun Subsection(text: String) {
    Text(
        text = text,
        style = MaterialTheme.typography.headlineSmall,
        modifier = Modifier.semantics { heading() }
    )
}

Teste automatizado de propriedades de acessibilidade

Ao personalizar as propriedades semânticas do seu app, por exemplo, ao seguir os casos de uso listados acima, é possível verificar a exatidão e evitar regressões usando testes automatizados de interface.

Por exemplo, para testar se o rótulo de clique de um elemento está definido corretamente, use o seguinte código:

@Test
fun test() {
    composeTestRule
        .onNode(nodeMatcher)
        .assert(
            SemanticsMatcher("onClickLabel is set correctly") {
                it.config.getOrNull(SemanticsActions.OnClick)?.label == "My Click Label"
            }
        )
}

Como criar funções de composição personalizadas de baixo nível

Um caso de uso mais avançado envolve a substituição de determinados componentes do Material Design em versões personalizadas do app. É essencial considerar as necessidades de acessibilidade nesse caso. Digamos que você esteja substituindo a Checkbox do Material Design por uma implementação própria. Você poderia facilmente se esquecer de adicionar o modificador triStateToggleable, que processa as propriedades de acessibilidade desse componente.

Como regra geral, é necessário analisar a implementação do componente na biblioteca do Material Design e simular qualquer comportamento de acessibilidade que possa ser encontrado. Além disso, use os modificadores do Compose Foundation, e não modificadores no nível da IU, porque eles incluem considerações de acessibilidade prontas. Teste a implementação do componente personalizado com vários serviços de acessibilidade para verificar o comportamento.

Modificar a ordem de apresentação com isTraversalGroup e traversalIndex

Por padrão, o comportamento do leitor de tela de acessibilidade em um app do Compose é implementado na ordem de leitura esperada, que geralmente é da esquerda para a direita e depois de cima para baixo. No entanto, existem alguns tipos de layouts de apps em que o algoritmo não consegue determinar a ordem de leitura real sem mais dicas. Em apps baseados em visualização, é possível corrigir esses problemas usando as propriedades traversalBefore e traversalAfter. A partir do Compose 1.5, ele oferece uma API igualmente flexível, mas com um novo modelo conceitual.

isTraversalGroup e traversalIndex são propriedades semânticas que permitem controlar a acessibilidade e a ordem de foco do TalkBack em cenários em que o algoritmo de classificação padrão não é apropriado. isTraversalGroup identifica grupos semanticamente importantes, enquanto traversalIndex ajusta a ordem de elementos individuais nesses grupos. Você pode usar isTraversalGroup sozinho ou com traversalIndex para personalização adicional.

Esta página descreve como usar isTraversalGroup e traversalIndex no seu app para controlar a ordem de travessia do leitor de tela.

Agrupar elementos com isTraversalGroup

isTraversalGroup é uma propriedade booleana que define se um nó semântica é um grupo de travessia. A função desse tipo de nó é servir como limite ou fronteira na organização dos filhos.

Definir isTraversalGroup = true em um nó significa que todos os filhos desse nó são visitados antes da mudança para outros elementos. Você pode definir isTraversalGroup em nós focalizáveis que não sejam leitores de tela, como colunas, linhas ou caixas.

Neste exemplo, um snippet é modificado para usar isTraversalGroup. O snippet abaixo emite quatro elementos de texto. Os dois elementos à esquerda pertencem a um elemento CardBox, enquanto os dois elementos à direita pertencem a outro elemento CardBox:

// CardBox() function takes in top and bottom sample text.
@Composable
fun CardBox(
    topSampleText: String,
    bottomSampleText: String,
    modifier: Modifier = Modifier
) {
    Box(modifier) {
        Column {
            Text(topSampleText)
            Text(bottomSampleText)
        }
    }
}

@Composable
fun TraversalGroupDemo() {
    val topSampleText1 = "This sentence is in "
    val bottomSampleText1 = "the left column."
    val topSampleText2 = "This sentence is "
    val bottomSampleText2 = "on the right."
    Row {
        CardBox(
            topSampleText1,
            bottomSampleText1
        )
        CardBox(
            topSampleText2,
            bottomSampleText2
        )
    }
}

O código produz uma saída semelhante a esta:

Layout com duas colunas de texto, com a coluna esquerda lendo "Esta frase está na coluna esquerda" e a coluna direita lendo "Esta frase está à direita".
Figura 1. Um layout com duas frases (uma na coluna da esquerda e outra na coluna da direita).

Como nenhuma semântica foi definida, o comportamento padrão do leitor de tela é percorrer elementos da esquerda para a direita e de cima para baixo. Devido a esse padrão, o TalkBack lê os fragmentos da frase na ordem errada:

"Esta frase está em" → "Esta frase é" → "a coluna da esquerda". → "à direita".

Para ordenar os fragmentos corretamente, modifique o snippet original e defina isTraversalGroup como true:

@Composable
fun TraversalGroupDemo2() {
    val topSampleText1 = "This sentence is in "
    val bottomSampleText1 = "the left column."
    val topSampleText2 = "This sentence is"
    val bottomSampleText2 = "on the right."
    Row {
        CardBox(
//      1,
            topSampleText1,
            bottomSampleText1,
            Modifier.semantics { isTraversalGroup = true }
        )
        CardBox(
//      2,
            topSampleText2,
            bottomSampleText2,
            Modifier.semantics { isTraversalGroup = true }
        )
    }
}

Como isTraversalGroup é definido especificamente em cada CardBox, os limites de CardBox são respeitados ao classificar os elementos. Nesse caso, o CardBox da esquerda é lido primeiro, seguido do CardBox à direita.

Agora, o TalkBack lê os fragmentos de frases na ordem correta:

"Esta frase está em" → "a coluna da esquerda". → "Esta frase está" → "à direita".

Personalizar ainda mais a ordem de apresentação com traversalIndex

traversalIndex é uma propriedade flutuante que permite personalizar a ordem de travessia do TalkBack. Se apenas agrupar elementos não for suficiente para o TalkBack funcionar corretamente, você pode usar traversalIndex com isTraversalGroup para personalizar ainda mais a ordem do leitor de tela.

A propriedade traversalIndex tem as seguintes características:

  • Elementos com valores traversalIndex menores têm prioridade.
  • Pode ser positivo ou negativo.
  • O valor padrão é 0f.
  • Afeta apenas os nós focalizáveis do leitor de tela, por exemplo, elementos na tela, como texto ou botões. Por exemplo, definir apenas traversalIndex em uma coluna não terá efeito, a menos que a coluna tenha isTraversalGroup definido também.

O exemplo abaixo mostra como usar traversalIndex e isTraversalGroup juntos.

Exemplo: exibição da aparência do relógio

Um mostrador de relógio é um cenário comum em que a ordem de travessia padrão não funciona. O exemplo nesta seção é baseado em um seletor de horário, em que um usuário pode percorrer os números em um mostrador de relógio e selecionar dígitos para os slots de hora e minuto.

Um mostrador de relógio com um seletor de horário acima dele.
Figura 2. Imagem de um mostrador de relógio.

No snippet simplificado abaixo, há uma CircularLayout em que 12 números são desenhados, começando com 12 e se movendo no sentido horário ao redor do círculo:

@Composable
fun ClockFaceDemo() {
    CircularLayout {
        repeat(12) { hour ->
            ClockText(hour)
        }
    }
}

@Composable
private fun ClockText(value: Int) {
    Box(modifier = Modifier) {
        Text((if (value == 0) 12 else value).toString())
    }
}

Como o mostrador de relógio não é lido logicamente com a ordem padrão "da esquerda para a direita" e de cima para baixo, o TalkBack lê os números fora de ordem. Para corrigir isso, use o valor do contador de incremento, conforme mostrado no snippet a seguir:

@Composable
fun ClockFaceDemo() {
    CircularLayout(Modifier.semantics { isTraversalGroup = true }) {
        repeat(12) { hour ->
            ClockText(hour)
        }
    }
}

@Composable
private fun ClockText(value: Int) {
    Box(modifier = Modifier.semantics { this.traversalIndex = value.toFloat() }) {
        Text((if (value == 0) 12 else value).toString())
    }
}

Para definir a ordem de travessia corretamente, primeiro torne o CircularLayout um grupo de travessia e defina isTraversalGroup = true. Em seguida, à medida que cada texto de relógio é desenhado no layout, defina o traversalIndex correspondente como o valor do contador.

Como o valor do contador aumenta continuamente, o traversalIndex de cada valor do relógio é maior à medida que os números são adicionados à tela. O valor do relógio 0 tem um traversalIndex de 0, o valor do relógio 1 tem um traversalIndex de 1 e assim por diante. Dessa forma, a ordem em que o TalkBack os lerá é definida. Agora, os números dentro de CircularLayout são lidos na ordem esperada.

Como os traversalIndexes definidos são relativos apenas a outros índices no mesmo agrupamento, o restante da ordem da tela foi preservado. Em outras palavras, as mudanças semânticas mostradas no snippet de código acima modificam apenas a ordem no mostrador de relógio que tem isTraversalGroup = true definido.

Observe que, sem definir a semântica CircularLayout's como isTraversalGroup = true, as mudanças de traversalIndex ainda se aplicam. No entanto, sem o CircularLayout para vinculá-los, os 12 dígitos do mostrador de relógio serão lidos por último, depois que todos os outros elementos na tela forem acessados. Isso ocorre porque todos os outros elementos têm um traversalIndex padrão de 0f, e os elementos de texto do relógio são lidos depois de todos os outros elementos 0f.

Exemplo: personalizar a ordem de travessia do botão de ação flutuante

Neste exemplo, você usa traversalIndex e isTraversalGroup para controlar a ordem de apresentação de um botão de ação flutuante (FAB, na sigla em inglês) do Material Design. Este exemplo é baseado no seguinte layout:

Um layout com uma barra de apps superior, texto de exemplo, um botão de ação flutuante e
  uma barra de apps na parte de baixo.
Figura 3. Layout com uma barra de apps superior, texto de exemplo, um botão de ação flutuante e uma barra de apps na parte de baixo.

Por padrão, o layout acima tem a seguinte ordem no TalkBack:

Barra de apps superior → Textos de exemplo 0 a 6 → botão de ação flutuante (FAB) → Barra de apps inferior

Você pode querer que o leitor de tela foque primeiro o FAB. Para definir um traversalIndex em um elemento do Material Design, como um FAB, faça o seguinte:

@Composable
fun FloatingBox() {
    Box(modifier = Modifier.semantics { isTraversalGroup = true; traversalIndex = -1f }) {
        FloatingActionButton(onClick = {}) {
            Icon(imageVector = Icons.Default.Add, contentDescription = "fab icon")
        }
    }
}

Nesse snippet, criar uma caixa com isTraversalGroup definido como true e definir um traversalIndex na mesma caixa (-1f é menor que o valor padrão de 0f) significa que a caixa flutuante vem antes de todos os outros elementos na tela.

Em seguida, coloque a caixa flutuante e outros elementos em um scaffold, que implementa um layout simples do Material Design:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ColumnWithFABFirstDemo() {
    Scaffold(
        topBar = { TopAppBar(title = { Text("Top App Bar") }) },
        floatingActionButtonPosition = FabPosition.End,
        floatingActionButton = { FloatingBox() },
        content = { padding -> ContentColumn(padding = padding) },
        bottomBar = { BottomAppBar { Text("Bottom App Bar") } }
    )
}

O TalkBack interage com os elementos na seguinte ordem:

FAB → Barra de apps superior → Textos de exemplo de 0 a 6 → Barra de apps inferior

Saiba mais

Para saber mais sobre o suporte à acessibilidade no seu código do Compose, consulte o codelab Acessibilidade no Jetpack Compose.