Aula 06 · Semana 4 · Terça, 29 de setembro de 2026 · 08:30 às 10:30
UX mobile e layouts
Teoria: o que os utilizadores esperam de uma app móvel: gestos, tamanhos de toque, padrões de navegação e diferenças entre Android e iOS. Prática: o modelo de layout do Flutter e os widgets Row, Column, Expanded, Flexible e Stack. As listas, as grelhas e os widgets próprios ficam para amanhã.
Repositório: pdm-aula-06
No fim desta aula consegues
Identificar as diferenças principais entre Material 3 e as Human Interface Guidelines e decidir quando seguir a plataforma.
Listar os gestos e tamanhos de toque que os utilizadores esperam, e o custo de os ignorar.
Explicar o modelo de layout do Flutter: constraints go down, sizes go up, parent sets position.
Distribuir espaço com Row, Column, Expanded e Flexible, e corrigir um overflow.
Ler um Stack com Positioned: quem fica por cima e onde.
Material 3 vs Human Interface Guidelines
Material 3 (Google)
HIG (Apple)
Navegação principal
Navigation bar em baixo com 3 a 5 destinos, ou navigation drawer
Tab bar em baixo; sem drawer
Voltar
Botão ou gesto do sistema, de qualquer lado; predictive back desde o Android 14
Gesto de deslizar a partir da margem esquerda e seta na barra
Botões
Filled, tonal, outlined, text; FAB para a ação principal
Botões de texto e de sistema; sem FAB
Diálogos e folhas
Diálogo centrado; bottom sheet modal
Alerta centrado; action sheet e folhas que deslizam de baixo
Tipografia
Roboto; escala com 15 estilos (display, headline, title, body, label)
SF Pro; escala Dynamic Type controlada pelo utilizador
Quando seguir a plataforma
O WhatsApp e o Gmail seguem cada plataforma: tabs em cima no Android, em baixo no iOS. O Instagram e o Spotify têm um design próprio igual em todo o lado. As duas escolhas são legítimas.
O Flutter é Material por defeito e é assim que vamos trabalhar. Há widgets Cupertino para quem quiser o aspeto iOS, mas manter os dois duplica trabalho.
O que o utilizador nota, e que deves respeitar sempre: o gesto de voltar do iOS, a física do scroll de cada plataforma, os diálogos de permissão do sistema, a tecla de retroceder do Android. O Flutter trata disto por defeito.
O que o utilizador não nota: se o botão é "filled" ou "bordered", se a fonte é Roboto ou Inter.
Para os testes: Material 3 é o sistema de design do Google e o padrão do Flutter; as HIG são as regras da Apple. Uma app pode ter design próprio, mas deve respeitar os gestos e convenções do sistema.
Gestos que os utilizadores esperam
Gesto
O que espera que aconteça
Se o ignoras
Tap
Ação imediata com feedback visual (ripple, mudança de cor)
Toca duas vezes, dispara duas ações
Long press
Menu de contexto, seleção, arrastar
Não encontra a opção "apagar" e vai às definições
Swipe horizontal
Voltar (margem esquerda no iOS), mudar de tab, apagar item de lista
Fica preso num ecrã; sai da app
Pull-to-refresh
Atualizar a lista, com indicador
Reabre a app para ver dados novos
Pinch e duplo toque
Zoom em imagens e mapas
Não consegue ler o que está na imagem
A regra é não inventar. Um gesto que só a tua app usa é um gesto que ninguém descobre.
Toque, polegar e legibilidade
48 × 48 dp é o tamanho mínimo de um alvo de toque (Material) e 44 pt na Apple. Um dedo tem cerca de 1 cm. Ícones de 24 dp precisam de padding à volta; o Flutter faz isso em IconButton por ti.
8 dp de espaço entre alvos vizinhos. Dois botões colados são um botão com 50% de erro.
Zona do polegar: com uma mão, o terço inferior do ecrã é fácil, o topo é difícil. Ações frequentes em baixo (barra de navegação, FAB); ações raras ou perigosas em cima.
Contraste 4,5:1 para texto normal (WCAG AA). Cinzento claro sobre branco ao sol não se lê. Vemos ferramentas numa aula mais à frente.
Texto: corpo com 14 a 16 sp, linhas curtas, e nunca desativar o tamanho de letra do sistema.
Para os testes: alvo mínimo de 48 dp, contraste mínimo de 4,5:1, ações frequentes ao alcance do polegar.
Padrões de navegação
Bottom navigation: 3 a 5 secções de topo, sempre visíveis, cada uma com a sua pilha de ecrãs. Instagram, YouTube, a nossa Tempo Açores (Previsão, Avisos, Definições).
Drawer: menu lateral para muitas secções ou secções raras. Gmail. Está a cair em desuso porque esconde as opções.
Tabs: vistas do mesmo conteúdo, em cima, deslizáveis. WhatsApp no Android (Conversas, Estados, Chamadas).
Lista → detalhe: o padrão mais comum em qualquer app: uma lista, tocas, entra o detalhe, voltas. Em tablet, os dois lado a lado.
Modal: fluxo que interrompe (login, criar novo item, filtros) e volta ao ponto de partida ao fechar.
Na quarta, 7 de outubro, implementamos os três primeiros com go_router. Hoje só o layout.
Estados vazios, loading e erro fazem parte do design
Cada ecrã que mostra dados tem quatro estados: a carregar, com dados, vazio e erro. Se só desenhas o segundo, os outros três são ecrãs em branco.
Loading: um indicador, ou melhor, um skeleton com a forma do conteúdo. Nunca um ecrã branco por mais de 300 ms.
Vazio: uma frase e uma ação. "Ainda não tens favoritos. Toca na estrela numa localidade." Não é um bug; é uma oportunidade.
Erro: o que aconteceu em linguagem humana e um botão para tentar de novo. "Sem ligação. Os dados são de há 12 minutos."
Apps que fazem isto bem: Airbnb, Revolut. Repara na próxima vez que ficares sem rede.
Hoje desenhas o estado com dados. Numa aula mais à frente modelamos os quatro estados com uma sealed class e testamo-los com o modo caos da API.
Layout na Tempo Açores
git clone https://github.com/Tiago-Rocha/pdm-aula-06.git
cd pdm-aula-06
flutter pub get
Um único projeto na raiz: abre a pasta pdm-aula-06 no VS Code. É a app da aula 5 já terminada, mais o ecrã de previsão em lib/forecast_screen.dart, onde vais trabalhar. lib/data.dart tem os dados estáticos e não se altera.
A prática tem quatro passos, um slide cada: o primeiro hoje, os outros amanhã. Cada slide de passo começa com três comandos, um por linha, que te põem no ponto de partida desse passo, quer tenhas acabado o anterior quer não. Depois deles, hot restart no VS Code: o botão ↻ da barra de depuração. Depois mostra o código que vais usar, e o que tens de fazer. O branch passo-N é o passo N feito; passo-4 é o estado final.
Abre a pasta no VS Code. Ctrl+Shift+P → Flutter: Select Device → o teu Android. Depois F5: corre a configuração "Tempo Açores (debug)" que já vem no projeto. Olha para o telemóvel: por baixo da temperatura há riscas amarelas e pretas. É um overflow, e é o passo 1.
Corre flutter test: quatro testes vermelhos, um por passo.
1. Constraints go down, sizes go up, parent sets position
Três regras num só desenho: os limites descem, o tamanho sobe, e só o pai sabe onde o filho fica.
// 1. O pai diz ao filho os limites (mínimo e máximo de largura e altura)
// 2. O filho escolhe o seu tamanho dentro desses limites
// 3. O pai posiciona o filho
Scaffold(
body: Container(color: Colors.teal), // ocupa o ecrã todo: o pai obriga
)
Scaffold(
body: Center(
child: Container(color: Colors.teal, width: 100, height: 100), // o Center deixa escolher
),
)
Pergunta à turma Se trocar o Center por Align(alignment: Alignment.topRight), onde fica o quadrado? E se tirar o width e o height ao Container que está dentro do Center?
Um widget não sabe onde está nem pode escolher a posição. Só sabe os limites que recebeu.
O width: 100 do primeiro Container seria ignorado: o Scaffold dá limites apertados (tight) do tamanho do ecrã.
O erro mais famoso do Flutter, "Vertical viewport was given unbounded height", é um ListView dentro de uma Column: a coluna dá altura infinita e a lista não sabe que tamanho ter. A cura é Expanded.
Para os testes: as constraints descem, os tamanhos sobem, o pai posiciona. Um filho nunca escolhe onde fica.
Analogia O pai diz ao filho "podes ter entre 0 e 300 de largura": são as constraints a descer. O filho responde "fico com 200": é o tamanho a subir. O pai decide então onde o põe. Um filho nunca escolhe a sua posição e nunca pode ser maior do que o pai permite.
2. Row, Column, Expanded e Flexible
Column(
crossAxisAlignment: CrossAxisAlignment.start, // alinha à esquerda
children: [
Text('Ponta Delgada', style: textTheme.headlineSmall),
const Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, // um a cada ponta
children: [Text('Hoje'), Text('21 °C')],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(flex: 2, child: Container(height: 40, color: Colors.teal)), // 2 partes
const SizedBox(width: 8),
Expanded(child: Container(height: 40, color: Colors.orange)), // 1 parte
const SizedBox(width: 8),
Flexible(child: Container(height: 40, width: 300, color: Colors.grey)), // até 1 parte
],
),
],
)
Pergunta à turma O que muda se trocar spaceBetween por spaceEvenly? Se o Container cinzento tivesse Expanded em vez de Flexible, que largura ficava? Como fazia para o teal ocupar três partes e o laranja uma?
2. Row e Column: o que interessa
mainAxis é a direção do widget (horizontal na Row, vertical na Column); crossAxis é a outra. MainAxisAlignment distribui espaço; CrossAxisAlignment alinha.
Expanded obriga o filho a ocupar o espaço que sobra, dividido pelos flex. Flexible deixa o filho ser mais pequeno se quiser. Spacer é um Expanded vazio.
Uma Row não faz scroll nem quebra linha. Se os filhos não cabem, vês as riscas amarelas e pretas de overflow. Usa Expanded, Wrap ou um ListView horizontal.
mainAxisSize: MainAxisSize.min faz a coluna encolher para o conteúdo em vez de ocupar toda a altura.
Passo 1: corrigir o overflow
git stash -u
git checkout main
flutter pub get
// Está assim: três larguras fixas, 3 × 160 = 480, e o telemóvel tem 360
Row(
children: [
SizedBox(width: 160, child: _Measure('Mín', '${today.tMin.round()} °C')),
SizedBox(width: 160, child: _Measure('Máx', '${today.tMax.round()} °C')),
SizedBox(width: 160, child: _Measure('Chuva', '${today.rainChance} %')),
],
)
// Pista: Expanded dá a cada filho uma parte igual do que sobra
Expanded(child: _Measure('Mín', '${today.tMin.round()} °C')),
Faz Antes de tocar no código, diz o que vai acontecer quando trocares os três SizedBox por Expanded. Depois faz e guarda o ficheiro: o hot reload é automático.
No telemóvel: as riscas amarelas e pretas desaparecem e os três valores ficam igualmente espaçados. No terminal:flutter test, o teste Passo 1 fica verde.
3. Stack e Positioned: imagem com texto por cima
SizedBox(
height: 200,
child: Stack(
fit: StackFit.expand, // os filhos sem posição ocupam tudo
children: [
Image.asset('assets/img/sao_miguel.jpg', fit: BoxFit.cover),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black54], // escurece para o texto se ler
),
),
),
Positioned(
left: 16,
bottom: 16,
child: Text('São Miguel', style: textTheme.headlineMedium?.copyWith(color: Colors.white)),
),
],
),
)
Pergunta à turma Se tirar o fit: StackFit.expand, o que acontece à imagem? Como punha o texto em cima à direita? E se trocasse a ordem da imagem e do gradiente na lista?
Stack sobrepõe filhos pela ordem da lista: o último fica por cima. Positioned ancora a uma ou mais margens.
O gradiente por cima da imagem é uma técnica comum em apps de viagens para garantir contraste no texto.
Até amanhã
Para fazer
Se o teste Passo 1 ainda não está verde, acaba o passo 1. Depois compara com git stash -u e depois git checkout passo-1.