Andamiaje, Layout y Composición en Flutter
En esta sesión aprenderás a maquetar vistas móviles en Flutter: estructurar el andamiaje (Scaffold y SafeArea), distribuir elementos en pantalla (Column, Row, Expanded), aplicar estilos visuales (Container y Padding), y solucionar desbordes (RenderFlex overflow) con SingleChildScrollView.
1. Convención Arquitectural: Screen frente a Page
En Flutter, una pantalla bien estructurada separa el marco exterior del contenido interno:
Screen(Pantalla de Ruta): Unidad a la que se navega. Es la única responsable de devolver elScaffold(con suAppBar,BottomNavigationBaroFloatingActionButton).Page(Lienzo Interno): Contenido visual que vive dentro delbodyde laScreen. Nunca debe devolver unScaffold, solo widgets de layout (SafeArea,Column,ListView, etc.).
- Diagrama Visual
- Código en Dart
import 'package:flutter/material.dart';
import '../pages/home_page.dart';
// SCREEN: Marco global con Scaffold
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Mi Aplicación'),
centerTitle: true,
),
// Hospeda la Page en su body. Cero Scaffolds anidados.
body: const HomePage(),
);
}
}
import 'package:flutter/material.dart';
// PAGE: Lienzo de contenido (NUNCA devuelve Scaffold)
class HomePage extends StatelessWidget {
const HomePage({super.key});
Widget build(BuildContext context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Text('Bienvenido a la vista interna'),
SizedBox(height: 12),
Text('Aquí vive el contenido modular de la pantalla.'),
],
),
),
);
}
}
Tener un Scaffold dentro de otro genera conflictos graves: las notificaciones (SnackBar) se tapan o no se muestran, aparecen barras superiores dobles (AppBar) y el teclado virtual tapa los campos de texto porque ambos Scaffold intentan redimensionarse al mismo tiempo (resizeToAvoidBottomInset).
2. Andamiaje y Hardware: Scaffold y SafeArea
Los dispositivos móviles tienen notch, esquinas redondeadas, cámaras frontales y barras de navegación por gestos. Scaffold y SafeArea garantizan que tu interfaz no quede tapada por estos elementos físicos:
Scaffold: Provee los espacios estándar de Material Design (appBar,body,floatingActionButton,bottomNavigationBar).SafeArea: Agrega automáticamente el margen interno necesario para proteger el contenido frente a la barra de estado superior y la barra de inicio o gestos inferior.
- Diagrama Visual
- Código en Dart
import 'package:flutter/material.dart';
class SafeDemoScreen extends StatelessWidget {
const SafeDemoScreen({super.key});
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Protección de Hardware'),
),
// SafeArea protege el contenido contra el notch y la barra de gestos
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Center(
child: Text(
'Este contenido nunca será tapado por el notch ni por la barra inferior.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
),
),
),
bottomNavigationBar: NavigationBar(
destinations: const [
NavigationDestination(icon: Icon(Icons.home), label: 'Inicio'),
NavigationDestination(icon: Icon(Icons.person), label: 'Perfil'),
],
),
);
}
}
3. Layout Lineal: Row y Column
Para organizar elementos en una sola dirección utilizamos Row (horizontal) y Column (vertical). Cada uno opera sobre dos ejes coordenados:
- Diagrama Visual
- Código en Dart
Alineaciones en Eje Principal y Transversal:
import 'package:flutter/material.dart';
class LayoutRowColumnExample extends StatelessWidget {
const LayoutRowColumnExample({super.key});
Widget build(BuildContext context) {
return Column(
// Eje principal de Column: Vertical
mainAxisAlignment: MainAxisAlignment.center,
// Eje transversal de Column: Horizontal
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Fila horizontal con distribución spaceBetween
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: const [
Icon(Icons.star, color: Colors.amber),
Text('Elemento Central'),
Icon(Icons.favorite, color: Colors.red),
],
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {},
child: const Text('Botón Expandido Transversalmente (stretch)'),
),
],
);
}
}
4. Distribución Responsiva: Expanded y flex
En lugar de calcular anchos fijos o usar porcentajes manuales con MediaQuery, usamos Expanded para que un hijo absorba el espacio disponible restante:
- Espacio flexible: Si combinas cajas fijas (ej. 70px) con
Expanded, el widget flexible se adapta automáticamente a cualquier ancho de pantalla. - Proporciones con
flex: Al colocar variosExpanded, el factorflexreparte el espacio proporcionalmente (por ejemplo,flex: 1yflex: 2reparten 33.3% y 66.6% del espacio sobrante).
- Diagrama Visual
- Código en Dart
import 'package:flutter/material.dart';
class ExpandedFlexDemo extends StatelessWidget {
const ExpandedFlexDemo({super.key});
Widget build(BuildContext context) {
return Column(
children: [
// 1. Cajas fijas en extremos y Expanded en el centro
Row(
children: [
Container(width: 70, height: 48, color: Colors.blue.shade100, child: const Center(child: Text('70px'))),
const SizedBox(width: 8),
Expanded(
child: Container(
height: 48,
color: Colors.purple.shade100,
child: const Center(child: Text('Expanded (Absorbe espacio)')),
),
),
const SizedBox(width: 8),
Container(width: 70, height: 48, color: Colors.blue.shade100, child: const Center(child: Text('70px'))),
],
),
const SizedBox(height: 16),
// 2. Reparto proporcional con flex: 1 (33.3%) y flex: 2 (66.6%)
Row(
children: [
Expanded(
flex: 1,
child: Container(
height: 48,
color: Colors.teal.shade100,
child: const Center(child: Text('flex: 1 (33.3%)')),
),
),
const SizedBox(width: 8),
Expanded(
flex: 2,
child: Container(
height: 48,
color: Colors.indigo.shade100,
child: const Center(child: Text('flex: 2 (66.6%)')),
),
),
],
),
],
);
}
}
5. Cajas y Estilos: Container frente a Padding
Padding es un widget liviano de responsabilidad única para espaciado. Container es la navaja suiza que combina dimensiones, márgenes y decoraciones complejas:
- Diagrama Visual
- Código en Dart
import 'package:flutter/material.dart';
class ContainerDecorationExample extends StatelessWidget {
const ContainerDecorationExample({super.key});
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.all(16.0), // Margen exterior
padding: const EdgeInsets.all(20.0), // Relleno interior
decoration: BoxDecoration(
// REGLA CRÍTICA: El color DEBE ir dentro de BoxDecoration
color: const Color(0xFFF9F1FD),
borderRadius: BorderRadius.circular(16.0),
border: Border.all(color: const Color(0xFFCBC4D2), width: 1.5),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.06),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: const Text(
'Tarjeta decorada con esquinas redondeadas y sombra sutil.',
style: TextStyle(fontWeight: FontWeight.w500),
),
);
}
}
Si usas decoration: BoxDecoration(...), el color de fondo debe ir dentro de BoxDecoration. Si colocas color en el Container al mismo tiempo que decoration, Flutter lanzará un error fatal en tiempo de ejecución ('color == null || decoration == null').
6. Prevención de Desbordes: SingleChildScrollView
Cuando el contenido de una Column supera la altura física de la pantalla, Flutter muestra las temidas rayas diagonales amarillas y negras (RenderFlex overflowed).
- Diagrama Visual
- Código en Dart
import 'package:flutter/material.dart';
class ScrollSolutionExample extends StatelessWidget {
const ScrollSolutionExample({super.key});
Widget build(BuildContext context) {
// SingleChildScrollView previene el error de desborde (rayas amarillas y negras)
return SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (int i = 1; i <= 6; i++) ...[
Container(
height: 100,
decoration: BoxDecoration(
color: Colors.purple.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.purple.shade200),
),
child: Center(child: Text('Tarjeta de contenido #$i')),
),
const SizedBox(height: 12),
],
FilledButton.icon(
onPressed: () {},
icon: const Icon(Icons.check),
label: const Text('Completar Formulario'),
),
],
),
);
}
}