graph TD
A["Scaffold"] --> B["Stack<br/>(corpo da tela)"]
B --> C["SingleChildScrollView<br/>(conteúdo rolável)"]
B --> D["Positioned<br/>(botão voltar)"]
C --> E["Column"]
E --> F["AspectRatio 4:3<br/>(imagem do produto)"]
E --> G["Padding all:24<br/>(conteúdo textual)"]
G --> H["Row<br/>(nome + favorito)"]
H --> H1["Expanded<br/>(nome do produto)"]
H --> H2["IconButton<br/>(coração)"]
G --> I["SizedBox height:8"]
G --> J["Row<br/>(preço + avaliação)"]
J --> J1["Text (preço)"]
J --> J2["Spacer"]
J --> J3["Icon (estrela)"]
J --> J4["Text (nota)"]
G --> K["SizedBox height:16"]
G --> L["Wrap<br/>(tags de características)"]
G --> M["SizedBox height:16"]
G --> N["Text<br/>(descrição)"]
G --> O["SizedBox height:80<br/>(espaço para botão fixo)"]
B --> P["Positioned bottom:0<br/>(botão 'Adicionar ao carrinho')"]
style A fill:#cfe2ff,stroke:#0d6efd
style B fill:#d1ecf1,stroke:#17a2b8
style E fill:#d1ecf1,stroke:#17a2b8
style F fill:#fff3cd,stroke:#ffc107
style H fill:#d4edda,stroke:#28a745
style J fill:#d4edda,stroke:#28a745
style L fill:#d4edda,stroke:#28a745
style P fill:#f8d7da,stroke:#dc3545
Módulo 04 — Layouts e Responsividade
Nos três módulos anteriores você percorreu um caminho impressionante: configurou o ambiente, aprendeu Dart com profundidade e entendeu como o Flutter organiza widgets em árvore. Agora chegou um dos momentos mais práticos e visualmente gratificantes da disciplina: aprender a posicionar, organizar e adaptar cada elemento da sua tela. Neste módulo, você vai entender como o Flutter decide onde cada coisa vai aparecer — e, mais importante, como você pode controlar esse processo com precisão e inteligência. Ao final deste material, você saberá construir telas que funcionam lindamente em um smartphone compacto e igualmente bem em um tablet com tela generosa. Estude com calma, execute todos os exemplos e chegue à aula pronto para transformar o layout do seu Projeto Integrador.
O Problema do Layout: Por que não basta “colocar as coisas na tela”?
Quem nunca desenvolveu para mobile pode ter a impressão de que colocar elementos na tela é simples: “vou posicionar esse botão aqui, esse texto ali”. Em ambientes fixos, como uma folha de papel ou uma janela de tamanho imutável, isso funcionaria. Mas um aplicativo móvel precisa rodar em dezenas de dispositivos diferentes — do smartphone mais antigo com tela de 4 polegadas ao tablet moderno com 12 polegadas de diagonal. A mesma tela precisa ser útil e bonita em todos eles.
Além disso, o conteúdo é dinâmico. Um texto pode ter uma linha ou dez. Uma lista pode ter três itens ou trezentos. O nome de um usuário pode ter quatro letras ou quarenta. Um sistema de layout robusto precisa lidar com toda essa variação sem quebrar, sem cortar conteúdo e sem deixar espaços estranhos em branco.
É para resolver esses desafios que o Flutter tem um sistema de layout sofisticado, baseado em regras claras e previsíveis. Compreender essas regras é o que separa quem apenas copia código de quem realmente sabe o que está fazendo. E é exatamente isso que você vai aprender agora.
A Regra de Ouro: Constraints Descem, Tamanhos Sobem, Pai Posiciona
O princípio mais importante do layout no Flutter
Existe uma regra que governa absolutamente tudo que acontece no layout do Flutter, e ela pode ser resumida em três partes. O pai passa constraints para o filho. O filho decide seu tamanho dentro dessas constraints. O pai posiciona o filho com base no tamanho que o filho escolheu.
Uma constraint é simplesmente um par de dimensões mínimas e máximas que o widget pai impõe ao filho: “você pode ter no mínimo X de largura e no máximo Y de largura; no mínimo A de altura e no máximo B de altura”. O filho, ao receber essas constraints, decide qual tamanho quer ter — desde que respeite os limites — e comunica esse tamanho de volta ao pai. O pai então posiciona o filho dentro de seu próprio espaço.
Essa cadeia de responsabilidades bem definidas é o que torna o sistema de layout do Flutter previsível. Quando um elemento se comporta de forma inesperada na tela, a causa quase sempre está em algum ponto dessa cadeia: ou o pai passou constraints inesperadas, ou o filho tomou um tamanho surpreendente, ou o pai posicionou o filho de forma não óbvia.
Vamos ver isso na prática com o exemplo mais simples possível. Quando você escreve um Text('Olá') dentro de um Scaffold, o que acontece? O Scaffold recebe do sistema operacional a constraint de ocupar a tela inteira — digamos 360×800 pixels. Ele passa essa constraint para o Text. O Text decide que precisa de, digamos, 40×20 pixels para exibir “Olá” na fonte padrão. Ele comunica esse tamanho ao Scaffold. O Scaffold posiciona o Text no canto superior esquerdo por padrão.
Esse mecanismo parece trivial num exemplo simples. Mas ele é exatamente o mesmo em qualquer nível de complexidade — seja numa tela com dois widgets ou numa tela com duzentos widgets aninhados em múltiplos níveis.
Erro comum: Um dos erros mais frequentes de quem começa com Flutter é colocar um widget de altura infinita (como uma Column sem limite de altura) dentro de um widget que também aceita altura infinita (como um ListView). O resultado é o famoso erro “Vertical viewport was given unbounded height”. Agora que você conhece a regra de constraints, entende por que isso acontece: o ListView passa uma constraint de altura infinita para a Column, e a Column, que normalmente se ajusta ao conteúdo, não sabe qual tamanho ter quando pode crescer infinitamente.
Row e Column: A Dupla Que Você Vai Usar Todo Dia
Se existissem dois widgets que resumissem 80% de todo o trabalho de layout em Flutter, esses seriam Row e Column. O Row organiza seus filhos em uma linha horizontal; o Column organiza os filhos em uma coluna vertical. Simples assim. Mas as possibilidades de configuração dessas duas classes são ricas o suficiente para criar interfaces de qualquer complexidade.
Ambos compartilham exatamente as mesmas propriedades — a diferença é apenas a direção. O mainAxis de um Row é o eixo horizontal; o mainAxis de uma Column é o eixo vertical. O crossAxis é sempre perpendicular ao mainAxis.
Controlando o Eixo Principal com mainAxisAlignment
O mainAxisAlignment define como os filhos são distribuídos ao longo do eixo principal. Existem seis valores possíveis, e cada um produz um comportamento visual distinto.
Exemplo — Os seis valores de mainAxisAlignment em uma Row
// Imagine três botões dentro de uma Row.
// Mude o mainAxisAlignment para ver cada comportamento:
Row(
mainAxisAlignment: MainAxisAlignment.start, // alinha tudo à esquerda (padrão)
children: [
ElevatedButton(onPressed: () {}, child: const Text('A')),
ElevatedButton(onPressed: () {}, child: const Text('B')),
ElevatedButton(onPressed: () {}, child: const Text('C')),
],
)Com MainAxisAlignment.start, os três botões ficam agrupados à esquerda. Com MainAxisAlignment.end, ficam agrupados à direita. Com MainAxisAlignment.center, ficam no centro. Com MainAxisAlignment.spaceBetween, o espaço disponível é dividido entre os botões (sem espaço nas bordas). Com MainAxisAlignment.spaceAround, cada botão recebe metade do espaço nas extremidades e o dobro entre eles. Com MainAxisAlignment.spaceEvenly, o espaço é distribuído igualmente em todos os intervalos, incluindo as bordas.
Controlando o Eixo Transversal com crossAxisAlignment
O crossAxisAlignment define como os filhos se alinham no eixo perpendicular ao principal. Numa Column, o eixo transversal é o horizontal — ou seja, crossAxisAlignment controla se os itens ficam alinhados à esquerda, ao centro ou à direita.
Exemplo — crossAxisAlignment em uma Column
// Uma Column com textos de tamanhos diferentes.
// Veja como o crossAxisAlignment muda o visual:
Column(
crossAxisAlignment: CrossAxisAlignment.start, // todos alinhados à esquerda
children: [
const Text('Título longo da seção'),
const Text('Subtítulo'),
const Text('Texto'),
],
)
// Com CrossAxisAlignment.center: cada texto fica centralizado.
// Com CrossAxisAlignment.end: todos ficam alinhados à direita.
// Com CrossAxisAlignment.stretch: cada filho é esticado para ocupar
// toda a largura disponível — muito útil para botões que devem
// ocupar a largura total da tela!mainAxisSize: Quando a Column ou Row Deve Encolher?
Por padrão, Row e Column tentam ocupar todo o espaço disponível no eixo principal. Uma Column dentro de um Scaffold vai tentar ter a altura total da tela, mesmo que seus filhos ocupem apenas um terço dela. Às vezes, você quer que o widget “abrace” seus filhos — ocupe apenas o espaço que os filhos precisam. Para isso, use mainAxisSize: MainAxisSize.min.
Exemplo — mainAxisSize para criar um container que se ajusta ao conteúdo
// Um Card que deve ter apenas a altura dos seus filhos, sem espaço extra:
Card(
child: Column(
mainAxisSize: MainAxisSize.min, // abraça os filhos
children: [
const ListTile(
leading: Icon(Icons.person),
title: Text('Ana Lima'),
subtitle: Text('ana@email.com'),
),
const Divider(),
TextButton(
onPressed: () {},
child: const Text('Ver perfil'),
),
],
),
)Sem MainAxisSize.min, a Column se expandiria para preencher toda a altura disponível — o que dentro de um Card causaria um visual estranho e cheio de espaço em branco. Com MainAxisSize.min, ela tem exatamente a altura que o conteúdo precisa.
Flexible e Expanded: Distribuindo Espaço de Forma Proporcional
Quando você tem uma Row com três botões e quer que dois deles tenham tamanho fixo, mas o terceiro ocupe todo o espaço restante, você usa Expanded. Quando quer que dois botões compartilhem o espaço disponível em proporções diferentes, você usa Flexible com flex. Esses dois widgets são indispensáveis para criar layouts que se adaptam ao espaço disponível.
Expanded: Ocupe Todo o Espaço Restante
O Expanded envolve um widget filho e instrui o Row ou Column pai: “dê todo o espaço que sobrar para este filho”. Se houver dois filhos com Expanded, cada um recebe metade do espaço restante. O Expanded é, na prática, um Flexible com fit: FlexFit.tight — o que significa que o filho é obrigado a ocupar todo o espaço que lhe foi designado.
Exemplo — Barra de busca com botão de ação
// Padrão muito comum: campo de texto que ocupa o máximo possível,
// com um botão de tamanho fixo ao lado.
Row(
children: [
Expanded(
// O TextField cresce para preencher todo o espaço restante
child: TextField(
decoration: const InputDecoration(
hintText: 'Pesquisar...',
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 8), // espaçamento fixo entre os dois elementos
ElevatedButton(
onPressed: () {},
child: const Icon(Icons.search),
),
],
)O botão tem seu tamanho natural (definido pelo ícone). O Expanded ao redor do TextField faz com que o campo de texto ocupe toda a largura restante da Row.
Flexible: Distribua Espaço com Proporção
O Flexible funciona como o Expanded, mas com uma diferença importante: o filho pode ter tamanho menor que o espaço designado. O parâmetro flex define a proporção. Com flex: 2 em um filho e flex: 1 em outro, o primeiro recebe o dobro do espaço que o segundo recebe.
Exemplo — Três colunas com proporções diferentes
// Um layout de três painéis onde o central é o dobro dos laterais
Row(
children: [
Flexible(
flex: 1,
child: Container(
color: Colors.blue.shade100,
child: const Center(child: Text('Lateral')),
),
),
Flexible(
flex: 2, // o dobro de espaço
child: Container(
color: Colors.green.shade100,
child: const Center(child: Text('Centro')),
),
),
Flexible(
flex: 1,
child: Container(
color: Colors.blue.shade100,
child: const Center(child: Text('Lateral')),
),
),
],
)Se a Row tiver 300 pixels de largura total, a proporção é 1:2:1 — ou seja, 75px | 150px | 75px. Se a tela mudar de tamanho, as proporções se mantêm automaticamente.
Stack e Positioned: Sobreposição de Elementos
Row e Column organizam elementos de forma linear — um ao lado do outro ou um abaixo do outro. Mas às vezes você precisa que elementos se sobreponham: um botão flutuante sobre uma imagem, um selo de “NOVO” sobre um card de produto, uma notificação sobre um ícone. Para isso, o Flutter oferece o Stack.
O Stack funciona como camadas: o primeiro filho fica na camada de baixo, o segundo fica sobre o primeiro, o terceiro sobre o segundo, e assim por diante. O tamanho do Stack é, por padrão, o tamanho do maior filho não-posicionado.
Positioned: Controle Preciso da Posição
Dentro de um Stack, você pode envolver qualquer filho com Positioned para especificar sua posição exata relativa ao Stack. Os parâmetros top, bottom, left e right definem a distância entre a borda do filho e a borda correspondente do Stack.
Exemplo — Card de produto com selo de desconto
// Um card com uma imagem, e sobre ela, um selo de desconto no canto superior direito
Stack(
children: [
// Camada 1 (base): a imagem do produto
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(
'https://via.placeholder.com/300x200',
width: 300,
height: 200,
fit: BoxFit.cover,
),
),
// Camada 2: o selo de desconto, posicionado no canto superior direito
Positioned(
top: 8,
right: 8,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(20),
),
child: const Text(
'-30%',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
),
),
],
)Align: Posicionamento Declarativo Dentro do Stack
Quando você não precisa de coordenadas exatas, mas sim de alinhamentos relativos (centro, canto superior esquerdo, centro inferior), o Align é mais elegante que o Positioned. Ele usa um Alignment que vai de -1.0 a 1.0 em cada eixo.
Exemplo — Texto centralizado sobre uma imagem de fundo
SizedBox(
width: double.infinity,
height: 200,
child: Stack(
children: [
// Imagem de fundo ocupando todo o Stack
Positioned.fill(
child: Image.network(
'https://via.placeholder.com/600x200',
fit: BoxFit.cover,
),
),
// Overlay escuro semitransparente
Positioned.fill(
child: Container(
color: Colors.black.withOpacity(0.4),
),
),
// Texto centralizado sobre tudo
const Align(
alignment: Alignment.center,
child: Text(
'Bem-vindo ao App',
style: TextStyle(
color: Colors.white,
fontSize: 28,
fontWeight: FontWeight.bold,
),
),
),
],
),
)Widgets de Rolagem: ListView e GridView
Quase todo aplicativo real tem listas. Uma lista de produtos, uma lista de mensagens, uma lista de notificações — esse padrão está em toda parte. O Flutter oferece widgets especializados para listas que rolam, e o mais fundamental deles é o ListView.
A regra mais importante sobre ListView é que ele tem altura infinita no eixo de rolagem. Isso significa que se você colocar um ListView dentro de uma Column sem dar a ele uma altura definida, vai obter aquele erro de “unbounded height” que mencionamos lá no início. A solução é sempre envolver o ListView em um Expanded (dentro de uma Column) ou em um SizedBox com altura definida.
ListView Simples e ListView.builder
O ListView simples recebe uma lista de widgets no parâmetro children e os exibe verticalmente com rolagem. Ele é adequado para listas curtas e estáticas. Para listas longas — com dezenas ou centenas de itens — você deve usar ListView.builder, que constrói cada item somente quando ele está prestes a aparecer na tela. Isso é fundamental para o desempenho: construir 1000 widgets de uma vez é lento; construir 15 (os que cabem na tela) e ir construindo os demais conforme o usuário rola é eficiente.
Exemplo — Lista de contatos com ListView.builder
// Uma lista com potencialmente muitos itens — sempre use .builder
class TelaContatos extends StatelessWidget {
// Imagine que essa lista vem de uma fonte de dados
final List<String> contatos = const [
'Ana Lima',
'Bruno Souza',
'Carlos Mendes',
'Diana Ferreira',
'Eduardo Costa',
// ... poderia ter centenas de itens
];
const TelaContatos({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Contatos')),
body: ListView.builder(
itemCount: contatos.length,
itemBuilder: (context, index) {
// Este método só é chamado para os itens visíveis na tela
return ListTile(
leading: CircleAvatar(
child: Text(contatos[index][0]), // primeira letra do nome
),
title: Text(contatos[index]),
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
onTap: () {
// navegação para detalhes do contato
},
);
},
),
);
}
}ListView.separated: Adicionando Divisórias
Uma variante muito prática do ListView.builder é o ListView.separated, que permite definir um widget separador entre os itens — geralmente um Divider.
GridView.builder: Grades Eficientes
Quando você precisa exibir itens em uma grade (dois, três ou mais por linha), o GridView.builder é a ferramenta certa. O parâmetro gridDelegate controla quantas colunas a grade tem e o espaçamento entre os itens.
Exemplo — Grade de produtos com duas colunas
GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, // duas colunas
crossAxisSpacing: 12, // espaço horizontal entre itens
mainAxisSpacing: 12, // espaço vertical entre itens
childAspectRatio: 0.75, // razão largura/altura de cada item
),
itemCount: produtos.length,
itemBuilder: (context, index) {
return CardProduto(produto: produtos[index]);
},
)SingleChildScrollView: Quando a Tela Não Cabe no Conteúdo
O SingleChildScrollView torna um único widget filho rolável. Ele é ideal para telas de formulário ou telas de detalhes cujo conteúdo pode ultrapassar a altura da tela — especialmente em dispositivos menores ou quando o teclado virtual aparece e comprime o espaço disponível. Ao contrário do ListView, que é otimizado para muitos itens similares, o SingleChildScrollView é adequado para um único filho complexo (geralmente uma Column com vários elementos distintos).
Exemplo — Tela de cadastro rolável
Scaffold(
appBar: AppBar(title: const Text('Criar Conta')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('Dados Pessoais', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
const TextField(decoration: InputDecoration(labelText: 'Nome completo')),
const SizedBox(height: 12),
const TextField(decoration: InputDecoration(labelText: 'E-mail')),
const SizedBox(height: 12),
const TextField(
obscureText: true,
decoration: InputDecoration(labelText: 'Senha'),
),
const SizedBox(height: 24),
const Text('Endereço', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
const TextField(decoration: InputDecoration(labelText: 'Rua')),
const SizedBox(height: 12),
const TextField(decoration: InputDecoration(labelText: 'Cidade')),
const SizedBox(height: 32),
ElevatedButton(
onPressed: () {},
child: const Text('Criar Conta'),
),
],
),
),
)Sem o SingleChildScrollView, se o teclado abrir enquanto o usuário estiver preenchendo um campo no final da tela, os widgets inferiores ficariam escondidos atrás do teclado. Com ele, a tela toda se torna rolável e o usuário pode acessar todos os campos normalmente.
Wrap: Quando os Filhos Precisam “Quebrar Linha”
Imagine que você tem uma lista de tags de interesse — “Tecnologia”, “Ciência”, “Arte”, “Música”, “Esportes” — e quer exibi-las em chips coloridos. Se você usar uma Row, eles podem ultrapassar a largura da tela e causar overflow. Se usar uma Column, cada tag ficará em uma linha separada, ocupando muito espaço vertical. O Wrap é a solução ideal: ele funciona como uma Row, mas quando os filhos não cabem na linha atual, ele automaticamente inicia uma nova linha.
Exemplo — Tags de interesse com Wrap
const tags = ['Tecnologia', 'Ciência', 'Arte', 'Música', 'Esportes', 'Culinária', 'Viagens'];
Wrap(
spacing: 8, // espaço horizontal entre os chips
runSpacing: 8, // espaço vertical entre as linhas
children: tags.map((tag) {
return Chip(
label: Text(tag),
backgroundColor: Colors.blue.shade100,
);
}).toList(),
)Se todos os chips couberem em uma linha, eles ficam em uma linha. Se não couberem, o Wrap quebra automaticamente para a próxima linha — sem overflow, sem código extra.
MediaQuery: Conhecendo o Dispositivo
Para criar layouts verdadeiramente responsivos, você precisa saber as dimensões da tela em que seu aplicativo está rodando. O MediaQuery é o mecanismo do Flutter para isso. Com ele, você obtém a largura e a altura da tela, a orientação do dispositivo (retrato ou paisagem), o padding de segurança (para respeitar o notch e a barra de status) e a preferência de tamanho de texto do sistema.
Para acessar o MediaQuery, você usa MediaQuery.of(context), que retorna um MediaQueryData com todas essas informações. Normalmente, você extrai a parte que precisa logo no início do método build.
Exemplo — Layout que se adapta à orientação do dispositivo
class TelaAdaptavel extends StatelessWidget {
const TelaAdaptavel({super.key});
@override
Widget build(BuildContext context) {
// Obtém as dimensões e a orientação da tela
final tamanhoTela = MediaQuery.of(context).size;
final orientacao = MediaQuery.of(context).orientation;
final largura = tamanhoTela.width;
return Scaffold(
appBar: AppBar(title: const Text('Layout Adaptável')),
body: Padding(
padding: const EdgeInsets.all(16),
child: orientacao == Orientation.portrait
// Em retrato: coluna única
? const Column(
children: [
PainelInformacoes(),
SizedBox(height: 16),
PainelAcoes(),
],
)
// Em paisagem: dois painéis lado a lado
: const Row(
children: [
Expanded(child: PainelInformacoes()),
SizedBox(width: 16),
Expanded(child: PainelAcoes()),
],
),
),
);
}
}Adaptando Tamanhos ao Tamanho da Tela
Além da orientação, o MediaQuery permite criar tamanhos proporcionais à tela. Em vez de hardcodar width: 200, você pode escrever width: largura * 0.5 — e o widget vai ocupar sempre metade da largura, independentemente do dispositivo.
Exemplo — Imagem que ocupa 80% da largura da tela
@override
Widget build(BuildContext context) {
final largura = MediaQuery.of(context).size.width;
return Center(
child: Container(
width: largura * 0.8, // 80% da largura, em qualquer dispositivo
height: largura * 0.8 * 0.5625, // proporção 16:9
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(12),
),
child: const Center(child: Text('Imagem ou Vídeo')),
),
);
}LayoutBuilder: Decisões Baseadas no Espaço Disponível
O MediaQuery te dá o tamanho da tela inteira. Mas e quando você precisa saber o tamanho do espaço que um widget específico tem disponível para si? Para isso, existe o LayoutBuilder.
O LayoutBuilder fornece, no momento da construção do widget, as constraints que o pai passou para ele. Isso permite tomar decisões de layout baseadas não no tamanho da tela, mas no espaço real que aquele widget tem. Isso é especialmente útil em componentes reutilizáveis que podem aparecer em contextos diferentes — em uma tela grande, podem exibir mais detalhes; em um espaço pequeno, exibem uma versão compacta.
Exemplo — Card que muda de layout baseado no espaço disponível
class CardAdaptavel extends StatelessWidget {
final String titulo;
final String descricao;
const CardAdaptavel({
super.key,
required this.titulo,
required this.descricao,
});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
// Se o widget tem mais de 400 pixels de largura, usa layout horizontal
if (constraints.maxWidth > 400) {
return Row(
children: [
const Icon(Icons.info, size: 48, color: Colors.blue),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(titulo, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
Text(descricao),
],
),
),
],
);
}
// Se tem menos de 400 pixels, usa layout vertical (compacto)
return Column(
children: [
const Icon(Icons.info, size: 32, color: Colors.blue),
const SizedBox(height: 8),
Text(titulo, style: const TextStyle(fontWeight: FontWeight.bold)),
Text(descricao, textAlign: TextAlign.center),
],
);
},
);
}
}Este CardAdaptavel pode ser colocado numa tela pequena e vai mostrar o layout vertical, ou numa tela grande (ou num tablet) e vai mostrar o layout horizontal — automaticamente, sem nenhum código extra.
Slivers: Efeitos de Rolagem Avançados
Você provavelmente já viu aquele efeito em que uma grande imagem de cabeçalho fica no topo da tela e, conforme você rola, ela vai encolhendo até virar uma barra de navegação normal. Ou um cabeçalho que fica fixo enquanto o conteúdo rola por baixo. Esses efeitos são implementados com Slivers.
Slivers são partes de uma área roláveis que podem ter comportamento especial. O widget CustomScrollView cria uma área de rolagem que pode conter vários Slivers com comportamentos diferentes. Os Slivers mais usados são SliverAppBar (barra que pode se expandir, recolher e flutuar), SliverList (lista de itens dentro de um CustomScrollView) e SliverGrid (grade de itens).
Exemplo — AppBar expansível com lista de conteúdo
class TelaComAppBarExpansivel extends StatelessWidget {
const TelaComAppBarExpansivel({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: [
// AppBar que se expande e recolhe com a rolagem
SliverAppBar(
expandedHeight: 200, // altura quando totalmente expandida
floating: false, // não reaparece ao rolar para cima
pinned: true, // fica fixo no topo quando recolhida
flexibleSpace: FlexibleSpaceBar(
title: const Text('Minha Tela'),
background: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.blue, Colors.indigo],
),
),
),
),
),
// Lista de itens abaixo do AppBar
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
return ListTile(
leading: CircleAvatar(child: Text('${index + 1}')),
title: Text('Item ${index + 1}'),
);
},
childCount: 30,
),
),
],
),
);
}
}Quando o usuário começa a rolar, o SliverAppBar vai gradualmente encolhendo de 200 pixels de altura até o tamanho normal de uma AppBar. Com pinned: true, ela fica fixada no topo mesmo depois de completamente recolhida.
SizedBox, Padding e Container: Os Widgets de Espaçamento e Decoração
Três widgets são usados com enorme frequência para controlar espaçamento e aparência: SizedBox, Padding e Container. Eles parecem simples à primeira vista, mas há nuances importantes sobre quando usar cada um.
O SizedBox é o mais simples: ele apenas impõe um tamanho. SizedBox(width: 16) cria um espaço horizontal fixo de 16 pixels. SizedBox(height: 24) cria um espaço vertical de 24 pixels. É o widget ideal para espaçamentos entre elementos numa Row ou Column. SizedBox(width: double.infinity) força o filho a ocupar toda a largura disponível.
O Padding aplica um espaçamento interno em volta do filho, usando um EdgeInsets. Ele é semanticamente mais preciso do que um Container apenas para espaçamento: deixa claro que a intenção é adicionar padding, sem alterar a aparência visual do widget.
O Container é o mais versátil dos três: ele pode aplicar padding, margem, cor de fundo, borda, sombra, gradiente e transformações. Quando você precisa de mais de uma dessas características simultaneamente, o Container é a escolha natural. Quando você precisa apenas de espaçamento, prefira Padding ou SizedBox — o código fica mais legível.
Exemplo — A diferença entre SizedBox, Padding e Container
// 1. SizedBox para espaçamento entre widgets
Column(
children: [
const Text('Primeiro item'),
const SizedBox(height: 16), // espaço vertical
const Text('Segundo item'),
],
)
// 2. Padding para espaçamento interno
Padding(
padding: const EdgeInsets.all(16), // 16px em todos os lados
child: const Text('Texto com espaçamento interno'),
)
// 3. Container para decoração completa
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.blue.shade200),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: const Text('Texto dentro de um container decorado'),
)Estratégias para Layouts Adaptativos em Tablets
Quando seu aplicativo precisa funcionar bem tanto em smartphones quanto em tablets, você precisa de estratégias mais elaboradas do que simplesmente usar MediaQuery. A abordagem profissional consiste em definir breakpoints — larguras a partir das quais o layout muda de configuração — e construir os widgets de forma que reajam a esses breakpoints de maneira coerente.
Uma convenção amplamente usada define três faixas: telas menores que 600 pixels de largura são tratadas como smartphones em modo retrato; entre 600 e 900 pixels, como tablets ou smartphones em modo paisagem; acima de 900 pixels, como tablets grandes ou desktops. Com esses breakpoints, você pode criar um sistema consistente que direciona o layout correto para cada faixa.
Exemplo — Classe auxiliar de breakpoints
// lib/utils/responsive.dart
// Uma classe utilitária simples para gerenciar breakpoints
class Responsive {
static const double _mobile = 600;
static const double _tablet = 900;
static bool isMobile(BuildContext context) =>
MediaQuery.of(context).size.width < _mobile;
static bool isTablet(BuildContext context) {
final largura = MediaQuery.of(context).size.width;
return largura >= _mobile && largura < _tablet;
}
static bool isDesktop(BuildContext context) =>
MediaQuery.of(context).size.width >= _tablet;
// Retorna um valor diferente para cada tamanho de tela
static T valor<T>({
required BuildContext context,
required T mobile,
T? tablet,
T? desktop,
}) {
if (isDesktop(context) && desktop != null) return desktop;
if (isTablet(context) && tablet != null) return tablet;
return mobile;
}
}Com essa classe, o código de layout fica limpo e expressivo:
SafeArea: Respeitando as Bordas do Dispositivo
Os smartphones modernos têm um desafio visual que os desenvolvedores precisam levar a sério: as áreas de sistema. A barra de status no topo (onde ficam a hora e os ícones de bateria e Wi-Fi), o notch em dispositivos com câmera frontal recortada na tela, a “ilha dinâmica” dos iPhones mais novos, e a barra de gestos no fundo — todas essas regiões pertencem ao sistema operacional, não ao aplicativo. Se você posicionar um botão importante perto da borda inferior sem cuidado, ele pode ficar escondido atrás da barra de gestos do Android. Se você colocar um texto no topo sem padding adequado, ele pode ficar coberto pela barra de status.
O widget SafeArea resolve exatamente esse problema de forma automática e elegante. Quando você envolve um widget com SafeArea, o Flutter consulta o sistema operacional para saber quais são as regiões de sistema ativas e adiciona automaticamente o padding necessário para que o conteúdo do seu aplicativo nunca fique atrás dessas regiões.
Exemplo — SafeArea em uma tela personalizada
// Cenário: uma tela sem AppBar (fullscreen) onde você precisa garantir
// que o conteúdo não fica atrás da barra de status ou do notch
class TelaFullscreen extends StatelessWidget {
const TelaFullscreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
// extendBodyBehindAppBar: true faz o body ir atrás da AppBar
backgroundColor: Colors.deepPurple,
body: SafeArea(
// bottom: false não adiciona padding na borda inferior
// (útil quando você tem uma BottomNavigationBar)
bottom: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Text(
'Bem-vindo!',
style: TextStyle(
color: Colors.white,
fontSize: 32,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 8),
Text(
'O conteúdo começa abaixo da barra de status.',
style: TextStyle(color: Colors.white70, fontSize: 16),
),
],
),
),
],
),
),
);
}
}Sem o SafeArea, o texto “Bem-vindo!” poderia aparecer atrás da barra de status no topo, misturando-se com os ícones de bateria e Wi-Fi. Com o SafeArea, o Flutter garante que o conteúdo começa sempre abaixo da última região de sistema.
AspectRatio e FractionallySizedBox: Proporções Precisas
Em design de interfaces, proporções consistentes fazem a diferença entre uma tela que parece profissional e uma que parece amadora. Dois widgets pouco conhecidos — mas muito úteis — ajudam a manter proporções precisas sem hardcodar dimensões absolutas.
O AspectRatio força um widget a manter uma proporção específica entre largura e altura. Se você quer que um elemento de vídeo ocupe toda a largura disponível mantendo a proporção 16:9 — como um player de vídeo —, o AspectRatio faz isso automaticamente independentemente do dispositivo. O parâmetro aspectRatio recebe um número decimal que representa largura dividido por altura. Para 16:9, o valor é 16/9 (que é aproximadamente 1.78).
O FractionallySizedBox define o tamanho de um widget como uma fração do espaço disponível. Em vez de calcular width: larguraTela * 0.8 manualmente com MediaQuery, você pode usar FractionallySizedBox(widthFactor: 0.8) e o widget ocupa 80% da largura do pai automaticamente.
Exemplo — Player de vídeo com proporção 16:9 e botão centralizado
// AspectRatio: o container do vídeo mantém proporção 16:9
// independentemente da largura da tela
AspectRatio(
aspectRatio: 16 / 9,
child: Container(
color: Colors.black,
child: const Center(
child: Icon(
Icons.play_circle_outline,
color: Colors.white,
size: 64,
),
),
),
)
// ---
// FractionallySizedBox: botão que ocupa sempre 70% da largura disponível
FractionallySizedBox(
widthFactor: 0.7, // 70% da largura do widget pai
child: ElevatedButton(
onPressed: () {},
child: const Text('Continuar'),
),
)IntrinsicHeight e IntrinsicWidth: Sincronizando Tamanhos
Imagine que você tem dois cartões lado a lado numa Row, e quer que ambos tenham a mesma altura — mesmo que um deles tenha muito mais texto do que o outro. Normalmente, cada filho de uma Row tem a altura que seu conteúdo precisa, o que faz os cartões ficarem com alturas diferentes, deixando o layout desequilibrado.
O IntrinsicHeight resolve isso: ele mede a altura que cada filho gostaria de ter e força todos os filhos da Row a assumirem a maior dessas alturas. O resultado é uma linha de widgets com alturas uniformes, sem nenhum cálculo manual.
É importante saber que IntrinsicHeight tem um custo de performance, pois ele precisa fazer uma passagem extra de medição antes de construir o layout. Por isso, ele não deve ser usado dentro de ListView ou em layouts que se repetem muitas vezes. Para listas com muitos itens, prefira dar uma altura fixa aos cards ou usar crossAxisAlignment: CrossAxisAlignment.stretch.
Exemplo — Dois cards com alturas iguais
// Sem IntrinsicHeight: os cards têm alturas diferentes
// Com IntrinsicHeight: ambos ficam com a altura do maior
IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch, // essencial com IntrinsicHeight
children: [
Expanded(
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Text('Card A', style: TextStyle(fontWeight: FontWeight.bold)),
SizedBox(height: 8),
Text('Este card tem apenas uma linha de texto.'),
],
),
),
),
),
const SizedBox(width: 8),
Expanded(
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Text('Card B', style: TextStyle(fontWeight: FontWeight.bold)),
SizedBox(height: 8),
Text('Este card tem muito mais texto do que o card ao lado. '
'O IntrinsicHeight garante que ambos ficam com a mesma altura.'),
],
),
),
),
),
],
),
)Spacer: Distribuindo Espaço em Branco de Forma Expressiva
O Spacer é um widget simples e elegante: ele ocupa todo o espaço flexível disponível dentro de uma Row ou Column, empurrando os widgets adjacentes para as extremidades. Funciona como um Expanded com um widget filho invisível. A diferença é semântica: quando você usa Spacer, está dizendo explicitamente que aquele espaço é intencional e vazio — e o código fica muito mais legível do que um Expanded(child: SizedBox()).
Exemplo — Rodapé com logo à esquerda e botão à direita
// Um rodapé onde o logo fica à esquerda e o botão à direita,
// com todo o espaço do meio sendo vazio
Row(
children: [
const FlutterLogo(size: 32),
const Spacer(), // empurra tudo para os extremos
TextButton(
onPressed: () {},
child: const Text('Sobre o App'),
),
],
)
// Também é possível usar flex no Spacer para criar distribuições assimétricas:
Row(
children: [
const Text('Esquerda'),
const Spacer(flex: 2), // dois terços do espaço
const Text('Centro'),
const Spacer(flex: 1), // um terço do espaço
const Text('Direita'),
],
)ConstrainedBox, UnconstrainedBox e OverflowBox: Controle Avançado de Constraints
Há situações em que você precisa exercer controle preciso sobre as constraints que chegam a um widget — seja para impor limites máximos, seja para remover constraints que o pai está impondo. Para isso, o Flutter oferece três widgets especializados.
O ConstrainedBox aplica constraints adicionais a um filho. Com BoxConstraints(minHeight: 100), por exemplo, você garante que o widget filho nunca vai ter menos de 100 pixels de altura — mesmo que seu conteúdo seja menor. Com BoxConstraints(maxWidth: 400), você evita que um widget cresça além de 400 pixels — útil para manter formulários com largura razoável em telas muito largas.
O UnconstrainedBox remove todas as constraints do filho, deixando-o crescer para o tamanho que quiser. Use com muito cuidado: se o filho crescer além do espaço disponível, causará overflow. Mas em casos específicos — como medir o tamanho natural de um widget antes de posicioná-lo — ele é indispensável.
O OverflowBox permite que o filho ignore as constraints do pai e assuma um tamanho maior, mas sem gerar o erro de overflow. O conteúdo que ultrapassar o OverflowBox simplesmente é cortado.
Exemplo — Formulário com largura máxima em telas grandes
// Em tablets, um formulário de cadastro não precisa ocupar os 900px
// de largura disponíveis. ConstrainedBox limita a 500px.
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 500),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const TextField(decoration: InputDecoration(labelText: 'Nome')),
const SizedBox(height: 12),
const TextField(decoration: InputDecoration(labelText: 'E-mail')),
const SizedBox(height: 24),
ElevatedButton(onPressed: () {}, child: const Text('Enviar')),
],
),
),
),
)Padding e EdgeInsets: Todos os Tipos de Espaçamento Interno
O EdgeInsets é a classe que especifica o espaçamento interno em todas as direções. Existem quatro construtores, cada um útil em um contexto diferente, e conhecê-los deixa o código mais expressivo e menos verboso.
EdgeInsets.all(16) aplica 16 pixels em todos os quatro lados — ideal para padding uniforme em cards e containers. EdgeInsets.symmetric(horizontal: 24, vertical: 12) aplica 24 pixels à esquerda e à direita, e 12 pixels no topo e na base — perfeito para botões e tags. EdgeInsets.only(top: 8, bottom: 16) aplica valores específicos apenas nos lados indicados — útil quando uma borda precisa de mais espaço que as outras. EdgeInsets.fromLTRB(left, top, right, bottom) especifica cada lado individualmente na ordem esquerda, topo, direita, base.
Exemplo — Diferentes tipos de EdgeInsets aplicados
// Padding uniforme em todos os lados — para conteúdo interno de cards
Padding(
padding: const EdgeInsets.all(16),
child: const Text('Conteúdo com espaçamento igual em todos os lados'),
)
// Padding assimétrico — para botões que precisam de mais espaço horizontal
ElevatedButton(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
),
onPressed: () {},
child: const Text('Botão com padding assimétrico'),
)
// Padding apenas embaixo — para separar seções sem padding no topo
Padding(
padding: const EdgeInsets.only(bottom: 24, left: 16, right: 16),
child: const Text('Esta seção tem espaço extra abaixo'),
)Construindo um Layout Completo: Passo a Passo
Para consolidar tudo que você aprendeu, vamos construir juntos uma tela mais complexa do zero — uma tela de detalhes de produto para um aplicativo de e-commerce. Ela vai usar SingleChildScrollView, Stack com imagem e gradiente, Wrap para tags, Row para preço e avaliação, e Expanded para distribuição de espaço.
O processo começa sempre pelo diagrama. Antes de escrever uma linha de código, visualize a estrutura.
Exemplo — Tela de detalhes de produto completa
class TelaDetalhesProduto extends StatelessWidget {
const TelaDetalhesProduto({super.key});
@override
Widget build(BuildContext context) {
const tags = ['Orgânico', 'Sem glúten', 'Vegano', 'Sem lactose'];
return Scaffold(
// extendBodyBehindAppBar: o body vai atrás da barra de status
backgroundColor: Colors.white,
body: Stack(
children: [
// Conteúdo rolável
SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Imagem do produto com proporção 4:3
AspectRatio(
aspectRatio: 4 / 3,
child: Container(
color: Colors.grey.shade200,
child: const Center(
child: Icon(Icons.image, size: 80, color: Colors.grey),
),
),
),
// Conteúdo textual
Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Nome do produto e botão de favorito
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Expanded(
child: Text(
'Granola Artesanal de Castanhas',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
),
IconButton(
icon: const Icon(Icons.favorite_border),
color: Colors.red,
onPressed: () {},
),
],
),
const SizedBox(height: 8),
// Preço e avaliação na mesma linha
Row(
children: [
const Text(
'R\$ 42,90',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
color: Colors.green,
),
),
const Spacer(),
const Icon(Icons.star, color: Colors.amber, size: 20),
const SizedBox(width: 4),
const Text(
'4,8 (127 avaliações)',
style: TextStyle(fontSize: 14, color: Colors.grey),
),
],
),
const SizedBox(height: 16),
// Tags com Wrap (quebram linha automaticamente)
Wrap(
spacing: 8,
runSpacing: 8,
children: tags.map((tag) {
return Chip(
label: Text(tag, style: const TextStyle(fontSize: 12)),
backgroundColor: Colors.green.shade50,
side: BorderSide(color: Colors.green.shade200),
);
}).toList(),
),
const SizedBox(height: 16),
// Descrição do produto
const Text(
'Nossa granola artesanal é preparada com ingredientes selecionados, '
'sem conservantes artificiais. Rica em fibras e proteínas, '
'é a escolha perfeita para um café da manhã nutritivo e saboroso. '
'Cada lote é produzido em pequena escala para garantir a máxima qualidade.',
style: TextStyle(fontSize: 15, height: 1.6, color: Colors.black87),
),
// Espaço para o botão fixo não cobrir o conteúdo
const SizedBox(height: 80),
],
),
),
],
),
),
// Botão fixo na parte inferior
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, -2),
),
],
),
child: ElevatedButton.icon(
onPressed: () {},
icon: const Icon(Icons.shopping_cart_outlined),
label: const Text('Adicionar ao Carrinho'),
style: ElevatedButton.styleFrom(
minimumSize: const Size(double.infinity, 52),
),
),
),
),
],
),
);
}
}Este exemplo usa Stack para sobrepor um botão fixo sobre um conteúdo rolável — um padrão extremamente comum em aplicativos de e-commerce, delivery e reservas. O botão fica sempre visível na tela enquanto o usuário explora o conteúdo, e o SizedBox(height: 80) no final da lista garante que o último item não fique escondido atrás do botão quando o usuário rola até o fim.
Colocando em Prática: Decomposição em Widgets
Um dos princípios mais importantes do desenvolvimento Flutter profissional é a decomposição de telas em widgets menores e reutilizáveis. Telas grandes e complexas com um único método build gigante são difíceis de ler, difíceis de manter e impossíveis de reutilizar. A boa prática é extrair cada seção lógica da tela em seu próprio widget.
A pergunta que você deve fazer enquanto cria um layout é: “esta parte da interface tem uma responsabilidade bem definida e poderia aparecer em outro lugar do aplicativo?”. Se a resposta for sim, ela merece ser um widget separado.
Vamos ver um exemplo prático de como uma tela de lista de notícias pode ser decomposta.
graph TD
A["TelaNoticias<br/>(Scaffold)"] --> B["AppBar"]
A --> C["ListView.builder"]
C --> D["CardNoticia<br/>(widget reutilizável)"]
D --> E["ImagemDestaque"]
D --> F["Column (conteúdo)"]
F --> G["BadgeCategoria"]
F --> H["Text (título)"]
F --> I["Row (metadados)"]
I --> J["Text (autor)"]
I --> K["Text (data)"]
style A fill:#cfe2ff,stroke:#0d6efd
style B fill:#d1ecf1,stroke:#17a2b8
style C fill:#d1ecf1,stroke:#17a2b8
style D fill:#d4edda,stroke:#28a745
style E fill:#fff3cd,stroke:#ffc107
style F fill:#fff3cd,stroke:#ffc107
style G fill:#f8d7da,stroke:#dc3545
style H fill:#f8d7da,stroke:#dc3545
style I fill:#f8d7da,stroke:#dc3545
style J fill:#e2d9f3,stroke:#6f42c1
style K fill:#e2d9f3,stroke:#6f42c1
Exemplo — Decomposição de uma tela de lista de notícias
// lib/features/noticias/widgets/card_noticia.dart
// Widget reutilizável para exibir uma notícia
class CardNoticia extends StatelessWidget {
final String titulo;
final String autor;
final String categoria;
final String dataPublicacao;
const CardNoticia({
super.key,
required this.titulo,
required this.autor,
required this.categoria,
required this.dataPublicacao,
});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Badge de categoria
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.blue.shade100,
borderRadius: BorderRadius.circular(4),
),
child: Text(
categoria,
style: TextStyle(
fontSize: 12,
color: Colors.blue.shade800,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(height: 8),
// Título da notícia
Text(
titulo,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
// Metadados: autor e data
Row(
children: [
const Icon(Icons.person_outline, size: 14, color: Colors.grey),
const SizedBox(width: 4),
Text(autor, style: const TextStyle(fontSize: 12, color: Colors.grey)),
const Spacer(),
const Icon(Icons.calendar_today_outlined, size: 14, color: Colors.grey),
const SizedBox(width: 4),
Text(dataPublicacao, style: const TextStyle(fontSize: 12, color: Colors.grey)),
],
),
],
),
),
);
}
}
// lib/features/noticias/pages/tela_noticias.dart
// A tela principal, limpa e legível
class TelaNoticias extends StatelessWidget {
const TelaNoticias({super.key});
@override
Widget build(BuildContext context) {
// Dados de exemplo (no Módulo 09 virão de uma API real)
final noticias = [
{'titulo': 'Flutter 4.0 lançado', 'autor': 'Dev News', 'categoria': 'Tecnologia', 'data': '24/02/2026'},
{'titulo': 'Dart ganha novos recursos', 'autor': 'Dart Blog', 'categoria': 'Programação', 'data': '23/02/2026'},
];
return Scaffold(
appBar: AppBar(title: const Text('Notícias')),
body: ListView.builder(
itemCount: noticias.length,
itemBuilder: (context, index) {
final noticia = noticias[index];
return CardNoticia(
titulo: noticia['titulo']!,
autor: noticia['autor']!,
categoria: noticia['categoria']!,
dataPublicacao: noticia['data']!,
);
},
),
);
}
}Perceba como a TelaNoticias ficou limpa e legível. Ela não precisa saber como o CardNoticia está organizado internamente — ela apenas passa os dados. E o CardNoticia pode ser reutilizado em qualquer outra parte do aplicativo.
O Diagrama de Árvore de Widgets: Ferramenta de Projeto
Uma das tarefas do Projeto Integrador neste módulo é criar um diagrama de árvore de widgets para as telas principais do seu aplicativo. Esse diagrama não é um detalhe burocrático — ele é uma ferramenta de projeto genuinamente útil.
Antes de começar a escrever código, desenhar a árvore de widgets força você a pensar: quais são os componentes da tela? Como eles se relacionam? Quais são widgets reutilizáveis? Onde vão os widgets de layout (Row, Column, Stack)? Essa reflexão antecipada evita reescritas de código e facilita a divisão de trabalho dentro do grupo.
Vamos ver como construir esse diagrama para uma tela de perfil de usuário:
graph TD
A["Scaffold"] --> B["AppBar<br/>título: 'Perfil'"]
A --> C["SingleChildScrollView"]
C --> D["Column<br/>crossAxisAlignment: stretch"]
D --> E["Stack<br/>(foto + badge)"]
E --> E1["CircleAvatar<br/>(foto de perfil)"]
E --> E2["Positioned<br/>(badge de verificado)"]
D --> F["SizedBox height:16"]
D --> G["Text<br/>(nome do usuário)"]
D --> H["Text<br/>(cargo/bio)"]
D --> I["Wrap<br/>(tags de interesse)"]
I --> I1["Chip (tag 1)"]
I --> I2["Chip (tag 2)"]
I --> I3["Chip (tag n)"]
D --> J["Divider"]
D --> K["Column<br/>(seção de estatísticas)"]
K --> K1["Row<br/>(publicações | seguidores | seguindo)"]
style A fill:#cfe2ff,stroke:#0d6efd
style C fill:#d1ecf1,stroke:#17a2b8
style D fill:#d1ecf1,stroke:#17a2b8
style E fill:#fff3cd,stroke:#ffc107
style G fill:#d4edda,stroke:#28a745
style I fill:#d4edda,stroke:#28a745
style K fill:#d4edda,stroke:#28a745
Esse diagrama mostra, de um relance, a estrutura completa da tela. Qualquer membro do grupo pode olhar para ele e entender a organização antes mesmo de abrir o código.
Aplicando no Projeto Integrador: As Telas Principais
Durante as aulas práticas deste módulo, seu grupo vai construir os layouts das telas principais do aplicativo. O processo começa antes do código: identifiquem as três ou quatro telas mais importantes do projeto, decomponham cada uma em widgets, documentem essa decomposição no diagrama de árvore e só então iniciem a implementação.
A ordem de trabalho recomendada é a seguinte. Primeiro, a equipe decide coletivamente quais são as telas principais e qual delas vai ser implementada por cada membro. Depois, cada membro cria o diagrama de árvore da sua tela e apresenta ao grupo para revisão e ajuste. Com o diagrama aprovado, a implementação começa — e o diagrama serve como guia durante toda a codificação.
Ao final das aulas práticas, o grupo deve verificar os layouts em diferentes tamanhos de tela no emulador, corrigindo eventuais problemas de overflow e garantindo que Expanded, Flexible e MediaQuery estejam sendo usados corretamente onde o conteúdo precisa se adaptar.
Atenção ao overflow: Quando o Flutter encontra um widget que tenta ocupar mais espaço do que o disponível, ele exibe uma faixa amarela e preta de aviso (o famoso “overflow indicator”). Isso nunca deve aparecer em um aplicativo em produção. Se aparecer durante o desenvolvimento, use o Expanded, o Flexible, o SingleChildScrollView ou reveja a lógica de constraints do widget problemático.
Erros Comuns de Layout e Como Corrigi-los
Ao longo de sua jornada com Flutter, você vai se deparar com alguns erros de layout que aparecem com frequência. Conhecê-los antecipadamente permite resolvê-los rapidamente, sem horas de frustração. Vamos falar dos mais comuns.
“Vertical viewport was given unbounded height”
Este é provavelmente o erro de layout mais frequente em Flutter. Ele acontece quando você coloca um widget de rolagem — como ListView ou GridView — dentro de outro widget que também tem altura ilimitada — como uma Column que não tem altura definida.
A causa é sempre a mesma: o widget de rolagem precisa saber qual é o espaço máximo que ele pode ocupar para calcular quais itens exibir. Se o pai não define esse limite, o widget de rolagem fica sem saber qual altura assumir.
Problema e Solução — Unbounded Height
// ERRADO: Column com altura infinita -> ListView sem saber seu tamanho
Column(
children: [
const Text('Título'),
ListView.builder( // ERRO: column passa altura infinita para o ListView
itemCount: 10,
itemBuilder: (_, i) => ListTile(title: Text('Item $i')),
),
],
)
// CORRETO: Envolva o ListView com Expanded dentro da Column
Column(
children: [
const Text('Título'),
Expanded( // diz ao ListView: "ocupe todo o espaço restante da Column"
child: ListView.builder(
itemCount: 10,
itemBuilder: (_, i) => ListTile(title: Text('Item $i')),
),
),
],
)“RenderFlex overflowed by X pixels”
Este erro aparece quando um filho de uma Row ou Column tenta ocupar mais espaço do que o disponível. O Flutter indica visualmente o problema com uma faixa amarela e preta — diagonal, como uma fita de sinalização de obra — no canto do widget.
As causas mais comuns são: um texto muito longo em uma Row que não vai quebrar linha, uma Column com muitos filhos de tamanho fixo que somados excedem a altura disponível, ou um widget com largura fixa que não cabe no espaço disponível.
Problema e Soluções — RenderFlex Overflow
// ERRADO: texto pode ser longo demais para a Row
Row(
children: [
const Icon(Icons.person),
Text(nomeDoUsuario), // se o nome for longo, vai causar overflow
const Icon(Icons.arrow_forward),
],
)
// SOLUÇÃO 1: envolva o Text com Expanded
Row(
children: [
const Icon(Icons.person),
Expanded(
child: Text(
nomeDoUsuario,
overflow: TextOverflow.ellipsis, // corta com "..." se for longo demais
maxLines: 1,
),
),
const Icon(Icons.arrow_forward),
],
)
// SOLUÇÃO 2: use Flexible para que o texto se adapte ao espaço
Row(
children: [
const Icon(Icons.person),
Flexible(
child: Text(
nomeDoUsuario,
softWrap: true, // permite quebrar linha
),
),
const Icon(Icons.arrow_forward),
],
)Boas Práticas de Layout no Flutter
Com o tempo, desenvolvedores Flutter experientes desenvolvem um conjunto de boas práticas que tornam o código de layout mais legível, manutenível e performático. Não existe uma lista definitiva — as melhores práticas evoluem com a comunidade e com a plataforma —, mas algumas delas são amplamente aceitas e valem a pena internalizar desde o início.
A primeira boa prática é preferir composição a inheritance. Quando você quer criar uma variação de um widget existente — um botão com estilo diferente, por exemplo —, a abordagem Flutter é criar um novo widget que contém o widget original configurado de uma forma específica, e não criar uma subclasse dele. O ElevatedButton não foi projetado para ser estendido; ele foi projetado para ser configurado por parâmetros e estilizado por ButtonStyle.
A segunda é extrair widgets antes que fiquem grandes. Uma boa heurística é: se o método build de um widget tem mais de 50 linhas, provavelmente já passou da hora de extrair partes dele em widgets separados. Widgets menores são mais fáceis de ler, de testar e de reutilizar.
A terceira é evitar layouts desnecessariamente profundos. Cada nível de aninhamento na árvore de widgets tem um custo de legibilidade. Quando você perceber que está aninhando cinco ou seis níveis de widgets apenas para obter um efeito simples, provavelmente existe uma abordagem mais direta. Por exemplo, em vez de Padding(padding: ..., child: Container(color: ..., child: ...)), você pode usar diretamente Container(padding: ..., color: ..., child: ...).
A quarta é usar constantes onde possível. Sempre que um widget não recebe dados dinâmicos, declare-o com const. O Flutter pula a reconstrução de widgets const durante o ciclo de rebuild, o que pode melhorar significativamente a performance em telas com muitas reconstruções.
Exemplo — Const em widgets folha melhora a performance
// BOM: o SizedBox e o Icon são declarados como const
// Flutter não os reconstrói quando a tela é atualizada
Column(
children: [
const SizedBox(height: 16),
const Icon(Icons.star, color: Colors.amber),
Text(nota), // este não pode ser const porque recebe dado dinâmico
const SizedBox(height: 8),
const Text('Avaliação'), // este pode ser const, pois o texto nunca muda
],
)A quinta boa prática é documentar a intenção dos widgets customizados. Quando você cria um widget reutilizável, adicione um comentário de documentação Dart (usando ///) explicando para que ele serve, quais são seus parâmetros obrigatórios e em qual contexto ele deve ser usado. Isso é especialmente importante em projetos de equipe, onde outros membros do grupo vão usar os seus widgets.
Flutter DevTools: Depurando Layouts Visualmente
O Flutter tem uma ferramenta de desenvolvimento visual chamada Flutter DevTools que inclui um painel de inspeção de widgets — o Widget Inspector. Com ele, você pode clicar em qualquer elemento na tela do emulador e ver imediatamente qual widget está por trás daquele elemento, onde ele está na árvore, quais são suas dimensões e quais constraints ele recebeu do pai.
Esta ferramenta transforma o debugging de layouts de uma experiência de adivinhação em uma investigação visual e objetiva. Você acessa a seção de constraints e vê exatamente o que o pai passou e o que o filho escolheu. Se algum widget tiver tamanho zero quando deveria ter um tamanho visível, você encontra o problema imediatamente.
Para acessar o Flutter DevTools, basta pressionar o ícone de inspeção na barra lateral do VS Code enquanto o aplicativo está rodando em modo debug. Alternativamente, o terminal de execução do Flutter exibe uma URL que abre o DevTools diretamente no navegador.
No Widget Inspector, duas funcionalidades são particularmente valiosas para debugging de layout. A primeira é o Select Widget Mode: você ativa esse modo e clica em qualquer ponto da tela do emulador — o Inspector seleciona e destaca o widget correspondente na árvore, mostrando seu tipo, suas propriedades e suas dimensões. A segunda é a visualização de constraints e layout boundaries: com ela, você vê linhas coloridas delimitando o espaço que cada widget ocupa e as constraints que ele recebeu.
Dica profissional: Sempre que estiver depurando um problema de layout e não entender por que um widget está com o tamanho errado, a primeira coisa a fazer é abrir o Widget Inspector e inspecionar as constraints. Em 90% dos casos, a causa fica evidente imediatamente.
Animações Básicas de Layout: AnimatedContainer
Os layouts não precisam ser estáticos. O Flutter torna muito simples adicionar transições suaves quando o tamanho ou a posição de um widget muda. O AnimatedContainer é a forma mais fácil de fazer isso: ele funciona exatamente como um Container normal, mas quando qualquer uma de suas propriedades muda — largura, altura, cor, borda, e assim por diante —, a mudança acontece de forma animada, com a curva de animação que você escolher.
Isso é especialmente útil para dar feedback visual ao usuário: um botão que se expande quando pressionado, um painel que revela mais informações quando clicado, um campo de texto que muda de borda para indicar foco. O AnimatedContainer implementa tudo isso automaticamente — você apenas atualiza o estado com setState e o Flutter cuida de interpolar todos os valores entre o estado anterior e o novo.
Exemplo — Painel expansível com AnimatedContainer
class PainelExpansivel extends StatefulWidget {
const PainelExpansivel({super.key});
@override
State<PainelExpansivel> createState() => _PainelExpansivelState();
}
class _PainelExpansivelState extends State<PainelExpansivel> {
bool _expandido = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
setState(() {
_expandido = !_expandido;
});
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 300), // duração da animação
curve: Curves.easeInOut, // curva de aceleração
width: double.infinity,
height: _expandido ? 200 : 60, // muda de 60 para 200 ao expandir
decoration: BoxDecoration(
color: _expandido ? Colors.blue.shade100 : Colors.grey.shade200,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _expandido ? Colors.blue : Colors.grey,
),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text(
'Mais informações',
style: TextStyle(fontWeight: FontWeight.bold),
),
const Spacer(),
AnimatedRotation(
turns: _expandido ? 0.5 : 0.0, // rotaciona 180°
duration: const Duration(milliseconds: 300),
child: const Icon(Icons.keyboard_arrow_down),
),
],
),
if (_expandido) ...[
const SizedBox(height: 12),
const Text(
'Este conteúdo só aparece quando o painel está expandido. '
'Use AnimatedContainer para transições suaves entre estados.',
),
],
],
),
),
),
);
}
}Observe como toda a magia acontece com apenas uma mudança de estado (_expandido = !_expandido). O AnimatedContainer detecta que a altura e a cor mudaram e interpola automaticamente entre os valores antigos e os novos ao longo de 300 milissegundos. Você não precisa escrever nenhuma lógica de animação.
Conexão com os Próximos Módulos
O layout que você está construindo agora é a fundação visual do seu aplicativo. Tudo que vem a seguir vai se apoiar nessa base.
No Módulo 05, você vai usar o go_router para conectar as telas que está construindo agora. O BottomNavigationBar do Scaffold vai ganhar sua navegação real, e as telas vão começar a conversar umas com as outras através de rotas. Ter os layouts bem definidos agora torna esse trabalho muito mais fluido.
No Módulo 06, os formulários que você já tem esboçados — telas de login, cadastro, criação de itens — vão ganhar validação real, controle de foco e feedback visual. A estrutura de SingleChildScrollView + Column que você aprendeu aqui é exatamente o padrão que formulários mobile bem feitos seguem.
No Módulo 07, o Provider vai fazer com que partes dos layouts se reconstruam automaticamente quando os dados mudam. A árvore de widgets que você está documentando agora vai ser a referência para entender quais partes precisam reagir a mudanças de estado e quais não precisam.
Os widgets ListView.builder e GridView.builder que você aprendeu aqui vão, a partir do Módulo 09, exibir dados reais vindos de uma API. A estrutura do widget não muda — apenas a fonte dos dados muda. Essa separação entre estrutura e dados é uma das grandes virtudes do Flutter.
Resumo: O Que Você Aprendeu Neste Módulo
Chegamos ao final de um módulo repleto de conceitos práticos e diretamente aplicáveis. Vamos recapitular.
Você compreendeu a regra fundamental do layout Flutter: constraints descem do pai para o filho, o filho decide seu tamanho dentro dessas constraints, e o pai posiciona o filho. Entendeu que quebrar essa regra é a causa da maioria dos erros de layout.
Você dominou Row e Column — seus parâmetros mainAxisAlignment, crossAxisAlignment e mainAxisSize — e aprendeu a usar Expanded e Flexible para distribuição proporcional de espaço. Conheceu o Stack com Positioned e Align para sobrepor elementos com precisão. Aprendeu a trabalhar com listas e grades eficientes usando ListView.builder e GridView.builder, e a tornar telas roláveis com SingleChildScrollView. Descobriu o Wrap para conteúdo que flui automaticamente para múltiplas linhas.
Você aprendeu a usar MediaQuery para conhecer o dispositivo e adaptar layouts à orientação e ao tamanho da tela, e LayoutBuilder para tomar decisões baseadas no espaço real disponível para um widget específico. Viu como os Slivers — em particular o SliverAppBar — permitem efeitos de rolagem avançados que encantam os usuários.
Por fim, você aprendeu a decompor telas em widgets menores e reutilizáveis, documentando essa decomposição em diagramas de árvore que facilitam o trabalho em equipe e a manutenção do código.
Na próxima aula, você trará esses conceitos para o laboratório e vai construir os layouts das telas principais do seu Projeto Integrador. Antes de chegar, pense em quais são as telas mais importantes do seu aplicativo e como elas poderiam ser decompostas em widgets. Essa reflexão prévia vai tornar o tempo no laboratório muito mais produtivo.
Antes da próxima aula: Execute todos os exemplos deste módulo no seu computador. Experimente mudar os valores de mainAxisAlignment, crossAxisAlignment e flex para ver o efeito em tempo real. Depois, abra o emulador e tente reproduzir o layout de uma tela de um aplicativo que você usa no dia a dia — decompondo-o em widgets da mesma forma que fizemos aqui. Essa prática vai acelerar muito o seu trabalho no laboratório.