Aula 08 · Semana 5 · Terça, 6 de outubro de 2026 · 08:30 às 11:30
Teoria: o que é estado numa app, onde deve viver, e comparação entre setState, Provider, BLoC e Riverpod. Prática, no ecrã da última aula: um cartão que abre e fecha, a unidade °C/°F, uma pesquisa na grelha das ilhas e um formulário com validação.
setState, Provider, BLoC e Riverpod e escolher com critério.StatefulWidget e usar initState, setState e dispose corretamente.
1
2
3
4
5
ForecastScreen o ecrã inteiro, pai de todos os outros.CurrentConditions a temperatura de agora.DaysRow → DayChip os cinco dias.DetailCard mínima, máxima, vento e chuva._expandedlê a unidadeIslandsGrid a pesquisa e a grelha das ilhas._searchAlertsForm mais abaixo, fora do ecrã.Para os testes: estado efémero fica no widget com setState; estado de aplicação fica fora da árvore e é partilhado.
1
2
3
4
ForecastScreen guarda _fahrenheit e muda-o com setState quando tocas em °F.CurrentConditions recebe fahrenheit no construtor e mostra 73 °F.DaysRow para o DayChip mostrar °F, a DaysRow tinha de receber fahrenheit só para o passar adiante.DetailCard ainda em °C: mais um construtor a mudar.AppBar do ForecastScreen e usada em CurrentConditions, no DetailCard e no DayChip, que está dentro da DaysRow.setState só, a variável tem de viver no antepassado comum (ForecastScreen) e descer por parâmetro, construtor a construtor. A DaysRow passa a receber a unidade só para a entregar ao DayChip. Numa app maior são cinco ou seis níveis: chama-se prop drilling.setState no ForecastScreen reconstrói o ecrã inteiro, não só os textos com temperaturas.A resposta a este problema é a razão de existirem as bibliotecas de gestão de estado. Todas resolvem a mesma coisa: pôr o estado num sítio acessível e reconstruir só quem o lê.
setState: quando chega e quando deixa de chegar
rebuild: só o DetailCard
setState no DetailCard (passo 1): só o cartão volta a correr o build.
rebuild: o ecrã todo
setState no ForecastScreen (passo 2): o ecrã inteiro volta a correr o build, até a grelha e o formulário.
build reconstrói uma árvore grande por causa de um booleano.Para os testes: setState marca o widget como sujo e agenda um rebuild do seu subtree no próximo frame. Não reconstrói a app inteira, mas reconstrói tudo abaixo.
class UnitSettings extends ChangeNotifier {
bool fahrenheit = false;
void toggle() {
fahrenheit = !fahrenheit;
notifyListeners(); // avisa todos os widgets que fazem watch
}
}
// num widget que mostra temperaturas: reconstrói quando a unidade muda
final fahrenheit = context.watch<UnitSettings>().fahrenheit;
// no botão °C/°F: só chama, não precisa de reconstruir
onSelectionChanged: (_) => context.read<UnitSettings>().toggle(),
InheritedWidget é o mecanismo do próprio Flutter para pôr um valor na árvore e deixar qualquer descendente lê-lo com context. O Theme.of(context) que usamos em todos os build é isto.ChangeNotifier é uma classe com notifyListeners(). O teu estado estende-a e chama notifyListeners() sempre que muda, como o UnitSettings do bloco de cima.Provider junta os dois: põe o notifier na árvore e context.watch<UnitSettings>() reconstrói só o widget que o chamou quando há notifyListeners.DayChip lê a unidade diretamente; a DaysRow nem sabe que existe.Para os testes: Provider é um InheritedWidget com açúcar; ChangeNotifier avisa os ouvintes; watch reconstrói, read não.
| Abordagem | O que acrescenta | Quando faz sentido |
|---|---|---|
| setState | Nada: é do próprio Flutter | Ecrã isolado, poucos widgets, protótipos |
| Provider + ChangeNotifier | Um objeto observável na árvore; watch reconstrói só quem lê | Vários ecrãs partilham estado; equipas pequenas; o nosso projeto |
| BLoC / Cubit | Estados imutáveis num Stream; cada transição é previsível e testável; mais ficheiros | Equipas grandes, requisitos fortes de teste, apps bancárias |
| Riverpod | Providers globais e tipados, sem BuildContext; erros apanhados em compilação | Quem já domina Provider e quer mais segurança |
Todas fazem a mesma coisa por baixo: um objeto que guarda estado e avisa quem o observa. A diferença está na cerimónia e nas garantias.
setState). Um quadro na cozinha que toda a casa consulta e que avisa quando muda (ChangeNotifier com Provider). Um livro de registos com todas as alterações, datadas e assinadas (BLoC: eventos e estados). O quadro chega para a maioria das casas.
ForecastScreen, com setState.
build corre outra vez e desenha 73 °F.
// 1. ação: o toque chama o callback, que pede a mudança
onSelectionChanged: (s) => setState(() => _fahrenheit = s.first),
// 2. estado: o valor existe num só sítio, no State do ForecastScreen
bool _fahrenheit = false; // the state lives in the State, not in the widget
// 3. UI: o build volta a correr e o valor novo desce por parâmetro
child: CurrentConditions(fahrenheit: _fahrenheit),
CurrentConditions recebe fahrenheit e só o mostra: não tem forma de o mudar. Quem muda é quem guarda._fahrenheit vês sempre o mesmo ecrã. É o que torna os erros fáceis de reproduzir.copyWith). Os campos final do DayForecast em lib/data.dart já são assim. BLoC e Riverpod levam isto até ao fim.Para os testes: fluxo unidirecional é ação → estado → UI; imutabilidade significa criar cópias com copyWith em vez de alterar objetos.
setState. Vários: Provider ou acima.setState para estado efémero e Provider com ChangeNotifier para o resto: é simples, é o recomendado no guia de arquitetura do Flutter, e dá para chegar a BLoC mais tarde sem reescrever tudo.setState. Provider entra na aula 13, quando tivermos dados da API para partilhar.git clone https://github.com/Tiago-Rocha/pdm-aula-08.git
cd pdm-aula-08
flutter pub get
pdm-aula-08 no VS Code. É a app do fim da aula 7, mais um formulário de avisos no fundo do ecrã. Trabalhas em lib/forecast_screen.dart e lib/alerts_form.dart, nos comentários TODO 1 a TODO 4.O Android não arranca? Quase sempre é o Java que o Gradle está a usar: vê os problemas frequentes do guia de instalação e chama o docente. Entretanto segue a aula no Chrome: todos os passos de hoje funcionam igual no browser.
class DetailCard extends StatefulWidget {
const DetailCard({super.key});
@override
State<DetailCard> createState() => _DetailCardState();
}
class _DetailCardState extends State<DetailCard> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
// ... o build de antes, que agora pode ler e mudar _expanded
}
}
State, que o Flutter cria uma vez e mantém vivo entre rebuilds. É por isso que o hot reload não perde o que está no ecrã.setState(() { ... }) faz duas coisas: executa a alteração e marca o widget para reconstruir no próximo frame. Alterar a variável sem setState muda o valor, mas o ecrã não acompanha.StatefulWidget, menos trabalho._expanded fica no _DetailCardState e não no DetailCard? O que acontece se fizeres _expanded = !_expanded sem setState?// Pista: Ctrl+. em cima de "DetailCard" → Convert to StatefulWidget
bool _expanded = false;
return Card(
clipBehavior: Clip.antiAlias, // keeps the ripple inside the corners
child: InkWell(
onTap: () => setState(() => _expanded = !_expanded),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Mínima e Máxima, como estão
if (_expanded) ...[
// Vento e Chuva, cada um com o SizedBox antes
],
Icon(_expanded ? Icons.expand_less : Icons.expand_more),
],
),
),
),
);
DetailCard num StatefulWidget com o atalho do VS Code, acrescenta o campo _expanded, embrulha o Padding num InkWell e põe as linhas do vento e da chuva dentro do if (_expanded) ...[ ].Na app: o cartão começa só com a mínima e a máxima e uma seta para baixo; um toque mostra o vento e a chuva e a seta vira para cima; outro toque fecha.
CurrentConditions mostra temperaturas, mas o botão para a escolher fica na AppBar, que é do ForecastScreen.setState: o estado vive no antepassado comum mais próximo de quem o muda e de quem o mostra. Aqui é o ForecastScreen, que passa a StatefulWidget.final, como o DayChip recebe o forecast. Não guarda nada.SegmentedButton é um controlled widget: mostra o que está em selected e avisa em onSelectionChanged. O Switch, a Checkbox e o Slider funcionam da mesma maneira.CurrentConditions mostra °F. E a fila dos dias e o cartão de detalhe? O que teríamos de mudar para o DayChip também mostrar °F?git stash -u
git checkout passo-1
flutter pub get
// Pista: Ctrl+. em cima de "ForecastScreen" → Convert to StatefulWidget
bool _fahrenheit = false; // the state lives in the State, not in the widget
appBar: AppBar(
title: const Text('Tempo Açores'),
actions: [
SegmentedButton<bool>(
segments: const [
ButtonSegment(value: false, label: Text('°C')),
ButtonSegment(value: true, label: Text('°F')),
],
selected: {_fahrenheit},
showSelectedIcon: false,
onSelectionChanged: (s) => setState(() => _fahrenheit = s.first),
),
const SizedBox(width: 8),
],
),
const CurrentConditions({super.key, required this.fahrenheit});
final bool fahrenheit; // chosen in ForecastScreen, received here
String temp(double c) =>
fahrenheit ? '...' : '${c.round()} °C';
// e no ListView do ForecastScreen:
child: CurrentConditions(fahrenheit: _fahrenheit),
ForecastScreen, põe o SegmentedButton nas actions da AppBar, acrescenta o parâmetro fahrenheit ao CurrentConditions e passa-lhe _fahrenheit. No lugar do '...', a conversão: °F = °C × 9 / 5 + 32, arredondada, com °F no fim.Na app: carregas em °F e o 23 °C grande passa a 73 °F, a mínima a 63 °F e a máxima a 73 °F. A fila dos dias continua em °C.
late final TextEditingController _search;
@override
void initState() {
super.initState(); // once, before the first build
_search = TextEditingController();
}
@override
void dispose() {
_search.dispose(); // what initState creates, dispose frees
super.dispose();
}
| Método | Quando corre | Para quê |
|---|---|---|
initState | Uma vez, quando o State é criado | Criar controllers, subscrever streams, lançar o primeiro pedido |
build | Sempre que é preciso desenhar | Só descrever a UI. Nada de pedidos de rede aqui |
didUpdateWidget | Quando o pai reconstrói com parâmetros novos | Reagir a um parâmetro que mudou (nova localidade selecionada) |
dispose | Uma vez, quando o widget sai da árvore | Libertar controllers, timers, subscrições, focus nodes |
build? E o que acontece se te esqueceres do dispose?Para os testes: tudo o que crias em initState libertas em dispose; build pode correr muitas vezes e não deve ter efeitos secundários.
TextEditingController guarda o texto da caixa. Precisas dele para ler o texto noutro sítio, limpá-lo (botão X) ou pôr um valor inicial. E aí, dispose.onChanged corre a cada tecla. Como o texto já está no controller, só precisas de pedir um rebuild: setState(() {}).Timer).InputDecoration controla o aspeto: prefixIcon, suffixIcon, hintText, labelText, borda e texto de erro.git stash -u
git checkout passo-2
flutter pub get
// Pista: Convert to StatefulWidget, e o controller do slide anterior
List<Island> get _filtered => islands
.where((i) => '...')
.toList();
// no build: uma Column com o TextField em cima e o GridView em baixo
child: TextField(
controller: _search,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.search),
hintText: 'Pesquisar ilha',
border: const OutlineInputBorder(),
// suffixIcon: null sem texto; com texto, um IconButton que limpa
),
onChanged: (_) => setState(() {}), // the text is in _search
),
// e o GridView mostra só as filtradas:
children: [for (final i in filtered) IslandCard(island: i)],
IslandsGrid, cria o controller em initState e liberta-o em dispose, e escreve a condição do where no lugar do '...': o nome da ilha contém o texto, sem distinguir maiúsculas. Depois o botão para limpar (Icons.clear, _search.clear) e, sem resultados, o texto 'Nenhuma ilha encontrada'.Na app: escreves "ter" e só fica a Terceira; aparece um X que limpa e volta a mostrar as nove; "xyz" mostra "Nenhuma ilha encontrada".
final _formKey = GlobalKey<FormState>(); // a field, never inside build
final _email = TextEditingController();
void _submit() {
// validate() runs every validator and shows the errors
if (_formKey.currentState!.validate()) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Avisos enviados para ${_email.text}')),
);
}
}
child: Form(
key: _formKey,
Form agrupa campos; a GlobalKey<FormState> dá acesso ao formulário a partir do botão. É dos poucos usos legítimos de GlobalKey.TextFormField tem um validator que devolve a mensagem de erro, ou null se o valor estiver bem. validate() corre todos, mostra os erros por baixo dos campos e devolve true se nenhum falhar.autovalidateMode: AutovalidateMode.onUserInteraction valida cada campo enquanto o utilizador escreve nele. Sem isto, só valida ao submeter.keyboardType: TextInputType.emailAddress e textInputAction: TextInputAction.next mudam o teclado do telemóvel. Em mobile, isto é UX.validate() devolve true?Para os testes: um validator devolve null quando o valor é válido e uma String com o erro quando não é.
git stash -u
git checkout passo-3
flutter pub get
// no Form, por baixo da key:
autovalidateMode: AutovalidateMode.onUserInteraction,
// no campo Nome:
validator: (v) {
if (v == null || v.trim().isEmpty) return 'Obrigatório';
// e com menos de 3 letras: 'Pelo menos 3 letras'
return null; // null means valid
},
// no campo E-mail:
keyboardType: TextInputType.emailAddress,
// e um validator: 'Obrigatório', e 'E-mail inválido' sem @ ou sem ponto
validator, acrescenta o autovalidateMode ao Form e o keyboardType ao e-mail.Na app: "Subscrever" com os campos vazios mostra "Obrigatório" por baixo dos dois e não mostra o SnackBar; "Ana" e "ana@uac.pt" mostram "Avisos enviados para ana@uac.pt". No telemóvel, o campo do e-mail abre o teclado com @.
DetailCard: StatefulWidget com _expanded, que abre e fecha ao toque (passo 1).ForecastScreen: guarda a unidade em _fahrenheit e passa-a por parâmetro ao CurrentConditions (passo 2). A fila dos dias ainda está em °C: é o custo de passar por parâmetro.IslandsGrid: pesquisa com controller criado em initState e libertado em dispose, lista filtrada calculada num getter (passo 3).AlertsForm: Form com GlobalKey, um validator por campo e autovalidateMode (passo 4).Tudo isto é estado efémero, e setState chega. O branch passo-4 é o estado final: compara o teu código com ele.
passo-4: git stash -u e depois git checkout passo-4.onChanged e TextEditingController.