Acessibilidade no iOS com SwiftUI: VoiceOver, Dynamic Type e contraste

Capítulo 11

Tempo estimado de leitura: 10 minutos

+ Exercício

O que é acessibilidade no iOS (e por que isso muda seu código)

Acessibilidade é o conjunto de práticas que permite que pessoas com diferentes necessidades (visuais, motoras, cognitivas e auditivas) usem seu app com autonomia. No iOS, três pilares aparecem com frequência no dia a dia de desenvolvimento com SwiftUI: (1) leitura e navegação por leitor de tela (VoiceOver), (2) adaptação de texto e layout ao tamanho de fonte do usuário (Dynamic Type) e (3) contraste e diferenciação visual adequada (incluindo Alto Contraste e “Diferenciar sem cor”).

Em SwiftUI, grande parte da acessibilidade vem “de graça” quando você usa componentes nativos corretamente. Mesmo assim, é comum precisar ajustar rótulos, dicas, valores, ordem de navegação e áreas de toque. Este capítulo foca no que você precisa aplicar no app do curso para que ele seja confortável com VoiceOver, escalável com Dynamic Type e legível em diferentes condições de contraste.

VoiceOver na prática: rótulos, dicas, valores e agrupamento

Como o VoiceOver “enxerga” sua interface

O VoiceOver percorre elementos acessíveis em uma ordem (geralmente de cima para baixo e da esquerda para a direita), anunciando: label (o que é), value (estado/valor atual) e, quando útil, um hint (o que acontece ao interagir). Se você não define nada, o sistema tenta inferir a partir de textos e ícones, mas isso falha em botões só com imagem, componentes customizados e layouts complexos.

Quando usar accessibilityLabel, accessibilityHint e accessibilityValue

  • accessibilityLabel: nome curto e claro do controle. Ex.: “Adicionar ao carrinho”.
  • accessibilityHint: descreve a ação após tocar duas vezes. Ex.: “Abre a tela de pagamento”. Evite repetir o label.
  • accessibilityValue: estado/valor atual. Ex.: “Ativado”, “3 itens”, “50%”.

Exemplo 1: botão com ícone (sem texto visível)

Botões com Image(systemName:) costumam ser lidos como o nome do SF Symbol (ruim) ou como nada (pior). Defina um label humano e, se necessário, um hint.

Button { viewModel.addItem() } label: { Image(systemName: "plus") } .accessibilityLabel("Adicionar item") .accessibilityHint("Cria um novo item na lista")

Exemplo 2: indicador de status com valor acessível

Se você mostra um status visual (ex.: “Prioridade”, “Progresso”, “Favorito”), exponha o valor para o VoiceOver. Isso é especialmente importante quando o texto na tela é abreviado ou quando o status é representado por cor/ícone.

Continue em nosso aplicativo e ...
  • Ouça o áudio com a tela desligada
  • Ganhe Certificado após a conclusão
  • + de 5000 cursos para você explorar!
ou continue lendo abaixo...
Download App

Baixar o aplicativo

HStack { Image(systemName: item.isFavorite ? "star.fill" : "star") Text(item.title) } .accessibilityElement(children: .combine) .accessibilityLabel("\(item.title)") .accessibilityValue(item.isFavorite ? "Favorito" : "Não favorito")

Neste caso, .combine faz o VoiceOver ler o conjunto como um único elemento, evitando que ele pare no ícone e depois no texto separadamente.

Exemplo 3: controles customizados e ordem de leitura

Em layouts com vários elementos próximos (cards, células de lista customizadas), você pode querer: (a) combinar filhos em um único elemento, ou (b) controlar a ordem de navegação.

VStack(alignment: .leading, spacing: 8) { Text(item.title).font(.headline) Text(item.subtitle).font(.subheadline) HStack { Text(item.priceFormatted) Spacer() Button("Comprar") { viewModel.buy(item) } } } .accessibilityElement(children: .contain) .accessibilitySortPriority(1)

.contain mantém elementos separados, mas ainda “agrupa” a área. Já accessibilitySortPriority ajuda quando a ordem natural do layout não fica boa (use com parcimônia; prioridades altas demais podem confundir).

Quando esconder elementos decorativos

Ícones puramente decorativos devem ser ignorados pelo VoiceOver para reduzir ruído.

Image(systemName: "chevron.right") .accessibilityHidden(true)

Tamanho de toque e áreas clicáveis (motor e precisão)

Um problema comum é ter botões visualmente pequenos, difíceis de tocar. O iOS recomenda alvos de toque confortáveis (em torno de 44x44 pt). Em SwiftUI, você pode aumentar a área tocável sem mudar o visual usando contentShape e padding.

Exemplo: aumentar área de toque de um ícone

Button { viewModel.toggleFavorite(item) } label: { Image(systemName: item.isFavorite ? "heart.fill" : "heart") .foregroundStyle(.red) } .padding(12) .contentShape(Rectangle()) .accessibilityLabel(item.isFavorite ? "Remover dos favoritos" : "Adicionar aos favoritos")

O padding aumenta a área; contentShape(Rectangle()) garante que a região clicável seja retangular (útil quando o conteúdo é pequeno ou irregular).

Dynamic Type: texto que cresce sem quebrar o layout

O que é Dynamic Type

Dynamic Type é o sistema de tamanhos de fonte do iOS. Usuários podem aumentar (ou reduzir) o tamanho do texto nas configurações. Seu app deve acompanhar isso sem truncar informações importantes, sem sobrepor elementos e sem impedir a navegação.

Boas práticas essenciais no SwiftUI

  • Prefira fontes semânticas: .font(.body), .headline, .title3 etc., em vez de tamanhos fixos.
  • Evite .frame(height:) rígido em componentes com texto.
  • Permita múltiplas linhas quando fizer sentido: .lineLimit(nil) (ou não definir) e .fixedSize(horizontal: false, vertical: true) em casos específicos.
  • Use LayoutPriority para evitar que textos importantes sejam comprimidos.

Exemplo: card com título e subtítulo que respeita Dynamic Type

VStack(alignment: .leading, spacing: 6) { Text(item.title) .font(.headline) .lineLimit(2) .layoutPriority(1) Text(item.subtitle) .font(.body) .foregroundStyle(.secondary) } .padding() .background(.thinMaterial) .clipShape(RoundedRectangle(cornerRadius: 12))

Aqui, layoutPriority(1) ajuda o título a “ganhar espaço” quando o layout apertar. Evite limitar demais linhas em conteúdos críticos (endereços, nomes, instruções).

Testando tamanhos de fonte no Preview

Use o Preview para simular tamanhos maiores e identificar quebras cedo.

#Preview("Acessibilidade - Dynamic Type") { SuaTela() .environment(\.sizeCategory, .accessibilityExtraExtraExtraLarge) }

Contraste, cores e “diferenciar sem cor”

O problema: informação só por cor

Se você indica estado apenas por cor (ex.: verde = ok, vermelho = erro), parte dos usuários pode não perceber. Sempre que possível, combine cor com texto, ícone ou forma.

Use cores semânticas e estilos do sistema

Prefira .foregroundStyle(.primary), .secondary e cores do sistema (como .tint) em vez de cores fixas. Isso melhora contraste automaticamente em diferentes modos (claro/escuro) e configurações de acessibilidade.

Exemplo: mensagem de erro com ícone + texto (não só vermelho)

HStack(spacing: 8) { Image(systemName: "exclamationmark.triangle.fill") Text("E-mail inválido") } .font(.callout) .foregroundStyle(.red) .accessibilityElement(children: .combine) .accessibilityLabel("Erro: e-mail inválido")

Mesmo usando vermelho, o ícone e o texto tornam o significado claro. O accessibilityLabel deixa a leitura objetiva.

Detectando configurações do usuário (alto contraste e diferenciar sem cor)

Você pode reagir a preferências do usuário com variáveis de ambiente. Use isso para reforçar bordas, aumentar separação visual e evitar depender só de cor.

@Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor @Environment(\.accessibilityContrast) private var accessibilityContrast
var body: some View { let needsExtraContrast = (accessibilityContrast == .increased) VStack { Text("Status") .padding() } .background(needsExtraContrast ? Color.black.opacity(0.08) : Color.clear) .overlay { if differentiateWithoutColor { RoundedRectangle(cornerRadius: 10).stroke(.primary, lineWidth: 2) } } }

Esse padrão cria uma “pista” visual adicional (borda) quando o usuário pede para diferenciar sem cor, e reforça contraste quando necessário.

Identificadores de acessibilidade (e por que ajudam em testes)

accessibilityIdentifier não é lido pelo VoiceOver, mas é essencial para testes de UI (XCUITest) encontrarem elementos de forma estável. Também ajuda em depuração quando você inspeciona a hierarquia de acessibilidade.

Exemplo: definindo identifiers em campos e botões

TextField("E-mail", text: $email) .textInputAutocapitalization(.never) .keyboardType(.emailAddress) .accessibilityIdentifier("login.email") Button("Entrar") { viewModel.login() } .accessibilityIdentifier("login.submit")

Use um padrão consistente, por exemplo: tela.elemento ou fluxo.tela.elemento.

Passo a passo: revisão de acessibilidade no app do curso

A seguir está um roteiro prático para você aplicar agora no app do curso. A ideia é abrir a tela principal do app e corrigir problemas comuns em sequência.

Etapa 1 — Mapear elementos interativos e garantir labels corretos

  • Liste todos os elementos tocáveis: botões, toggles, links, células clicáveis, ícones com ação.
  • Para cada um, confirme: o texto visível já descreve a ação? Se não, adicione accessibilityLabel.
  • Se a ação não for óbvia, adicione accessibilityHint (curto e direto).
// Exemplo típico: botão de filtro só com ícone Button { viewModel.openFilters() } label: { Image(systemName: "line.3.horizontal.decrease.circle") } .accessibilityLabel("Filtros") .accessibilityHint("Abre opções para filtrar a lista") .accessibilityIdentifier("home.filters")

Etapa 2 — Corrigir células/linhas “barulhentas” (muitos stops do VoiceOver)

  • Se uma célula tem ícone + título + subtítulo + badge, o VoiceOver pode parar em cada item.
  • Decida: a célula deve ser lida como um único elemento? Use .accessibilityElement(children: .combine).
  • Se precisa manter elementos separados, use .contain e garanta que cada parte tenha sentido sozinha.
// Célula que deve ser lida como uma frase única HStack(spacing: 12) { Image(systemName: "doc.text") Text(item.title) Spacer() Text(item.dateFormatted).foregroundStyle(.secondary) } .accessibilityElement(children: .combine) .accessibilityLabel("\(item.title), \(item.dateFormatted)")

Etapa 3 — Verificar ordem de navegação e foco

  • Abra a tela e percorra com VoiceOver: a ordem faz sentido?
  • Se um elemento “pula” para fora de contexto, avalie reorganizar a hierarquia de views (primeira opção).
  • Se não for possível, use accessibilitySortPriority para ajustar pontos específicos.
// Exemplo: garantir que um alerta/aviso seja lido antes de ações Text("Pagamento pendente") .accessibilitySortPriority(2) Button("Pagar agora") { viewModel.pay() } .accessibilitySortPriority(1)

Etapa 4 — Garantir alvo de toque confortável

  • Procure ícones pequenos (ex.: lixeira, editar, favorito) e aumente a área com padding.
  • Use contentShape quando o layout tiver espaços vazios que deveriam ser clicáveis.
Button { viewModel.delete(item) } label: { Image(systemName: "trash") } .padding(12) .contentShape(Rectangle()) .accessibilityLabel("Excluir") .accessibilityHint("Remove este item")

Etapa 5 — Ajustar para Dynamic Type (sem truncar conteúdo importante)

  • No Preview, teste .accessibilityExtraExtraExtraLarge.
  • Remova alturas fixas em cards/linhas com texto.
  • Evite lineLimit(1) em títulos importantes; prefira 2+ linhas quando necessário.
  • Se um texto some por falta de espaço, aumente layoutPriority do que é mais importante.
#Preview("Tela - Fonte grande") { SuaTela() .environment(\.sizeCategory, .accessibilityExtraExtraExtraLarge) }

Etapa 6 — Reforçar contraste e não depender só de cor

  • Substitua cores fixas por estilos semânticos quando possível.
  • Para estados (erro/sucesso/selecionado), adicione texto/ícone.
  • Use accessibilityDifferentiateWithoutColor para adicionar bordas/ícones quando necessário.
@Environment(\.accessibilityDifferentiateWithoutColor) private var diffWithoutColor var statusView: some View { HStack(spacing: 8) { Circle().fill(.green).frame(width: 10, height: 10) Text("Sincronizado") } .overlay(alignment: .leading) { if diffWithoutColor { Image(systemName: "checkmark.circle").offset(x: -2) } } .accessibilityElement(children: .combine) .accessibilityLabel("Status") .accessibilityValue("Sincronizado") }

Checklist prático (para usar antes de enviar para revisão)

ItemVerificaçãoComo corrigir
Botões com íconeVoiceOver anuncia algo útil?accessibilityLabel e, se necessário, accessibilityHint
Estados e contagensUsuário entende o estado atual?accessibilityValue (ex.: “Ativado”, “3 itens”)
Elementos decorativosVoiceOver está “falando demais”?accessibilityHidden(true)
Células complexasMuitos stops por linha?.accessibilityElement(children: .combine) ou .contain
Ordem de navegaçãoFluxo de leitura faz sentido?Reorganizar layout; em último caso accessibilitySortPriority
Alvo de toqueControles pequenos são fáceis de tocar?padding + contentShape
Dynamic TypeFonte grande quebra layout?Fontes semânticas, remover alturas fixas, ajustar lineLimit/layoutPriority
ContrasteLegível no modo escuro e alto contraste?Usar estilos semânticos, reforçar bordas/ícones, evitar cor como único sinal
Testes de UIElementos são encontrados de forma estável?accessibilityIdentifier com padrão consistente

Agora responda o exercício sobre o conteúdo:

Em uma lista do SwiftUI, um botão exibido apenas como ícone está sendo anunciado pelo VoiceOver com um nome confuso. Qual é a melhor ação para tornar esse controle compreensível e, se necessário, descrever o que acontece ao interagir?

Você acertou! Parabéns, agora siga para a próxima página

Você errou! Tente novamente.

Botões só com ícone podem ser lidos com nomes técnicos ou nem serem anunciados. Defina um accessibilityLabel humano e, quando a ação não for evidente, complemente com um accessibilityHint sem repetir o label.

Próximo capitúlo

Internacionalização no iOS com SwiftUI: Localizable.strings, pluralização e formatação

Arrow Right Icon
Capa do Ebook gratuito iOS para Iniciantes com SwiftUI: do zero ao primeiro app na App Store
79%

iOS para Iniciantes com SwiftUI: do zero ao primeiro app na App Store

Novo curso

14 páginas

Baixe o app para ganhar Certificação grátis e ouvir os cursos em background, mesmo com a tela desligada.