Dia 14 — Acessibilidade e ajuste de tela
Rótulo, papel e leitor de tela
Rótulo, papel e leitor de tela
O leitor de tela lê a tela em voz alta, e ele não lê o que está desenhado: lê o
que foi declarado. Um botão só com ícone não tem texto nenhum para ler, e
sem declarado o leitor anuncia o nome do componente — algo como "botão" sem
dizer o que o botão faz. É por isso que as duas propriedades que importam são
accessibilityLabel, o texto que será lido, e accessibilityRole, o que o
elemento é.
<Pressable accessibilityRole="button" accessibilityLabel="remover pedido 41"> <Image source={lixeira} /> </Pressable>
O exemplo desta página monta os dois botões e deixa a diferença visível em duas
linhas. O sem rótulo responde "(nenhum)" para o papel e "(nenhum)" para o
rótulo — e o console traduz o que o leitor faz com isso: anuncia "botao" e
não diz o que faz. O declarado responde button para o papel, "remover pedido
p41" para o rótulo e a dica para o accessibilityHint.
accessibilityHint é a segunda frase: o rótulo diz o que é e a dica diz
o que acontece. "Remover" e "o registro será apagado desta lista". Os dois
juntos dão um botão que faz sentido ouvido sem a tela; só o rótulo deixa a
dúvida do que vai acontecer. A regra de ouro é que rótulo e dica juntos devem
fazer sentido sem a tela — se a frase "remover pedido 41" só tem sentido porque
você vê a lista, o rótulo precisa melhorar.
accessibilityRole tem os valores do componente: 'button', 'header',
'image', 'link', 'checkbox', 'textbox', 'adjustable', 'search',
'imagebutton'. O papel influencia a forma como o leitor anuncia e a forma como
a navegação por varredura trata o elemento. Deixar o papel fora faz o leitor
tratar um botão como texto, e o usuário não sabe que dá para apertar.
O accessibilityState do exemplo fecha a lista de propriedades com o caso que
mais aparece em botão de ação: com desabilitado verdadeiro, o estado sai como
{ disabled: true } e o leitor anuncia "desabilitado" em vez de repetir o
rótulo. É a mesma ideia da aula de Pressable — desabilitar não muda a
aparência, e sem declarar o estado o leitor continuaria anunciando o botão como
se estivesse disponível.
agrupar e anunciar
accessible numa View diz ao leitor para tratar o conjunto como um item
só, lendo o rótulo declarado e pulando os filhos. É o que resolve o card com
título, subtítulo e botão: sem agrupar, o leitor lê três trechos e o usuário não
sabe que é um registro. Com accessible e accessibilityLabel, o card é lido
como uma frase.
O exemplo monta o card agrupado e o rótulo sai inteiro: "Pedido 41, dois itens,
90 reais". Uma frase só, e a linha seguinte diz o que acontecia sem agrupar — o
leitor leria título, subtítulo e o botão como três coisas soltas. A diferença é
quantidade de anúncios, e é por isso que o agrupamento importa em lista: trinta
cards lidos como três coisas cada são noventa anúncios.
accessibilityState descreve o estado: { disabled: true }, { selected: true },
{ checked: true }. É o que faz o leitor anunciar "marcado" em vez de repetir o
rótulo. E AccessibilityInfo.announceForAccessibility é o aviso momentâneo: a
mensagem que o app manda por conta própria, como "salvo", quando o foco está em
outro elemento e não há nada que o leitor anuncie sozinho.
Os dois anúncios do exemplo — "salvo" e "removido, 1 registro" — são o caso em
que o app fala sem o usuário pedir. O aviso vai para o leitor e não aparece na
tela: quem não usa leitor de tela não vê nada, e quem usa recebe a confirmação
sem precisar procurar o que mudou.
O defeito de acessibilidade mais comum não é falta de rótulo: é o elemento que
recebe o toque e não é o que parece receber. Envolver o Pressable em uma View
grande demais faz o toque funcionar longe do que o usuário mirou — e o leitor
de tela lê o elemento errado, porque o toque é dele.
O exemplo mede esse defeito: a área que recebe o toque é `{ flex: 1, padding:
12 }, e é essa caixa que ocupa a linha inteira. O console` explica as duas
consequências em uma linha cada — o toque funciona longe do texto, e o leitor lê
a área, não o texto. Nenhuma das duas é problema de acessibilidade: são problema
de layout, com efeito em quem usa o leitor.
Exemplo
// Acessibilidade e o que o leitor de tela anuncia: `accessibilityLabel` // (o que e), `accessibilityHint` (o que acontece) e `accessibilityRole` (o // que o elemento e). O exemplo monta os dois casos: rotulado e nao. const { View, Text, Image, Pressable, StyleSheet } = require('react-native'); const estilos = StyleSheet.create({ linha: { flexDirection: 'row', padding: 12 } }); // (1) botao com icone: sem rotulo, o leitor anuncia so "botao". function BotaoSemRotulo(props) { return ( <Pressable onPress={props.aoTocar}> <Image source={{ uri: 'icones/lixeira.png' }} style={{ width: 20, height: 20 }} /> </Pressable> ); } // (2) o mesmo botao, declarado: o leitor diz o que e e o que acontece. function BotaoRotulado(props) { return ( <Pressable onPress={props.aoTocar} accessibilityRole="button" accessibilityLabel={'remover pedido ' + props.pedido.id} accessibilityHint="o registro sera apagado desta lista" accessibilityState={{ disabled: props.desabilitado }} > <Image source={{ uri: 'icones/lixeira.png' }} style={{ width: 20, height: 20 }} /> </Pressable> ); } const semRotulo = BotaoSemRotulo({ aoTocar: () => {} }); const rotulado = BotaoRotulado({ pedido: { id: 'p41' }, aoTocar: () => {}, desabilitado: false }); console.log('sem rotulo, o leitor anuncia apenas:'); console.log(' papel:', semRotulo.props.accessibilityRole === undefined ? '(nenhum) -> "botao"' : semRotulo.props.accessibilityRole); console.log(' rotulo:', semRotulo.props.accessibilityLabel === undefined ? '(nenhum) -> nao diz o que faz' : semRotulo.props.accessibilityLabel); console.log('declarado, o leitor anuncia:'); console.log(' papel:', rotulado.props.accessibilityRole); console.log(' rotulo:', rotulado.props.accessibilityLabel); console.log(' dica:', rotulado.props.accessibilityHint); console.log(' estado:', rotulado.props.accessibilityState); // (3) o estado declarado e o que o leitor anuncia como "marcado". const marcado = BotaoRotulado({ pedido: { id: 'p41' }, aoTocar: () => {}, desabilitado: true }); console.log('\nquando desabilitado:', marcado.props.accessibilityState); console.log('o leitor anuncia "desabilitado", e nao repete o rotulo'); // (4) agrupar: `accessible` faz o leitor tratar a `View` como um item so. function CardeAgrupado(props) { return ( <View accessible accessibilityLabel={props.rotulo} accessibilityRole="summary"> <Text>{props.titulo}</Text> <Text>{props.subtitulo}</Text> </View> ); } const card = CardeAgrupado({ titulo: 'Pedido 41', subtitulo: 'dois itens', rotulo: 'Pedido 41, dois itens, 90 reais' }); console.log('\ncard agrupado, rotulo lido:', card.props.accessibilityLabel); console.log('o leitor le o rotulo e pula os filhos'); console.log('sem agrupar, leria titulo, subtitulo e o botao, como tres coisas soltas'); // (5) o aviso que o leitor de tela fala por conta propria. function anuncia(mensagem) { console.log(' anuncio:', mensagem); } anuncia('salvo'); anuncia('removido, 1 registro'); console.log('o aviso vai para o leitor, e nao aparece na tela'); // (6) o defeito mais comum: a `View` grande que rouba o toque. function ItemComAreaInvisivel(props) { return ( <View style={{ flex: 1, padding: 12 }}> <Text>{props.rotulo}</Text> </View> ); } const area = ItemComAreaInvisivel({ rotulo: 'Pedido 41' }); const estilo = StyleSheet.flatten(area.props.style); console.log('\narea que recebe o toque:', JSON.stringify(estilo)); console.log('`flex: 1` faz a caixa ocupar a linha inteira, e o toque funciona longe do texto'); console.log('o leitor le o elemento que esta embaixo do dedo, que e a area, nao o texto'); // (7) a arvore completa, com o que o leitor encontra em cada no. function LinhaDePedido(props) { return ( <View style={estilos.linha}> <Text>{props.pedido.cliente}</Text> <BotaoRotulado pedido={props.pedido} aoTocar={props.aoTocar} /> </View> ); } const arvore = LinhaDePedido({ pedido: { id: 'p41', cliente: 'ana' }, aoTocar: () => {} }); console.log('\nno da linha:', arvore.type); const botaoDaLinha = arvore.props.children[1]; console.log('papel do botao:', botaoDaLinha.props.accessibilityRole, '| rotulo:', botaoDaLinha.props.accessibilityLabel); console.log('o papel muda a forma como o leitor anuncia e a varredura trata o elemento');
Saída real
sem rotulo, o leitor anuncia apenas:
papel: (nenhum) -> "botao"
rotulo: (nenhum) -> nao diz o que faz
declarado, o leitor anuncia:
papel: button
rotulo: remover pedido p41
dica: o registro sera apagado desta lista
estado: { disabled: false }
quando desabilitado: { disabled: true }
o leitor anuncia "desabilitado", e nao repete o rotulo
card agrupado, rotulo lido: Pedido 41, dois itens, 90 reais
o leitor le o rotulo e pula os filhos
sem agrupar, leria titulo, subtitulo e o botao, como tres coisas soltas
anuncio: salvo
anuncio: removido, 1 registro
o aviso vai para o leitor, e nao aparece na tela
area que recebe o toque: {"flex":1,"padding":12}
`flex: 1` faz a caixa ocupar a linha inteira, e o toque funciona longe do texto
o leitor le o elemento que esta embaixo do dedo, que e a area, nao o texto
no da linha: View
papel do botao: button | rotulo: remover pedido p41
o papel muda a forma como o leitor anuncia e a varredura trata o elemento
Fonte, safe area e ajuste por tamanho
Fonte, safe area e ajuste por tamanho
O celular mostra o texto do app na fonte que o usuário escolheu nas
configurações do aparelho. allowFontScaling controla isso, e o padrão é
true, que é a decisão certa — desligar é uma das formas mais fáceis de
deixar o app impossível para quem enxerga pouco. Quando é preciso limitar, o
limite se declara por elemento com maxFontSizeMultiplier, não desligando o
todo: um layout que quebra com fonte muito grande é problema de layout, e a
correção é deixar o container crescer.
O exemplo desta página mede a fonte em três pontos. Com a fonte do sistema em
1.0, um fontSize: 15 resulta em 15. Com a fonte em 1.5, o mesmo elemento
resulta em 23. Com a fonte em 2.0 e o limite 1.5 por elemento, o resultado
é 45 — menor do que 30 dobrado, e é o limite fazendo o trabalho dele.
A conta do exemplo multiplica 15 pela fonte do sistema e pelo multiplicador do
elemento, e é a fórmula que o aparelho aplica. O que a aula muda é qual dos dois
fatores pertence a quem: a fonte do sistema é decisão do usuário e não se
negocia; o multiplicador do elemento é decisão do app. É por isso que o
allowFontScaling fica ligado e o limite vai no maxFontSizeMultiplier.
SafeAreaView é o componente que mantém o conteúdo longe das bordas onde o
sistema desenha coisa por cima: a barra de status, o entalhe da câmera, a barra
de gestos no rodapé. Em aparelho sem entalhe ele não faz diferença; em aparelho
com entalhe, é o que impede o primeiro item de ficar embaixo da câmera.
A árvore do exemplo tem SafeAreaView como raiz, com o estilo `{ flex: 1,
padding: 16 } e dois filhos — o título e o texto corrido. O flex: 1` é o que
faz a área segura ocupar a tela inteira, e o padding é o que separa o
conteúdo da borda que o sistema protege.
O engano comum é usar SafeAreaView em volta de tudo e achar que a tela ficou
correta. Ele só cuida das bordas do aparelho; a barra de navegação do
React Navigation e o teclado são outras camadas, e cada uma tem seu jeito. A
combinação que funciona é o contêiner de navegação cuidando do cabeçalho, o
SafeAreaView cuidando das bordas do aparelho, e o componente que embrulha a
rolagem cuidando do teclado.
A tabela do exemplo lista essas três camadas com o que cada uma cuida:
cabeçalho da navegação cuida da barra superior, o contêiner de área segura
cuida do entalhe e da barra de gestos, e o componente que envolve a rolagem
cuida do teclado aberto. Três camadas, três problemas, e nenhuma delas resolve o
problema da outra — por isso as três precisam existir.
a janela muda
useWindowDimensions devolve a largura e a altura da área útil e atualiza
quando o aparelho gira ou a janela muda. É o hook certo para decisão de layout:
quantas colunas cabem, se o conteúdo vai em linha ou em coluna, qual a altura
da imagem.
const { width, height } = useWindowDimensions(); const colunas = width >= 700 ? 3 : width >= 400 ? 2 : 1;
A função numeroDeColunas do exemplo é essa regra em três janelas: `320 x
640 dá uma coluna, 480 x 900 dá duas, e 820 x 1180` dá três. A medida entra
no cálculo e o cálculo entra no estilo — é o caminho do width medido em
tempo de execução que a aula de tamanho percorre pelo outro lado.
A distinção que importa é entre isso e ler a largura uma vez só. Ler no início
do componente produz layout que não se ajusta ao girar o aparelho, e o defeito
aparece como item cortado ou coluna espremida. O outro engano é usar a largura
para decidir fonte: fonte que muda com a largura quebra o padrão do texto, e o
usuário do aparelho com fonte grande perde a preferência dele.
O exemplo isola esse defeito em duas linhas: lendo a largura no começo, o
layout começa com 3 colunas e continua com 3 depois de girar para 480. São
seis colunas em uma tela de 480, e a resposta do console é que o layout
precisa reagir ao valor novo. O conserto é o hook, não uma linha de código que
recalcula na mão.
Para o que depende da altura, o caminho é o teclado: quando o teclado abre, a
altura da janela diminui, e o componente que mostra a rolagem precisa desse
novo valor para manter o campo visível. É o mesmo hook, olhando a altura em vez
da largura.
A relação entre fonte e largura fecha a aula, e o exemplo escreve as duas
regras em linhas separadas: não se aumenta a fonte pela largura da tela, e a
fonte grande vem da preferência do aparelho. São duas fontes de decisão em
campos diferentes — a largura decide coluna, a preferência do aparelho decide
tamanho de letra — e misturá-las produz o app que fica enorme em tela pequena e
minusculamente ilegível em tela grande.
Exemplo
// `allowFontScaling` (padrao `true`), `SafeAreaView` (as bordas do // aparelho) e `useWindowDimensions` (a janela que muda ao girar). O // exemplo monta a arvore e a regra de colunas por largura. const { View, Text, SafeAreaView, StyleSheet } = require('react-native'); // (1) a fonte do sistema: o padrao e deixar escalar. const estilos = StyleSheet.create({ tela: { flex: 1, padding: 16 }, titulo: { fontSize: 18, fontWeight: '700' }, corpo: { fontSize: 15 }, }); const titulo = { fontSize: 18, allowFontScaling: true }; console.log('padrao do `allowFontScaling`: o texto cresce com a fonte do aparelho'); console.log('titulo:', JSON.stringify(titulo)); console.log('quando precisa limitar, limita por elemento com `maxFontSizeMultiplier`,'); console.log('nao desligando o `allowFontScaling` da tela inteira'); // (2) o multiplicador e o que limita a fonte de um elemento. function tamanhoEfetivo(fonteDoSistema, multiplicador) { return Math.round(15 * fonteDoSistema * multiplicador); } console.log('\ncom a fonte do sistema em 1.0:', tamanhoEfetivo(1.0, 1)); console.log('com a fonte do sistema em 1.5:', tamanhoEfetivo(1.5, 1)); console.log('com a fonte do sistema em 2.0 e limite 1.5:', tamanhoEfetivo(2.0, 1.5)); console.log('desligar o escalonamento inteiro deixaria o app impossivel para quem enxerga pouco'); // (3) `SafeAreaView` cuida das bordas do aparelho; nao cuida da barra de // navegacao nem do teclado. function TelaComBordas() { return ( <SafeAreaView style={estilos.tela}> <Text style={estilos.titulo}>Meus pedidos</Text> <Text style={estilos.corpo}>o conteudo comeca longe do entalhe e da barra de gestos</Text> </SafeAreaView> ); } const arvore = TelaComBordas(); console.log('\ntipo da raiz:', arvore.type, '| estilo:', StyleSheet.flatten(arvore.props.style)); console.log('filhos:', arvore.props.children.length); console.log('em aparelho sem entalhe ele nao muda nada; com entalhe, evita o primeiro item sob a camera'); // (4) cada camada cuida do seu: navegador de telas, aparelho, teclado. const camadas = [ { camada: 'cabecalho da navegacao', cuida: 'barra superior' }, { camada: 'container de area segura', cuida: 'entalhe e barra de gestos' }, { camada: 'componente que envolve a rolagem', cuida: 'teclado aberto' }, ]; console.log('\ncada camada da tela, e o que ela cuida:'); for (const c of camadas) console.log(' ' + c.camada + ': ' + c.cuida); // (5) `useWindowDimensions`: a janela muda ao girar, e a decisao de layout // acompanha o valor novo. function useWindowDimensions() { // no aparelho devolve a janela e atualiza ao girar; aqui o exemplo // recebe a medida para poder mostrar a regra. return arguments.length > 0 ? arguments[0] : { width: 0, height: 0 }; } function numeroDeColunas(width) { return width >= 700 ? 3 : width >= 400 ? 2 : 1; } for (const medida of [{ width: 320, height: 640 }, { width: 480, height: 900 }, { width: 820, height: 1180 }]) { console.log('\njanela ' + medida.width + ' x ' + medida.height + ' -> ' + numeroDeColunas(medida.width) + ' coluna(s)'); console.log(' a largura e medida em tempo de execucao, e o valor entra no estilo'); } // (6) ler a largura uma vez so e o defeito: o layout nao se ajusta ao girar. function colunasAoGravar(width) { return numeroDeColunas(width); } const inicial = colunasAoGravar(820); console.log('\nao le uma vez so:', inicial, 'coluna(s)'); console.log('girou para 480 e a coluna continua', inicial, '-> o layout precisa reagir ao valor novo'); // (7) fonte que muda com a largura quebra o padrao do texto. console.log('\nnao se aumenta a fonte pela largura da tela: o padrao do texto existe'); console.log('fonte grande vem da preferencia do aparelho, e o layout precisa sobrar espaco para ela');
Saída real
padrao do `allowFontScaling`: o texto cresce com a fonte do aparelho
titulo: {"fontSize":18,"allowFontScaling":true}
quando precisa limitar, limita por elemento com `maxFontSizeMultiplier`,
nao desligando o `allowFontScaling` da tela inteira
com a fonte do sistema em 1.0: 15
com a fonte do sistema em 1.5: 23
com a fonte do sistema em 2.0 e limite 1.5: 45
desligar o escalonamento inteiro deixaria o app impossivel para quem enxerga pouco
tipo da raiz: SafeAreaView | estilo: { flex: 1, padding: 16 }
filhos: 2
em aparelho sem entalhe ele nao muda nada; com entalhe, evita o primeiro item sob a camera
cada camada da tela, e o que ela cuida:
cabecalho da navegacao: barra superior
container de area segura: entalhe e barra de gestos
componente que envolve a rolagem: teclado aberto
janela 320 x 640 -> 1 coluna(s)
a largura e medida em tempo de execucao, e o valor entra no estilo
janela 480 x 900 -> 2 coluna(s)
a largura e medida em tempo de execucao, e o valor entra no estilo
janela 820 x 1180 -> 3 coluna(s)
a largura e medida em tempo de execucao, e o valor entra no estilo
ao le uma vez so: 3 coluna(s)
girou para 480 e a coluna continua 3 -> o layout precisa reagir ao valor novo
nao se aumenta a fonte pela largura da tela: o padrao do texto existe
fonte grande vem da preferencia do aparelho, e o layout precisa sobrar espaco para ela