Melhorando acessibilidade de linhas e colunas em Flutter

Publicado em 27 jul 2020. Uns 7 minutos de leitura.

Flutter parte do princpípio de "tudo é um widget!", e isso inclui as primitivas de layout, como Row e Column. Com essas ferramentas bem simples, conseguimos criar vários tipos de layout. Porém, nem sempre a maneira com que algo é visualmente disposto bate com a ordem e agrupamento semântico. Estava fazendo um app em Flutter e, ao tentar usá-lo com o VoiceOver (leitor de tela do iPhone, usado normalmente por pessoas com deficiência visual), percebi que ele lia alguns conteúdos numa ordem ruim. Vamos ver o que precisei fazer para consertar isso — é mais fácil do que parece!

Eu gosto de criar projetos pessoais para estudar e experimentar novos conceitos, por isso comecei a fazer um simples app de Pokédex. Ele tem uma lista de Pokémon na página inicial e, ao tocar em um deles, vai para uma página com detalhes incluindo tipos, descrição, ataques e resistências e vulnerabilidades. Nestes últimos que vamos focar agora. A imagem abaixo mostra o exemplo da página de um Pokémon.

Screenshot do aplicativo de Pokédex, mostrando a página do Pokémon Venusaur. Nela há a imagem do Pokémon, a descrição da Pokédex, a geração que foi introduzido e duas listas, uma com os tipos a que ele é resistente e outra com os tipos a que ele é vulnerável. Estas listas estão lado a lado.

As listas de resistência e vulnerabilidade estão lado a lado. Mais precisamente, elas fazem parte de uma Row do Flutter. Cada lista dessa, por sua vez, é uma Column que contém o texto do cabeçalho cada tipo com seu multiplicador. O código é mais ou menos assim:

Row(
  children: [
    Expanded(
      child: Column(
        children: [
          Text('Resistant to'),
          TypeInteraction(PokemonType.grass, 0.25),
          TypeInteraction(PokemonType.water, 0.5),
          TypeInteraction(PokemonType.electric, 0.5),
          // ...
        ],
      ),
    ),
    Expanded(
      child: Column(
        children: [
          Text('Vulnerable to'),
          TypeInteraction(PokemonType.flying, 2),
          TypeInteraction(PokemonType.fire, 2),
          TypeInteraction(PokemonType.ice, 2),
          // ...
        ],
      ),
    ),
  ],
);

Um detalhe é que no código essa lista não está literal como no exemplo acima, mas é construída de acordo com cada Pokémon. Mas, para facilitar o entendimento, nos exemplos eu vou colocar esses widgets direto no código.

Há um widget a mais no exemplo acima que não expliquei, o TypeInteraction. Ele é mais uma Row, com o texto do nome do tipo (dentro de um Container para dar a cor de fundo), e outro texto que é o multiplicador em si. O build dele tem essa cara:

Row(
  children: [
    Container(
      color: pokemonType.color,
      child: Text(pokemonType.name),
    ),
    Text('×$multiplier')
  ],
);

Com esses widgets, fica fácil fazer layouts de colunas e linhas sem muita complicação. Dado o tamanho dos nomes de tipos, fica um espaçamento entre cada coluna e quem está vendo a tela consegue diferenciar a quais são os tipos esse Pokémon é resistente e a quais ele é vulnerável. Mas e quem não está vendo a tela?

Com essa tela pronta, ativei o VoiceOver no meu telefone para tentar navegar pelo app e consumir o conteúdo. Em geral, Flutter funciona bem e tem bons padrões que facilitam a vida de ferramentas de acessibilidade como o VoiceOver. Passei pela lista, escolhi um Pokémon, ouvi sua descrição, a geração em que ele foi introduzido… mas quando chegou na parte de resistência e vulnerabilidade, o leitor de tela do iOS não consumiu o conteúdo como eu achei que iria.

Neste vídeo eu mostro como o VoiceOver estava lendo essas listas. Para mudar o foco de um item para o próximo, eu deslizo com o dedo na direção direita ou esquerda, para ler o próximo ou o anterior. Cada vez que o foco mudar, um retângulo se move para onde o foco está e ele toca uma batida.

O que aconteceu aqui é que o VoiceOver está lendo o texto da esquerda para a direira, de cima para baixo, como normalmente (no ocidente) se faz. Isso fez ele ler primeiro os cabeçalhos das duas listas, e depois cada tipo e seu multiplicador, separadamente, ao invés de ler primeiro todas as resistências e depois todas as vulnerabilidades. Uma pessoa que não está vendo a tela não teria como saber quais os tipos que estão em cada coluna. Até dá para chutar que estão alternando entre resistências e vulnerabilidade pela ordem da leitura dos cabeçalhos, e pelos multiplicadores serem maiores ou menores que 1, mas não tem pra quê deixar esse processamento na cabeça de quem está usando o app.

Felizmente, Flutter tem, não surpreendentemente, widgets que podem nos ajudar. Neste caso, podemos usar o widget Semantics para transformar o que são apenas textos dispostos na tela de uma maneira visualmente agradável em itens com um significado que o leitor de tela compreende e respeita.

Meu primeiro pensamento foi: preciso agrupar todas as resistências e todas as vulnerabilidades. Para isso, usei o Semantics com o atributo container. Note que este atributo não é o mesmo que um widget Container, como o que usei para envolver o nome dos tipos e dar uma cor de fundo. No Semantics, o atributo container indica que os descendentes desse widget representam um nó semântico na navegação.

Row(
  children: [
    Expanded(
      child: Semantics(
        container: true,
        child: Column(
          children: [
            Text('Resistant to'),
            TypeInteraction(PokemonType.grass, 0.25),
            TypeInteraction(PokemonType.water, 0.5),
            TypeInteraction(PokemonType.electric, 0.5),
            // ...
          ],
        ),
      ),
    ),
    Expanded(
      child: Semantics(
        container: true,
        child: Column(
          children: [
            Text('Vulnerable to'),
            TypeInteraction(PokemonType.flying, 2),
            TypeInteraction(PokemonType.fire, 2),
            TypeInteraction(PokemonType.ice, 2),
            // ...
          ],
        ),
      ),
    ),
  ],
);

"É isso!", pensei eu levemente enganado. Adicionar este widget agrupou de fato cada coluna, porém o resultado não foi exatamente o que eu esperava…

O que aconteceu agora foi que o VoiceOver leu todos os Text que estavam dentro da Coluna de uma vez, exceto os que eram descendentes de um Container (estes continuaram separados). Então ele leu o título da coluna e todos os multiplicadores de uma vez só, e depois me permitiu passar por cada tipo (sem seu multiplicador que já foi lido). Digamos que "vezes dois vezes dois vezes dois vezes dois" não é exatamente o que eu queria que acontecesse aqui.

Dado que eu quero que o tipo e o multiplicador sejam relacionados, e lidos em conjunto, eu preciso também sinalizar que estes dois fazer um nó semântico. Ou seja, novamente usar o Semantics. Dessa vez ele vai envelopar o conteúdo do TypeInteraction:

Semantics(
  child: Row(
    children: [
      Container(
        color: pokemonType.color,
        child: Text(pokemonType.name),
      ),
      Text('×$multiplier')
    ],
  )
);

Agora sim. Cada coluna é um agrupamento semântico. Nela, há um Text que será lido quando a coluna estiver selecionada. Os outros textos existentes vão fazer parte e outros nós semânticos e não serão lidos quando o parente estiver selecionado. Ao deslizar para prosseguir, o VoiceOver leu cada tipo e multiplicador separadamente, em ordem. Ao terminar uma coluna, o foco passou para a próxima e ele fez a mesma coisa.

Para este problemada da ordem de leitura, isso foi suficiente, mas o widget de Semantics faz muito mais do que isso e tem dezenas de atributos além do container que você pode conferir na documentação do widget Semantics no flutter.dev. Com pouca adição ao código, o conteúdo agora pode ser consumido mais facilmente por quem utiliza um leitor de tela como o VoiceOver.