Flutter: desarrollo móvil multiplataforma
Construye aplicaciones nativas multiplataforma profesionales con un solo código base: arquitectura interna con Impeller, un atlas visual exclusivo de 22 widgets esenciales, maquetación moderna con Material 3, navegación con GoRouter, gestión de estado (setState, Provider, Riverpod), APIs REST y un proyecto móvil integrador completo.
Arquitectura de Flutter, Impeller y entorno
Comprende las entrañas del SDK de Google: las tres capas del framework, la revolución del motor gráfico Impeller contra el jank, la preparación rigurosa del entorno multiplataforma y la creación de tu primer proyecto nativo.
- Desglosar las tres capas de Flutter: Embedder, Engine (C++) y Framework (Dart).
- Comprender el motor Impeller: por qué sustituye a Skia y erradica el molesto shader compilation jank.
- Ejecutar el diagnóstico con Flutter Doctor: configurar toolchain en Linux, Windows y macOS.
- Crear y ejecutar tu primera app: anatomía de archivos y la diferencia bajo el capó entre Hot Reload y Hot Restart.
1. ¿Por qué Flutter no usa WebView ni puentes OEM?
En el ecosistema móvil tradicional existen tres enfoques dominantes:
- Nativo puro (Kotlin/Swift): compila directamente contra los widgets del sistema operativo (OEM). Máximo rendimiento, pero exige mantener dos bases de código separadas.
- Híbrido WebView (Cordova/Capacitor): empaqueta HTML/CSS/JS dentro de un navegador embebido. Fácil reutilización web, pero penalización severa en tasa de refresco y respuesta gestual.
- Puentes reactivos (React Native): ejecuta JavaScript en un hilo separado y se comunica mediante serialización asíncrona (Bridge/JSI) para manipular vistas nativas del SO. Si el puente se satura con eventos rápidos (scroll o animaciones complejas), la interfaz se congela.
Flutter rompe este paradigma pintando cada píxel directamente. No le pide al sistema operativo botones, barras ni interruptores nativos. Flutter incluye su propio motor de renderizado y una suite completa de widgets dibujados mediante operaciones gráficas de bajo nivel sobre un lienzo (Canvas). El resultado es un control milimétrico de la interfaz (pixel-perfect) y un rendimiento constante a 60 o 120 fotogramas por segundo (fps).
2. Las tres capas de la arquitectura interna
La arquitectura de Flutter se organiza en una jerarquía estricta de responsabilidades, desde los drivers del dispositivo hasta la API de widgets que consumes en Dart:
| Capa | Lenguaje / Tecnología | Responsabilidades clave |
|---|---|---|
| Framework | Dart |
Material & Cupertino: bibliotecas de diseño. Widgets: catálogo declarativo inmutable. Rendering: cálculo de cajas y coordenadas. Animation, Painting, Gestures: eventos táctiles y física. |
| Engine | C / C++ |
Impeller / Skia: rasterización gráfica por hardware. Dart VM: ejecución AOT (producción) y JIT (desarrollo). Text layout (LibTxt / HarfBuzz): renderizado de fuentes y glifos. Platform Channels: interoperabilidad con APIs nativas del SO. |
| Embedder | C++, Java/Kotlin, Obj-C/Swift |
Específico de cada plataforma anfitriona (Android, iOS, Windows, macOS, Linux, Web). Crea la ventana de superficie gráfica, gestiona el bucle de eventos del SO y entrega el acceso al teclado y sensores. |
3. La revolución de Impeller: erradicando el Shader Jank
Durante años, Flutter utilizó la biblioteca gráfica Skia. Skia funcionaba de manera impecable en la mayoría de escenarios, pero sufría de un problema conocido como Shader Compilation Jank: cuando un usuario abría una pantalla con una animación, gradiente o sombra nueva por primera vez, el driver de la GPU tenía que compilar los sombreadores (shaders) sobre la marcha en tiempo de ejecución. Esta compilación tardaba entre 30 y 100 milisegundos, provocando una caída perceptible de fotogramas (un "tirón" o stutter).
Para erradicar definitivamente este defecto, el equipo de Flutter diseñó Impeller desde cero:
- Precompilación AOT de shaders: todos los sombreadores y pipelines gráficos se compilan en tiempo de construcción (build time) utilizando Metal en iOS y Vulkan (con fallback a OpenGL) en Android.
- Predicibilidad absoluta: ningún fotograma se demora compilando código de GPU mientras el usuario interactúa con la pantalla.
- Uso concurrente de la GPU: aprovecha al máximo las colas de renderizado paralelas de los procesadores modernos.
--enable-impeller o inspeccionarlo en tiempo de ejecución con Flutter DevTools.
4. Instalación limpia y diagnóstico con Flutter Doctor
El SDK de Flutter incluye el compilador de Dart, las herramientas de línea de comandos y los envoltorios nativos. Para instalarlo de forma limpia en tu estación de trabajo:
# 1. En Linux / macOS (descarga directa del canal estable)
# O clonando el repositorio oficial en una ruta sin permisos de root:
git clone https://github.com/flutter/flutter.git -b stable ~/flutter
# 2. Exportar la ruta en tu ~/.bashrc o ~/.zshrc
export PATH="$HOME/flutter/bin:$PATH"
source ~/.bashrc
# 3. Comprobar la versión instalada
flutter --version
Una vez configurado el PATH, el comando fundamental es flutter doctor. Esta utilidad audita el sistema operativo en busca de dependencias faltantes (Java JDK, Android SDK, Android Studio, VS Code y cadenas de compilación nativas):
# Diagnóstico completo con salida detallada
flutter doctor -v
# Aceptar todas las licencias del SDK de Android
flutter doctor --android-licenses
[✓] Flutter (Channel stable, 3.47.4, on Linux 6.8.0, locale es_ES.UTF-8) [✓] Android toolchain - develop for Android devices (Android SDK version 34.0.0) [✓] Chrome - develop for the web [✓] Linux toolchain - develop for Linux desktop [✓] Android Studio (version 2024.1) [✓] VS Code (version 1.93.0) [✓] Connected device (2 available) [✓] Network resources • No issues found!
5. Creación del primer proyecto y anatomía de carpetas
Para inicializar una nueva aplicación utilizamos el comando flutter create. Recomendamos especificar siempre la organización inversa de dominio mediante el flag --org para fijar el Package Name (Android) y Bundle Identifier (iOS):
# Creación con identificador de organización
flutter create --org com.webcode mi_primera_app
# Ingresar al proyecto
cd mi_primera_app
# Ejecutar en el dispositivo o emulador conectado
flutter run
La estructura de archivos generada es limpia y modular:
lib/: Aquí vive todo tu código Dart. El punto de entrada indiscutible eslib/main.dart.pubspec.yaml: El manifiesto del proyecto. Declara el nombre de la app, versión, dependencias de paquetes (de pub.dev), fuentes y rutas de imágenes (assets).android/eios/: Proyectos anfitriones nativos (Gradle y Xcode). El 90% del tiempo no necesitas tocarlos, salvo para configurar permisos de cámara/GPS, llaves de firma o splash screens.web/,linux/,windows/,macos/: Proyectos anfitriones para escritorio y navegador web.test/: Suites de pruebas unitarias y de widgets.
6. El superpoder del desarrollo: Hot Reload vs Hot Restart
Uno de los factores que catapulta la productividad en Flutter es la velocidad de iteración. Es crítico dominar la diferencia técnica entre ambos mecanismos:
| Acción | Atajo CLI | ¿Qué ocurre internamente? | Preservación de Estado |
|---|---|---|---|
| Hot Reload | r | Inyecta el nuevo código compilado en la Dart VM y reconstruye el árbol de widgets inmediatamente (~200ms). | Conserva el estado (contadores, campos de texto y formularios quedan intactos). |
| Hot Restart | R | Destruye el estado completo, reinicia la Dart VM y vuelve a ejecutar la función main() desde cero (~1s). |
Pierde el estado (devuelve la app a su condición inicial limpia). |
| Full Rebuild | Detener y flutter run |
Recompila binarios nativos de Android/iOS (Gradle/Xcode). Necesario cuando agregas plugins C++/Java/Swift o editas pubspec.yaml. |
Reinstalación completa en el dispositivo. |
Regla de oro de desarrollo
Si modificas la función main(), variables estáticas globales o el método initState() de un StatefulWidget, Hot Reload no surtirá efecto porque ese código solo corre al nacer el componente. En esos casos, ejecuta siempre Hot Restart (R).
Puntos clave del capítulo
- Flutter renderiza sobre un lienzo propio con control pixel a pixel, eliminando la sobrecarga de puentes OEM y WebViews.
- El motor gráfico Impeller compila sombreadores AOT durante el build, eliminando los tirones (jank) de la primera animación.
flutter doctores la herramienta canónica para auditar las dependencias del sistema y licencias del SDK.- Todo el código multiplataforma se ubica en
lib/, conlib/main.dartcomo punto de entrada de la aplicación. - Hot Reload (r) actualiza la UI preservando el estado en memoria; Hot Restart (R) reinicia la máquina virtual.
Atlas y Glosario Visual de 22 Widgets
Reconoce visualmente la anatomía espacial de los componentes esenciales antes de programarlos. Guía de referencia rápida con diagramas vectoriales, esquemas de diseño y snippets para el 100% de los 22 widgets fundamentales de Flutter.
- Interiorizar el modelo mental de pantalla: cómo encajan los slots del
Scaffolden un teléfono móvil. - Diferenciar contenedores y ejes:
Row(horizontal),Column(vertical) yStack(capas en profundidad). - Distinguir cajas de espaciado: cuándo usar
Container,PaddingoSizedBoxsin desperdiciar recursos. - Consultar la ficha técnica completa de los 22 widgets: cada uno con su esquema wireframe SVG dedicado, rol y código mínimo.
1. El Mapa Maestro: Anatomía de una Pantalla Móvil
En Flutter una pantalla no se maqueta mediante reglas de flotación ni posicionamiento absoluto descontrolado.
Se utiliza una estructura arquitectónica donde el widget Scaffold actúa como esqueleto
anfitrión y proporciona "ranuras" (slots) predefinidas para cada zona funcional:
Cada área representa un parámetro nombrado directo en el constructor de Scaffold.
2. Catálogo Visual de los 22 Widgets Esenciales
A continuación desglosamos cada uno de los 22 componentes que conforman la columna vertebral de cualquier aplicación en Flutter, organizados por su rol arquitectónico con su esquema gráfico representativo:
Categoría I · Componentes de Estructura y Navegación
1. Scaffold
EstructuraEsqueleto básico visual de una pantalla en Material Design. Ofrece ranuras directas para barra superior, cuerpo central, cajón lateral y botones flotantes.
Scaffold(
appBar: AppBar(title: const Text('Inicio')),
body: const Center(child: Text('Hola Flutter')),
floatingActionButton: FloatingActionButton(
onPressed: () {},
child: const Icon(Icons.add),
),
);
2. AppBar
NavegaciónBarra de herramientas superior. Aloja el icono de navegación (leading), el título de la vista y botones de acción (actions).
AppBar(
leading: const BackButton(),
title: const Text('Detalle de Pedido'),
actions: [
IconButton(icon: const Icon(Icons.share), onPressed: () {}),
],
);
3. BottomNavigationBar
NavegaciónBarra inferior que permite alternar entre destinos primarios de la aplicación mediante un toque en pantalla.
BottomNavigationBar(
currentIndex: 0,
onTap: (index) {},
items: const [
BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Perfil'),
],
);
4. Drawer
NavegaciónPanel lateral oculto que se despliega horizontalmente desde el borde de la pantalla para presentar accesos secundarios o perfil del usuario.
Drawer(
child: ListView(
children: const [
DrawerHeader(child: Text('Menú Principal')),
ListTile(leading: Icon(Icons.settings), title: Text('Ajustes')),
],
),
);
5. TabBar / TabBarView
Pestañas
Sistema de navegación por pestañas en paralelo. TabBar muestra los botones de encabezado con indicador deslizante y TabBarView conmuta las vistas sincronizadas mediante gestos de swipe.
DefaultTabController(
length: 2,
child: Scaffold(
appBar: AppBar(
bottom: const TabBar(
tabs: [Tab(text: 'Pendientes'), Tab(text: 'Completados')],
),
),
body: const TabBarView(
children: [Text('Vista 1'), Text('Vista 2')],
),
),
);
Categoría II · Componentes de Diseño y Contenedores
6. Container
ContenedorCaja multipropósito. Permite fijar dimensiones, márgenes (margin), rellenos (padding), colores de fondo, bordes redondeados y sombras complejas (BoxDecoration).
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: const [BoxShadow(blurRadius: 4, color: Colors.black12)],
),
child: const Text('Caja decorada'),
);
7. Padding
EspaciadoWidget de alta eficiencia optimizado para una única tarea: inyectar espacio interno vacío alrededor de su elemento hijo, sin el costo de un Container completo.
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
child: const Text('Texto con respiro'),
);
8. SizedBox
DimensionesCaja de dimensiones exactas e invariables. Se emplea para forzar anchos/altos precisos o como separador en blanco entre elementos de una fila o columna.
// Separador vertical limpio y constante
const SizedBox(height: 16);
// Caja con tamaño forzado
SizedBox(
width: double.infinity,
child: ElevatedButton(onPressed: () {}, child: const Text('Botón ancho')),
);
9. Card
SuperficiePanel superficial con bordes redondeados y sombra calculada por elevación (efecto z-axis en Material Design). Agrupa información relacionada de forma destacada.
Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: const ListTile(
leading: Icon(Icons.person),
title: Text('Juan Pérez'),
subtitle: Text('Paciente asignado'),
),
);
Categoría III · Componentes de Alineación y Distribución
10. Row
Flujo XDistribuidor lineal horizontal. Organiza sus widgets hijos en fila de izquierda a derecha. Eje principal (MainAxis): horizontal; eje cruzado (CrossAxis): vertical.
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: const [
Icon(Icons.star),
Text('Puntuación: 4.8'),
Icon(Icons.arrow_forward),
],
);
11. Column
Flujo YDistribuidor lineal vertical. Apila sus widgets hijos uno debajo del otro. Eje principal (MainAxis): vertical; eje cruzado (CrossAxis): horizontal.
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Text('Título', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
SizedBox(height: 8),
Text('Subtítulo o descripción corta'),
],
);
12. Stack
Capas ZContenedor de superposición. Permite colocar widgets unos encima de otros en el eje Z (fondos con imágenes, etiquetas de descuento, badges de notificación flotantes).
Stack(
children: [
Image.asset('banner.jpg'),
Positioned(
bottom: 10,
left: 10,
child: const Text('Texto sobre imagen'),
),
],
);
13. Center
AlineaciónComponente de alineación que centra de forma matemática a su único widget hijo, tanto en el eje horizontal como en el vertical, dentro del espacio que le concede su padre.
const Center(
child: CircularProgressIndicator(),
);
Categoría IV · Componentes de Texto y Visualización
14. Text
Tipografía
Componente fundamental para pintar cadenas de texto. Configura fuentes, pesos, interlineados, desbordamiento (overflow) y estilos con TextStyle.
const Text(
'Total a pagar: S/ 120.00',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.blueAccent,
),
);
15. Icon
GlifosPinta glifos vectoriales escalables de la librería del sistema (Material Icons). Permite ajustar tamaño y color sin pérdida de nitidez.
const Icon(
Icons.check_circle,
color: Colors.green,
size: 32,
);
16. Image
Gráficos
Carga mapas de bits desde internet (Image.network), archivos locales del paquete (Image.asset) o memoria binaria.
Image.network(
'https://webcode.net.pe/logo.png',
width: 120,
fit: BoxFit.contain,
);
Categoría V · Componentes de Interacción y Formularios
17. ElevatedButton
Botón primarioBotón estándar con relieve, color de fondo y sombra proyectada. Resalta la acción primordial de un formulario o vista.
ElevatedButton(
onPressed: () => procesar(),
child: const Text('Confirmar'),
);
18. TextButton
Botón planoBotón sin borde ni fondo visible en reposo. Ideal para acciones secundarias, enlaces de cancelación o dentro de cuadros de diálogo.
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancelar'),
);
19. IconButton
Botón iconoBotón compuesto únicamente por un glifo con efecto de ondas táctiles (ink ripple). Muy común en barras de herramientas.
IconButton(
icon: const Icon(Icons.favorite_border),
onPressed: () => alternarFavorito(),
);
20. TextField
Entrada de texto
Campo de texto interactivo. Gestiona la apertura del teclado táctil, decoraciones de borde, prefijos, sufijos y captura mediante TextEditingController.
TextField(
controller: emailCtrl,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Correo electrónico',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.email),
),
);
21. FloatingActionButton
Botón flotanteBotón circular flotante (FAB) que se eleva sobre el contenido general para activar la acción más determinante y trascendental de la pantalla (crear, agregar, disparar).
FloatingActionButton(
onPressed: () => abrirNuevo(),
backgroundColor: Colors.blueAccent,
child: const Icon(Icons.add),
);
Categoría VI · Componentes de Listas y Desplazamiento
22. ListView
Lista deslizable
Lista lineal desplazable. Su constructor ListView.builder es la solución canónica para renderizar miles de registros de forma perezosa (lazy loading), reciclando memoria al salir de pantalla.
ListView.builder(
itemCount: pacientes.length,
itemBuilder: (context, index) {
return ListTile(
leading: const Icon(Icons.person),
title: Text(pacientes[index].nombre),
);
},
);
23. GridView
Cuadrícula 2DMalla bidimensional deslizante en filas y columnas. Ideal para catálogos comerciales de productos, tableros de fotos, paneles de métricas o galerías.
GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
),
itemCount: productos.length,
itemBuilder: (context, i) => ProductoCard(productos[i]),
);
Cómo utilizar este Atlas a lo largo del curso
Guarda este capítulo en tus marcadores de cabecera. Cada vez que diseñes una nueva pantalla, regresa aquí para contrastar qué widget resuelve tu necesidad de espacio con el menor consumo de CPU. En los capítulos siguientes tomaremos cada uno de estos bloques para conectarlos a datos dinámicos, validaciones reactivas y lógica de negocio.
Puntos clave del capítulo
Scaffoldproporciona la estructura base de pantalla con ranuras paraappBar,body,floatingActionButtonybottomNavigationBar.RowyColumnorganizan widgets linealmente en un solo eje, mientras queStackpermite superposición en capas sobre el eje Z.- Para espaciar, prefiere
SizedBoxoPaddingantes de crear unContainerinnecesario. ListView.builderyGridView.builderimplementan reciclaje de memoria inteligente para conjuntos de datos grandes o infinitos.- Toda la interfaz en Flutter se construye componiendo estos widgets primitivos en un árbol jerárquico.
Anatomía de una App y ciclo de vida
Descifra el engranaje interno de una aplicación en Flutter: el punto de entrada con runApp(),
el triple árbol de renderizado (Widget, Element, RenderObject), la diferencia arquitectónica
entre StatelessWidget y StatefulWidget, y el ciclo de vida riguroso de la clase State.
- Rastrear el arranque: qué hace
WidgetsFlutterBinding.ensureInitialized()yrunApp(). - Dominar el triple árbol: cómo colaboran la capa declarativa (Widget), la estructural (Element) y la geométrica (RenderObject).
- Elegir entre Stateless y Stateful: inmutabilidad con
constfrente a estado persistente. - Controlar el ciclo de vida de State: de
initState()adispose(), evitando memory leaks y errores conmounted.
1. El Punto de Entrada: main() y runApp()
Toda aplicación Flutter nace en la función clásica de Dart void main() ubicada en lib/main.dart.
Dentro de ella se invoca a runApp():
import 'package:flutter/material.dart';
void main() {
// Asegura que los canales binarios del sistema estén listos
// (Obligatorio si inicializas plugins como Firebase, SharedPreferences o SQLite antes de runApp)
WidgetsFlutterBinding.ensureInitialized();
runApp(const MiAplicacion());
}
class MiAplicacion extends StatelessWidget {
const MiAplicacion({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Clínica Móvil',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF02569B)),
useMaterial3: true,
),
home: const Scaffold(
body: Center(child: Text('Bienvenido al sistema')),
),
);
}
}
Bajo el capó, runApp() ejecuta tres acciones críticas:
- Infla el widget raíz que le pasas como parámetro y lo ancla a la ventana nativa mediante el
RenderView. - Crea el
Elementraíz que coordinará todo el árbol de vistas. - Programa el primer fotograma en el motor gráfico Impeller para pintar la pantalla de inmediato.
2. El Triple Árbol de Flutter: ¿Por qué es tan rápido?
Uno de los mayores secretos de rendimiento de Flutter reside en que no maneja un solo árbol de interfaz, sino tres árboles sincronizados en memoria:
| Árbol | Características | Responsabilidad en la arquitectura |
|---|---|---|
| 1. Widget Tree | Inmutable, ligero, desechable | Es el plano o receta declarativa que escribes en Dart. Crear widgets cuesta casi cero memoria; Flutter puede destruirlos y recrearlos 60 o 120 veces por segundo sin problema. |
| 2. Element Tree | Mutable, persistente, gestor de ciclo |
Es el "cerebro" estructural. Guarda la instancia viva en memoria, enlaza el Widget con el RenderObject, mantiene el estado (en StatefulWidgets) y decide cuándo un componente puede reutilizarse comparando su tipo y su Key.
|
| 3. RenderObject Tree | Pesado, persistente, cálculo espacial |
Es el objeto que realiza el trabajo duro: calcula restricciones de tamaño (layout), detecta impactos táctiles (hitTest) y pinta los píxeles en el Canvas (paint) para que Impeller los rasterice. Solo se recalcula cuando cambian dimensiones o colores reales.
|
¿Qué ocurre cuando cambia un dato?
Cuando se reconstruye un widget, Flutter no destruye el RenderObject si el tipo y la llave coinciden. Simplemente actualiza sus propiedades (por ejemplo, cambia el color de fondo) y le pide a Impeller repintar esa zona. Por eso Flutter alcanza 120 fps sostenidos: el 95% del árbol pesado se recicla intacto.
3. StatelessWidget vs StatefulWidget
Todos los widgets de tu aplicación heredan directa o indirectamente de una de estas dos clases base:
StatelessWidget
Representa una interfaz estática que solo depende de los parámetros que recibe en su constructor.
Todos sus campos deben ser final y sus constructores deben declararse con const siempre que sea posible.
class TarjetaUsuario extends StatelessWidget {
final String nombre;
final String rol;
const TarjetaUsuario({
super.key,
required this.nombre,
required this.rol,
});
@override
Widget build(BuildContext context) {
return ListTile(
leading: const Icon(Icons.person),
title: Text(nombre),
subtitle: Text(rol),
);
}
}
StatefulWidget
Representa una interfaz dinámica cuyos datos cambian durante el ciclo de vida en respuesta a eventos del usuario o respuestas de red. Se divide obligatoriamente en dos clases separadas:
// 1. El Widget (inmutable y ligero)
class ContadorWidget extends StatefulWidget {
const ContadorWidget({super.key});
@override
State<ContadorWidget> createState() => _ContadorWidgetState();
}
// 2. El State (mutable y persistente)
class _ContadorWidgetState extends State<ContadorWidget> {
int _contador = 0;
void _incrementar() {
setState(() => _contador++);
}
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: _incrementar,
child: Text('Clics: $_contador'),
);
}
}
4. El Ciclo de Vida Completo de la Clase State
El objeto State cuenta con una secuencia determinista de etapas desde que nace hasta que es destruido de la memoria. Dominar este ciclo es imprescindible para conectar controladores, escuchar streams y prevenir fugas de memoria:
Los métodos verdes y azules corresponden al nacimiento, el bucle central al re-renderizado reactivo y los rojos a la liberación obligatoria de recursos.
| Método | Frecuencia | Propósito técnico y qué código escribir aquí |
|---|---|---|
createState() |
1 vez | Invocado por Flutter inmediatamente al insertar el StatefulWidget en el árbol para instanciar su clase de estado. |
initState() |
1 vez |
Inicialización única. Aquí configuras controladores (TextEditingController, ScrollController), te suscribes a Streams o disparas peticiones HTTP iniciales. Siempre debes llamar primero a super.initState().
|
didChangeDependencies() |
1 o más veces |
Se ejecuta justo después de initState() y cada vez que cambia un InheritedWidget del cual depende este widget (por ejemplo, si el usuario cambia el tema claro/oscuro o la orientación de pantalla con MediaQuery).
|
build() |
Muchas veces |
Renderizado declarativo. Retorna el árbol de widgets. Debe ser un método puro, rápido y libre de efectos secundarios (¡nunca dispares peticiones HTTP ni inicies timers dentro de build()!).
|
didUpdateWidget() |
Ocasional |
Se dispara si el widget padre se reconstruye y le pasa nuevos parámetros al widget hijo manteniendo el mismo State. Permite comparar oldWidget contra widget para reaccionar a cambios.
|
setState() |
A demanda |
Notifica al framework que las variables internas han mutado. Marca el elemento como dirty y programa una nueva llamada a build() en el siguiente refresco de pantalla.
|
deactivate() |
Rara vez | Se invoca cuando el elemento es retirado temporalmente del árbol (por ejemplo, al moverlo de posición mediante una GlobalKey). |
dispose() |
1 vez (final) |
Destrucción y limpieza obligatoria. Aquí liberas timers, cancelas suscripciones a Streams y llamas a controller.dispose() para erradicar memory leaks. Siempre finaliza llamando a super.dispose().
|
5. La Trampa Clásica de Asincronía: La propiedad mounted
Cuando ejecutas operaciones asíncronas (como una llamada HTTP o lectura de base de datos) dentro de un State, el usuario puede retroceder de pantalla antes de que la petición termine. Si el widget ya no existe e intentas llamar a setState(), Flutter arrojará una excepción severa: «setState() called after dispose()».
La solución profesional es verificar la propiedad booleana mounted antes de mutar la interfaz:
Future<void> _cargarDatosServidor() async {
final resultado = await servicioApi.obtenerPerfil();
// Guarda de seguridad: verifica si el widget sigue vivo en pantalla
if (!mounted) return;
setState(() {
_perfil = resultado;
_cargando = false;
});
}
Puntos clave del capítulo
main()arranca conrunApp(), el cual ancla el árbol de widgets a la superficie gráfica nativa.- Flutter mantiene tres árboles: Widgets (configuración inmutable), Elements (gestión estructural de ciclo) y RenderObjects (cálculo geométrico y pintura).
- Usa
StatelessWidgetpara interfaces estáticas yStatefulWidgetcuando el componente deba almacenar y mutar datos interactivos. - En el ciclo de vida de
State, inicializa eninitState()y libera obligatoriamente recursos endispose(). - Comprueba siempre
if (!mounted) return;tras operaciones conawaitantes de invocarsetState().
Estructura de pantalla con Scaffold
Domina el contenedor estructural por excelencia de Material 3: configuración exhaustiva de AppBar, cajones de navegación laterales (Drawer y EndDrawer), botones flotantes (FAB), notificaciones reactivas con SnackBar y ventanas emergentes inferiores.
- Configurar la AppBar profesionalmente: ranuras
leading,title,actionsy elevación tonal en Material 3. - Implementar menús deslizantes: estructura de
DraweryUserAccountsDrawerHeadercon cierre determinista. - Dominar el FloatingActionButton: variantes M3 (small, regular, large, extended) y anclaje espacial con
floatingActionButtonLocation. - Disparar notificaciones y BottomSheets: uso del estándar
ScaffoldMessengery cuadros de diálogo modales inferiores.
1. La Barra de Herramientas: AppBar a Fondo
En Material 3, la AppBar no es simplemente una barra de color plano con un título de texto. Es una superficie interactiva que coordina navegación, acciones globales y respuesta a eventos de desplazamiento:
AppBar(
// 1. Icono de navegación izquierdo (Drawer o flecha de retorno)
leading: IconButton(
icon: const Icon(Icons.menu),
tooltip: 'Abrir menú',
onPressed: () => Scaffold.of(context).openDrawer(),
),
// 2. Título principal centrado o alineado al inicio
title: const Text('Gestión de Citas'),
centerTitle: false,
// 3. Botones de acción en la esquina superior derecha
actions: [
IconButton(
icon: const Icon(Icons.search),
tooltip: 'Buscar paciente',
onPressed: () {},
),
IconButton(
icon: const Icon(Icons.filter_list),
tooltip: 'Filtrar',
onPressed: () {},
),
],
// 4. Parámetros visuales en Material 3
elevation: 0,
scrolledUnderElevation: 3.0, // Elevación tonal automática al hacer scroll
backgroundColor: Theme.of(context).colorScheme.surface,
);
Elevación tonal en Material 3
A diferencia de Material 2, que utilizaba sombras oscuras pesadas para simular altura física, Material 3 utiliza elevación tonal (surface tint): a mayor elevación, el color de la superficie se tiñe sutilmente con el color primario de tu paleta (ColorScheme.primary), conservando un contraste óptico perfecto tanto en modo claro como en modo oscuro.
2. Menús Desplegables: Drawer y EndDrawer
El Scaffold permite declarar dos tipos de cajones laterales:
drawer: se despliega desde el borde izquierdo (en idiomas de lectura izquierda a derecha). Si no defines unleadingmanual en laAppBar, Flutter dibuja automáticamente el icono de hamburguesa que lo activa.endDrawer: se despliega desde el borde derecho. Excelente para filtros secundarios, paneles de auditoría o carritos de compra.
Drawer(
child: ListView(
padding: EdgeInsets.zero,
children: [
// Encabezado con información del usuario autenticado
UserAccountsDrawerHeader(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
),
currentAccountPicture: const CircleAvatar(
backgroundColor: Colors.white,
child: Icon(Icons.person, size: 36, color: Color(0xFF02569B)),
),
accountName: const Text('Dr. Carlos Mendoza'),
accountEmail: const Text('carlos.mendoza@clinica.pe'),
),
// Opciones de navegación
ListTile(
leading: const Icon(Icons.calendar_today),
title: const Text('Citas de Hoy'),
onTap: () {
// 1. Cerrar el drawer primero
Navigator.pop(context);
// 2. Navegar a la pantalla deseada
},
),
ListTile(
leading: const Icon(Icons.people_outline),
title: const Text('Directorio de Pacientes'),
onTap: () => Navigator.pop(context),
),
const Divider(),
ListTile(
leading: const Icon(Icons.logout, color: Colors.redAccent),
title: const Text('Cerrar Sesión', style: TextStyle(color: Colors.redAccent)),
onTap: () => Navigator.pop(context),
),
],
),
);
3. Botones Flotantes: Variantes y Posicionamiento del FAB
El FloatingActionButton representa la acción primordial de la vista. En Material 3 se introducen variantes de tamaño y un widget extendido para incluir texto descriptivo:
| Constructor / Variante | Dimensiones M3 | Caso de uso recomendado |
|---|---|---|
FloatingActionButton.small() |
40 × 40 px | Acciones secundarias en pantallas compactas o vistas con múltiples botones. |
FloatingActionButton() |
56 × 56 px | El estándar tradicional para la acción principal (+, editar, guardar). |
FloatingActionButton.large() |
96 × 96 px | Interfaces en tablets, pantallas de quiosco o acciones de alta prioridad. |
FloatingActionButton.extended() |
Altura 56 px, ancho auto | Combina un icono con una etiqueta de texto legible (ej: «Nueva Cita»). |
Scaffold(
// Ubicaciones: endFloat (default), centerFloat, centerDocked, endDocked
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
floatingActionButton: FloatingActionButton.extended(
onPressed: () => registrarNuevaCita(),
icon: const Icon(Icons.add),
label: const Text('Nueva Cita'),
),
body: const Center(child: Text('Contenido principal')),
);
4. Notificaciones Reactivas con SnackBar y ScaffoldMessenger
Para mostrar mensajes de confirmación o avisos breves en la parte inferior de la pantalla, Flutter provee SnackBar.
En versiones modernas de Flutter es obligatorio orquestar su apertura mediante ScaffoldMessenger:
void mostrarMensajeExito(BuildContext context, String mensaje) {
// 1. Ocultar SnackBar previo si estuviese visible
ScaffoldMessenger.of(context).hideCurrentSnackBar();
// 2. Disparar el nuevo aviso configurado
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.check_circle_outline, color: Colors.white),
const SizedBox(width: 10),
Expanded(child: Text(mensaje)),
],
),
duration: const Duration(seconds: 3),
behavior: SnackBarBehavior.floating, // Flota sobre la pantalla sin pegarse al borde
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
action: SnackBarAction(
label: 'DESHACER',
textColor: Colors.amberAccent,
onPressed: () {
// Lógica de reversión inmediata
},
),
),
);
}
5. Paneles Modales Inferiores: showModalBottomSheet
Cuando requieres que el usuario seleccione una opción o complete un flujo secundario sin abandonar la pantalla actual, el patrón recomendado en móviles es el Bottom Sheet Modal:
void mostrarOpciones(BuildContext context) {
showModalBottomSheet(
context: context,
showDragHandle: true, // Tirador visual nativo de arrastre en Material 3
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (BuildContext ctx) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min, // Ocupa solo la altura requerida
children: [
ListTile(
leading: const Icon(Icons.photo_camera),
title: const Text('Tomar fotografía'),
onTap: () => Navigator.pop(ctx, 'camara'),
),
ListTile(
leading: const Icon(Icons.photo_library),
title: const Text('Elegir de la galería'),
onTap: () => Navigator.pop(ctx, 'galeria'),
),
],
),
);
},
);
}
Puntos clave del capítulo
Scaffoldorganiza de forma coherente las ranuras deappBar,drawer,bodyyfloatingActionButton.- En Material 3,
AppBarimplementa elevación tonal automática conscrolledUnderElevation. - Dentro de un
Drawer, invoca siempreNavigator.pop(context)al pulsar un elemento para cerrar el menú antes de navegar. - Utiliza
ScaffoldMessenger.of(context).showSnackBar()para emitir mensajes transitorios flotantes con soporte para acciones de usuario. showModalBottomSheetconshowDragHandle: trueymainAxisSize: MainAxisSize.minofrece el estándar moderno para flujos secundarios.
Distribución y flujo: Row, Column y Stack
Aprende a maquetar interfaces complejas y responsivas: los sistemas de ejes en Row y Column, la distribución proporcional con Expanded y Flexible, la solución definitiva al error de desbordamiento por píxeles y la superposición de capas en el eje Z con Stack y Positioned.
- Dominar los ejes primario y cruzado: alinear elementos con precisión mediante
MainAxisAlignmentyCrossAxisAlignment. - Controlar la flexibilidad del espacio: diferenciar
Expanded,Flexibley el atajo elásticoSpacer. - Erradicar el desbordamiento de pantalla: resolver el clásico error «A RenderFlex overflowed by X pixels» con
WrapySingleChildScrollView. - Superponer capas visuales: componer diseños complejos en el eje Z mediante
StackyPositioned.
1. La Mecánica de Ejes en Row y Column
Tanto Row como Column derivan del widget interno Flex. La única diferencia entre ambos es la orientación del eje en el que distribuyen a sus hijos:
| Widget | Eje Principal (MainAxis) | Eje Cruzado (CrossAxis) |
|---|---|---|
Row |
Horizontal (X) · De izquierda a derecha | Vertical (Y) · De arriba a abajo |
Column |
Vertical (Y) · De arriba a abajo | Horizontal (X) · De izquierda a derecha |
Las opciones de alineación determinan el espaciado entre componentes:
MainAxisAlignment.start / end / center: agrupa a los hijos al principio, final o centro del eje.MainAxisAlignment.spaceBetween: distribuye el espacio libre sobrante exclusivamente entre los hijos (los extremos quedan pegados al borde).MainAxisAlignment.spaceAround: distribuye espacio equitativo alrededor de cada hijo (el espacio entre elementos es el doble que en los extremos).MainAxisAlignment.spaceEvenly: todo el espacio (bordes y entre elementos) es matemáticamente idéntico.CrossAxisAlignment.stretch: obliga a todos los hijos a expandirse hasta cubrir todo el ancho/alto del eje cruzado disponible.
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
// MainAxisSize.min hace que el Row o Column solo ocupe lo necesario
mainAxisSize: MainAxisSize.max,
children: const [
Icon(Icons.access_time, color: Colors.blueGrey),
Text('09:30 AM', style: TextStyle(fontWeight: FontWeight.bold)),
Chip(label: Text('Confirmada')),
],
);
2. Espacio Flexible: Expanded vs Flexible vs Spacer
Cuando colocas elementos dentro de una fila o columna, los widgets rígidos (como textos largos o imágenes) pueden entrar en conflicto con el espacio restante. Para gestionar el espacio disponible proporcionalmente, disponemos de tres herramientas:
Expanded
Obliga al widget hijo a expandirse hasta llenar obligatoriamente todo el espacio libre restante en el eje principal (equivalente a fit: FlexFit.tight).
Row(
children: const [
Icon(Icons.mail),
SizedBox(width: 8),
Expanded(
child: Text('Texto largo que jamás desborda la pantalla'),
),
],
);
Flexible
Le permite al widget hijo crecer hasta el espacio disponible si lo necesita, pero sin forzarlo a estirarse si su contenido intrínseco es menor (fit: FlexFit.loose).
Row(
children: const [
Flexible(
child: Text('Texto corto que no fuerza expansión'),
),
Icon(Icons.check),
],
);
Spacer
Un atajo declarativo elegante que inserta un espacio elástico vacío. Es exactamente equivalente a escribir Expanded(child: SizedBox()).
Row(
children: const [
Text('Total:'),
Spacer(), // Empuja el precio al extremo
Text('S/ 250.00'),
],
);
Mediante la propiedad flex puedes establecer relaciones de proporción (por ejemplo, dividir una fila en 70% / 30% asignando flex: 7 y flex: 3):
Row(
children: [
Expanded(flex: 3, child: Container(color: Colors.blueAccent, height: 40)),
Expanded(flex: 1, child: Container(color: Colors.amberAccent, height: 40)),
],
);
3. Cómo Erradicar el Error: «A RenderFlex overflowed»
Todo desarrollador de Flutter se encuentra tarde o temprano con la temida franja rayada en amarillo y negro. Este error ocurre cuando los hijos de un Row o Column exigen más píxeles físicos de los que la pantalla puede ofrecer:
| Escenario del Desbordamiento | Causa habitual | Solución canónica en Flutter |
|---|---|---|
| Texto largo en un Row | El widget Text intenta crecer infinitamente en línea recta horizontal. |
Envolver el Text dentro de un Expanded o Flexible para forzar saltos de línea. |
| Formulario o Column con teclado | Al abrirse el teclado virtual, la altura útil de pantalla se reduce a la mitad. | Envolver la columna raíz dentro de un SingleChildScrollView con padding adecuado. |
| Etiquetas, tags o chips dinámicos | Una lista horizontal de chips supera el ancho del dispositivo. | Reemplazar el Row por un Wrap para que salten de fila automáticamente. |
// Ejemplo: Tags dinámicos que se adaptan a múltiples líneas sin desbordar
Wrap(
spacing: 8.0, // Separación horizontal entre chips
runSpacing: 4.0, // Separación vertical entre líneas consecutivas
children: const [
Chip(label: Text('Medicina General')),
Chip(label: Text('Cardiología')),
Chip(label: Text('Pediatría')),
Chip(label: Text('Dermatología')),
Chip(label: Text('Traumatología')),
],
);
4. Superposición en el Eje Z: Stack y Positioned
Mientras que Row y Column distribuyen en una sola dimensión (1D), Stack permite superponer widgets unos encima de otros a lo largo del eje Z.
El orden de declaración en la lista children define la profundidad: el primer elemento queda al fondo y el último se dibuja en la cima.
// Tarjeta de perfil médico con avatar e indicador de estado "En línea"
SizedBox(
width: 90,
height: 90,
child: Stack(
clipBehavior: Clip.none, // Permite que elementos sobresalgan del marco
children: [
// 1. Imagen base circular
const CircleAvatar(
radius: 42,
backgroundImage: NetworkImage('https://webcode.net.pe/medico1.jpg'),
),
// 2. Insignia flotante anclada a la esquina inferior derecha
Positioned(
bottom: 2,
right: 2,
child: Container(
width: 18,
height: 18,
decoration: BoxDecoration(
color: Colors.greenAccent[700],
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2.5),
),
),
),
],
),
);
5. ¿Qué Ocurre al Girar el Celular? Rotación y Adaptabilidad
Al girar un dispositivo físico de vertical (Portrait) a horizontal (Landscape), muchos desarrolladores
temen perder los datos de su pantalla. En los sistemas nativos tradicionales (como Android con Java o Kotlin),
la rotación destruye y vuelve a crear la Activity por defecto, borrando variables de memoria si no se gestionan
explícitamente.
¡Flutter NO destruye la aplicación ni borra tu State! El árbol de elementos y los objetos
de estado en memoria RAM permanecen 100% intactos: los textos ingresados en los controladores, las listas y los contadores
no se pierden. Lo que ocurre es que el motor detecta la inversión geométrica del tamaño de la ventana en
MediaQueryData y dispara automáticamente una nueva pasada del método build(context)
para adaptar el renderizado al nuevo Viewport.
OrientationBuilder redistribuye el layout vertical en 2 columnas maestras para aprovechar el ancho y eliminar el desbordamiento.
6. Implementación Adaptativa con OrientationBuilder
Para adaptar la interfaz automáticamente a la orientación física sin duplicar lógica de negocio, Flutter
provee el widget OrientationBuilder:
import 'package:flutter/material.dart';
class DirectorioClinicoAdaptativo extends StatelessWidget {
const DirectorioClinicoAdaptativo({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Agenda Médica')),
body: OrientationBuilder(
builder: (BuildContext context, Orientation orientation) {
// Si el usuario sostiene el teléfono en vertical: 1 sola columna
if (orientation == Orientation.portrait) {
return const ListaMedicosVertical();
}
// Si el usuario gira el teléfono en horizontal: 2 columnas maestro-detalle
return const Row(
children: [
Expanded(flex: 2, child: ListaMedicosVertical()),
VerticalDivider(width: 1),
Expanded(flex: 3, child: FichaDetalleConsulta()),
],
);
},
),
);
}
}
Si tu aplicación móvil solo está diseñada para operar de forma vertical (por ejemplo, en terminales de pago POS o flujos muy estrictos),
puedes bloquear la orientación globalmente antes de invocar runApp() mediante SystemChrome:
import 'package:flutter/services.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Forzar que la app solo se ejecute en vertical
await SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
]);
runApp(const MiApp());
}
Puntos clave del capítulo
Rowmaneja el eje X como primario y el eje Y como cruzado;Columninvierte este comportamiento.Expandedconsume todo el espacio libre restante forzosamente;Flexiblepermite al hijo respetar su tamaño intrínseco.- Usa
Spacer()para empujar widgets a los extremos opuestos de una fila o columna. - Para evitar el error de desbordamiento (overflow), recurre a
Expandeden textos,SingleChildScrollViewen formularios yWrapen chips. Stackcompone interfaces en capas sobre el eje Z yPositionedubica elementos por coordenadas milimétricas.- Al girar el celular, Flutter NO destruye el objeto
State; elMediaQueryDatase recalcula yOrientationBuilderpermite reconfigurar el layout en 2 columnas sin desbordamiento.
Cajas, decoración y dimensiones
Domina el estilizado visual y la geometría de interfaz: personalización profunda con BoxDecoration (bordes, gradientes, sombras y formas), la regla de oro para evitar conflictos de color, optimización con SizedBox y el uso de Card en Material 3.
- Dominar BoxDecoration: aplicar colores, gradientes lineales, bordes perimetrales y sombras difusas (
BoxShadow). - Evitar el assert de colisión de color: entender por qué no se debe mezclar
colorcondecorationen unContainer. - Optimizar el rendimiento del árbol: preferir
SizedBoxoPaddingantes de crear contenedores sobrecargados. - Gestionar restricciones geométricas: utilizar
ConstrainedBoxy recortar desbordes conclipBehavior.
1. Anatomía y Decoración Profunda con Container
El widget Container es el equivalente más cercano a un <div> estilizado de la web. Combina posicionamiento, márgenes externos, rellenos internos, dimensiones fijas y decoración gráfica:
Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
// 1. Gradiente de fondo con dos paradas de color
gradient: const LinearGradient(
colors: [Color(0xFF02569B), Color(0xFF0175C2)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
// 2. Esquinas redondeadas
borderRadius: BorderRadius.circular(16),
// 3. Borde perimetral sutil
border: Border.all(color: Colors.white.withOpacity(0.2), width: 1.5),
// 4. Sombras proyectadas en elevación
boxShadow: [
BoxShadow(
color: const Color(0xFF02569B).withOpacity(0.35),
offset: const Offset(0, 6),
blurRadius: 12,
spreadRadius: 1,
),
],
),
child: const Text(
'Tarjeta Promocional Clínica 2026',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
),
);
La regla de oro: Colisión de color en Container
En Flutter, jamás debes declarar la propiedad color directamente en Container si ya estás utilizando decoration: BoxDecoration(...). El framework arrojará un error fatal en tiempo de ejecución: «Cannot provide both a color and a decoration». Cuando uses decoration, el color de fondo debe ir siempre dentro del BoxDecoration.
2. Comparativa de Rendimiento: ¿Container, Padding o SizedBox?
Un error muy extendido entre desarrolladores principiantes es recurrir a Container para cualquier separación visual.
Bajo el capó, Container es un widget compuesto sumamente pesado que puede instanciar hasta seis widgets internos (DecoratedBox, Padding, LimitedBox, ConstrainedBox, Align y Transform).
| Widget | Costo en Memoria y Renderizado | Cuándo utilizarlo exclusivamente |
|---|---|---|
const SizedBox |
Mínimo / Casi cero | Para generar espacios en blanco verticales/horizontales o forzar un ancho o alto exacto a un botón. |
const Padding |
Muy bajo | Cuando solo necesitas espacio interno alrededor de un hijo, sin fondos, bordes ni sombras. |
Container |
Alto (compuesto) | Úsalo únicamente si vas a combinar al menos dos características (ej. fondo con borde y margen). |
3. Restricciones Geométricas con ConstrainedBox
En ocasiones deseas que una caja no tenga un tamaño rígido, sino que se mueva dentro de un rango aceptable (por ejemplo, que tenga un ancho mínimo de 120 px pero no supere los 300 px en pantallas grandes):
ConstrainedBox(
constraints: const BoxConstraints(
minWidth: 140,
maxWidth: 280,
minHeight: 48,
),
child: ElevatedButton(
onPressed: () {},
child: const Text('Botón con tamaño inteligente'),
),
);
4. Tarjetas Material 3 con Card y ClipBehavior
El widget Card representa una superficie física elevada según las directrices de Material Design.
Un problema frecuente ocurre al colocar imágenes dentro de una tarjeta: la imagen, por defecto, se dibuja en forma rectangular pura y tapa las esquinas redondeadas de la tarjeta.
Para resolverlo se utiliza clipBehavior: Clip.antiAlias:
Card(
elevation: 2,
// clipBehavior recorta a los hijos para respetar el borde circular
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Image.network(
'https://webcode.net.pe/sala_cirugia.jpg',
height: 140,
width: double.infinity,
fit: BoxFit.cover,
),
const Padding(
padding: EdgeInsets.all(12),
child: Text(
'Quirófano Central 01',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
),
],
),
);
Puntos clave del capítulo
BoxDecorationagrupa el color, bordes, gradientes y sombras de unContainer.- Nunca mezcles el argumento
coloren la raíz de unContainersi ya declarasdecoration. - Utiliza
const SizedBox()yconst Padding()para ahorrar memoria en lugar de instanciar contenedores vacíos. ConstrainedBoxestablece límites elásticos conminWidth,maxWidth,minHeightymaxHeight.- Aplica
clipBehavior: Clip.antiAliasen unCardpara que las imágenes respeten el redondeo de esquinas.
Listas y cuadrículas eficientes con scroll
Aprende a renderizar colecciones masivas de datos sin penalizar la memoria del dispositivo: reciclaje de memoria en el Viewport con ListView.builder, divisores automáticos con ListView.separated, mallas 2D con GridView y la maquetación limpia de filas mediante ListTile.
- Dominar el reciclaje de Viewport: por qué
ListView.builderes la solución obligatoria para listas de más de 20 elementos. - Insertar separadores limpios: implementar
ListView.separatedcon divisores estandarizados. - Optimizar la tasa de refresco a 120 fps: acelerar el scroll mediante la propiedad
itemExtenty evitar la trampa deshrinkWrap. - Construir cuadrículas responsivas: configurar
GridView.buildercon delegados de conteo fijo o extensión máxima. - Estructurar filas con ListTile: anatomía de ranuras con
leading,title,subtitleytrailing.
1. La Ilusión del Scroll: ¿Cómo funciona el Viewport?
En una aplicación móvil con 5,000 registros de pacientes o productos, instanciar los 5,000 widgets de golpe en memoria provocaría un colapso por saturación de RAM (Out of Memory) y congelaría la pantalla durante segundos.
Flutter resuelve esto mediante el concepto de Viewport (Ventana de Visualización):
ListView(children: [...]): instancia todos los widgets de la lista inmediatamente al arrancar. Es aceptable únicamente para listas estáticas muy cortas (pantallas de configuración o menos de 15 items).ListView.builder(): utiliza evaluación perezosa (lazy loading). Solo construye e instancia los widgets que son visibles en la pantalla del usuario en ese instante exacto. Conforme el usuario desliza hacia abajo, los widgets que salen por arriba son destruidos o reciclados, manteniendo un consumo de memoria constante y mínimo.
class Medico {
final int id;
final String nombre;
final String especialidad;
final bool activo;
const Medico({required this.id, required this.nombre, required this.especialidad, required this.activo});
}
// Renderizado perezoso de 10,000 médicos con consumo constante de RAM
ListView.builder(
itemCount: listaMedicos.length,
itemBuilder: (BuildContext context, int index) {
final medico = listaMedicos[index];
return ListTile(
leading: CircleAvatar(
child: Text('${medico.id}'),
),
title: Text(medico.nombre, style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text(medico.especialidad),
trailing: Icon(
medico.activo ? Icons.check_circle : Icons.cancel,
color: medico.activo ? Colors.green : Colors.grey,
),
onTap: () {
// Navegar al perfil del médico
},
);
},
);
2. Separadores Automáticos con ListView.separated
Cuando requieres insertar una línea divisoria (Divider) o un espacio constante entre cada elemento sin tener que evaluar manualmente si te encuentras en el último registro:
ListView.separated(
itemCount: pacientes.length,
separatorBuilder: (context, index) => const Divider(
height: 1,
indent: 16,
endIndent: 16,
),
itemBuilder: (context, index) {
return ListTile(
title: Text(pacientes[index].nombreCompleto),
subtitle: Text('DNI: ${pacientes[index].documento}'),
);
},
);
3. Optimización Crítica: itemExtent y la Trampa de shrinkWrap
Para garantizar una tasa fluida de 60 o 120 fotogramas por segundo al desplazarse rápidamente por una lista, existen dos parámetros de rendimiento indispensables:
| Propiedad | Impacto en Rendimiento | Explicación técnica |
|---|---|---|
itemExtent: 72.0 |
Aceleración masiva | Si todos los elementos de la lista miden la misma altura fija (por ejemplo, 72 px), infórmaselo a Flutter mediante itemExtent. Esto evita que el motor tenga que medir cada widget en tiempo de ejecución, permitiendo saltos instantáneos de scroll. |
shrinkWrap: true |
Peligro en listas grandes | Evita usar shrinkWrap: true en listas con más de 30 elementos. Esta propiedad obliga al ListView a calcular la altura total acumulada de todos sus hijos a la vez, anulando el reciclaje perezoso del Viewport y congelando la UI. |
4. Cuadrículas Malla con GridView.builder
Para presentar catálogos comerciales, galerías fotográficas o paneles de métricas en dos dimensiones (filas y columnas), utilizamos GridView.builder:
GridView.builder(
padding: const EdgeInsets.all(12),
itemCount: especialidades.length,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, // 2 columnas exactas
crossAxisSpacing: 10, // Separación horizontal entre columnas
mainAxisSpacing: 10, // Separación vertical entre filas
childAspectRatio: 1.2, // Proporción ancho / alto de cada celda
),
itemBuilder: (context, index) {
final esp = especialidades[index];
return Card(
elevation: 2,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.local_hospital, size: 32, color: Color(0xFF02569B)),
const SizedBox(height: 8),
Text(esp.nombre, style: const TextStyle(fontWeight: FontWeight.bold)),
],
),
),
);
},
);
Cuadrículas responsivas automáticas
Si deseas que tu cuadrícula se adapte dinámicamente entre teléfonos (2 columnas) y tablets (4 o 5 columnas) sin condicionales manuales, sustituye SliverGridDelegateWithFixedCrossAxisCount por SliverGridDelegateWithMaxCrossAxisExtent(maxCrossAxisExtent: 180): Flutter calculará cuántas columnas de máximo 180 px caben en el ancho de la pantalla actual.
Puntos clave del capítulo
ListView.builderutiliza evaluación perezosa en el Viewport, construyendo solo los widgets visibles y reciclando memoria al deslizar.ListView.separatedes el método canónico para inyectar divisores visuales limpios entre elementos de una colección.- Fijar
itemExtentahorra el cálculo de geometría a Flutter y acelera el scroll a 120 fps sostenidos. - Evita
shrinkWrap: trueen listas extensas para no anular el aislamiento del Viewport. GridView.buildermaqueta mallas bidimensionales mediante delegados de conteo fijo o extensión máxima adaptativa.
Botones, gestos y feedback táctil
Aprende a capturar la interacción del usuario con rigor técnico: la jerarquía de botones en Material 3 (Filled, ElevatedButton, Outlined y TextButton), personalización avanzada con ButtonStyle, detección de gestos complejos con GestureDetector y el feedback táctil con ondas de tinta (InkWell).
- Dominar la jerarquía de botones en Material 3: cuándo usar
FilledButton,ElevatedButton,OutlinedButtonoTextButton. - Personalizar estilos sin fricción: aplicar colores, redondeos y paddings mediante
styleFrom(). - Controlar el estado deshabilitado: el mecanismo idiomático de Flutter con
onPressed: null. - Diferenciar GestureDetector de InkWell: ondas táctiles (ripple effect) sobre Material frente a detectores de gestos puros (doble toque, arrastre y pulsación larga).
1. La Jerarquía de Botones en Material 3
En Material 3, los botones no son meros contenedores con eventos de clic: representan una jerarquía visual de énfasis que guía la mirada y la intención del usuario en la pantalla:
| Widget | Nivel de Énfasis | Apariencia M3 | Caso de Uso Recomendado |
|---|---|---|---|
FilledButton |
Énfasis Máximo | Color de fondo sólido primario, sin sombra de elevación. | La acción final y determinante de una pantalla (ej: «Pagar Consulta», «Guardar Paciente»). |
FilledButton.tonal |
Énfasis Alto-Medio | Fondo con tono secundario suave y menor contraste. | Acciones importantes que no deben competir con el botón primario principal. |
ElevatedButton |
Énfasis Medio | Superficie elevada con sombra proyectada sutil. | Acciones en pantallas planas con fondos dinámicos o listas con relieve. |
OutlinedButton |
Énfasis Medio-Bajo | Borde perimetral sin relleno de fondo en reposo. | Acciones alternativas directas (ej: «Descargar Receta», «Ver Historial»). |
TextButton |
Énfasis Mínimo | Texto puro sin borde ni fondo visible. | Opciones de cancelación, enlaces de pie o dentro de cuadros de diálogo. |
2. Personalización con styleFrom() y Estado Deshabilitado
Para personalizar botones en Flutter se utiliza el helper estático styleFrom(), evitando la complejidad innecesaria de construir un ButtonStyle desde cero:
FilledButton.icon(
// Si onPressed es null, Flutter deshabilita el botón automáticamente
onPressed: formularioValido ? () => confirmarCita() : null,
icon: const Icon(Icons.check_circle_outline),
label: const Text('Confirmar Agendamiento'),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF02569B),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
);
¿Cómo deshabilitar un botón idiomáticamente?
En Flutter no existe una propiedad booleana disabled: true. La convención obligatoria del framework consiste en pasar null al parámetro onPressed. Automáticamente el botón adopta la opacidad reducida y los colores deshabilitados de la paleta de Material 3.
3. Feedback Táctil: InkWell vs GestureDetector
Cuando deseas que un elemento gráfico ordinario (como un Container, un Card o una imagen) reaccione a toques del usuario, dispones de dos mecanismos con propósitos arquitectónicos muy distintos:
InkWell (Feedback Material)
Produce el clásico efecto de ondas de tinta líquida (splash ripple) que emana desde el punto exacto donde el usuario apoya el dedo.
Requiere que exista un ancestro Material para pintar la tinta.
Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(12),
splashColor: Colors.blueAccent.withOpacity(0.3),
onTap: () => abrirDetalle(),
child: const Padding(
padding: EdgeInsets.all(16),
child: Text('Elemento con onda táctil'),
),
),
);
GestureDetector (Gestos Puros)
No produce efectos visuales por sí mismo. Es un detector de eventos crudos de bajo nivel que reconoce doble toque, pulsación sostenida, deslizamiento (swipe) y arrastre (drag).
GestureDetector(
onTap: () => registrarToque(),
onDoubleTap: () => darMeGusta(),
onLongPress: () => mostrarMenuContextual(),
onPanUpdate: (detalles) {
// detalles.delta.dx contiene el arrastre en X
},
child: const Icon(Icons.touch_app, size: 48),
);
Puntos clave del capítulo
- Material 3 establece una jerarquía clara:
FilledButtonpara acciones primarias,OutlinedButtonpara secundarias yTextButtonpara terciarias. - Usa
styleFrom()en los constructores de botones para configurar colores, rellenos y bordes de forma concisa. - Para deshabilitar cualquier botón en Flutter, asigna
onPressed: null. - Usa
InkWellcuando requieras la animación visual de ondas táctiles sobre una superficie de Material. - Usa
GestureDetectorpara eventos gestuales complejos como doble toque, pulsación larga o arrastre libre.
Formularios y validación de entradas
Aprende a capturar y auditar datos de usuario con robustez: la tríada Form, GlobalKey y TextFormField, reglas de validación reactivas con expresiones regulares, gestión de controladores (TextEditingController), flujo de foco con FocusNode y el control profesional del teclado móvil.
- Implementar la arquitectura Form: orquestar el estado con
GlobalKey<FormState>y validaciones centralizadas. - Construir validadores puros: validar DNI de 8 dígitos, correos electrónicos y contraseñas con expresiones regulares.
- Manejar controladores y memoria: ciclo de vida de
TextEditingControllery la regla obligatoria dedispose(). - Optimizar la experiencia del teclado móvil:
TextInputType,TextInputAction, saltos de foco conFocusNodey cierre automático conunfocus().
1. La Tríada de Formularios: Form, GlobalKey y TextFormField
En aplicaciones reales no se utilizan campos TextField aislados para registrar pacientes o procesar pagos.
Se utiliza la arquitectura de Form, la cual conecta múltiples campos bajo una sola llave global (GlobalKey) que permite disparar validaciones atómicas en bloque:
class FormularioPacienteState extends State<FormularioPaciente> {
// 1. Llave global que referencia el estado del formulario
final _formKey = GlobalKey<FormState>();
// 2. Controladores de texto para leer y mutar valores
late final TextEditingController _nombreCtrl;
late final TextEditingController _dniCtrl;
late final TextEditingController _emailCtrl;
@override
void initState() {
super.initState();
_nombreCtrl = TextEditingController();
_dniCtrl = TextEditingController();
_emailCtrl = TextEditingController();
}
@override
void dispose() {
// 3. Regla de oro: liberar siempre los controladores en dispose
_nombreCtrl.dispose();
_dniCtrl.dispose();
_emailCtrl.dispose();
super.dispose();
}
void _enviarFormulario() {
// 4. validate() dispara todas las funciones validator del árbol
if (_formKey.currentState!.validate()) {
// Si todos los campos son válidos, procedemos al guardado
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Guardando paciente en base de datos...')),
);
}
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(
children: [
// Campos TextFormField integrados...
],
),
);
}
}
2. Reglas de Validación con Expresiones Regulares
Cada TextFormField recibe una función pura en su propiedad validator:
si retorna una cadena de texto (String), Flutter pinta el mensaje de error en color rojo debajo del campo; si retorna null, la validación se considera exitosa:
// 1. Campo Nombre (no vacío)
TextFormField(
controller: _nombreCtrl,
decoration: const InputDecoration(
labelText: 'Nombres y Apellidos *',
prefixIcon: Icon(Icons.person),
border: OutlineInputBorder(),
),
validator: (valor) {
if (valor == null || valor.trim().isEmpty) {
return 'El nombre es obligatorio';
}
return null;
},
),
const SizedBox(height: 16),
// 2. Campo DNI (Exactamente 8 dígitos numéricos peruanos)
TextFormField(
controller: _dniCtrl,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Documento Nacional de Identidad (DNI) *',
prefixIcon: Icon(Icons.badge),
border: OutlineInputBorder(),
),
validator: (valor) {
if (valor == null || valor.trim().isEmpty) {
return 'Ingrese el número de DNI';
}
final dniRegex = RegExp(r'^\d{8}$');
if (!dniRegex.hasMatch(valor.trim())) {
return 'El DNI debe contener exactamente 8 dígitos numéricos';
}
return null;
},
),
const SizedBox(height: 16),
// 3. Campo Correo Electrónico
TextFormField(
controller: _emailCtrl,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Correo Electrónico *',
prefixIcon: Icon(Icons.email),
border: OutlineInputBorder(),
),
validator: (valor) {
if (valor == null || valor.trim().isEmpty) {
return 'Ingrese un correo electrónico';
}
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
if (!emailRegex.hasMatch(valor.trim())) {
return 'Formato de correo no válido';
}
return null;
},
),
3. Gestión Profesional del Teclado y Foco (FocusNode)
En dispositivos móviles, obligar al usuario a tocar manualmente cada campo en pantalla resulta tedioso.
Para brindar una experiencia de usuario fluida se utiliza FocusNode para encadenar campos con el botón "Siguiente" (→) del teclado virtual:
// Declarar los nodos de foco en la clase State
final _nodoDni = FocusNode();
final _nodoEmail = FocusNode();
// En el primer campo:
TextFormField(
controller: _nombreCtrl,
textInputAction: TextInputAction.next, // Muestra botón "Siguiente" en el teclado
onFieldSubmitted: (_) {
// Salta automáticamente al campo DNI
FocusScope.of(context).requestFocus(_nodoDni);
},
);
// En el segundo campo:
TextFormField(
controller: _dniCtrl,
focusNode: _nodoDni,
textInputAction: TextInputAction.next,
onFieldSubmitted: (_) {
FocusScope.of(context).requestFocus(_nodoEmail);
},
);
4. Cerrar el Teclado al Tocar Fuera de la Pantalla
Un reclamo habitual en apps móviles es que el teclado virtual permanece abierto tapando botones de confirmación incluso cuando el usuario pulsa sobre zonas vacías de la pantalla.
El patrón canónico para resolverlo en Flutter consiste en envolver el cuerpo de la vista en un GestureDetector con unfocus():
// Al tocar cualquier área vacía, el teclado se retrae suavemente
GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: Scaffold(
appBar: AppBar(title: const Text('Registro')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
children: [
// Campos de formulario...
],
),
),
),
),
);
Puntos clave del capítulo
Formcentraliza la gestión del formulario y se controla externamente mediante unaGlobalKey<FormState>.- El método
_formKey.currentState!.validate()ejecuta las validaciones de todos losTextFormFielden cascada. - Todo
TextEditingControllerinstanciado debe ser liberado obligatoriamente en el métododispose()para erradicar fugas de memoria. - Usa
textInputAction: TextInputAction.nextyFocusNodepara guiar al usuario campo por campo con el teclado. - Envuelve tu pantalla en
GestureDetector(onTap: () => FocusScope.of(context).unfocus())para retraer el teclado al tocar fuera.
Diálogos modales, alertas y pestañas
Aprende a gestionar la atención y la navegación contextual del usuario: cuadros de diálogo de confirmación tipados con AlertDialog, menús modales con SimpleDialog, pestañas sincronizadas con TabBar y TabBarView, y la persistencia de vistas con AutomaticKeepAliveClientMixin.
- Lanzar alertas de confirmación:
showDialog<bool>yAlertDialogcon retorno asíncrono tipado. - Construir selectores modales: menús de selección rápida con
SimpleDialogySimpleDialogOption. - Dominar el sistema de pestañas:
DefaultTabControllerfrente al control programático conTabController. - Evitar la pérdida de estado en pestañas: conservar datos de scroll y formularios con
AutomaticKeepAliveClientMixin.
1. Cuadros de Diálogo de Confirmación: AlertDialog
En Flutter, los cuadros de diálogo son funciones asíncronas que se abren sobre la ruta actual mediante showDialog<T>().
Al cerrarse, devuelven un valor fuertemente tipado a través de Navigator.pop(context, resultado):
Future<void> confirmarCancelacionCita(BuildContext context, int citaId) async {
// 1. showDialog retorna un Future con el valor devuelto en Navigator.pop
final bool? confirmar = await showDialog<bool>(
context: context,
// barrierDismissible: false impide cerrar el diálogo pulsando fuera
barrierDismissible: false,
builder: (BuildContext ctx) {
return AlertDialog(
icon: const Icon(Icons.warning_amber_rounded, color: Colors.redAccent, size: 36),
title: const Text('¿Cancelar Cita Médica?'),
content: Text('Esta acción liberará el turno #$citaId para otro paciente.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false), // Retorna false
child: const Text('Conservar Cita'),
),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: Colors.redAccent),
onPressed: () => Navigator.pop(ctx, true), // Retorna true
child: const Text('Sí, Cancelar'),
),
],
);
},
);
// 2. Evaluamos la decisión del usuario de forma asíncrona
if (confirmar == true && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('La cita médica ha sido cancelada')),
);
}
}
2. Selectores Rápidos con SimpleDialog
Para presentar una lista de opciones exclusivas (como elegir el médico de turno o el método de pago) se emplea SimpleDialog con opciones SimpleDialogOption:
Future<String?> seleccionarMetodoPago(BuildContext context) async {
return await showDialog<String>(
context: context,
builder: (BuildContext ctx) {
return SimpleDialog(
title: const Text('Seleccione método de pago'),
children: [
SimpleDialogOption(
onPressed: () => Navigator.pop(ctx, 'EFECTIVO'),
child: const ListTile(leading: Icon(Icons.money), title: Text('Efectivo en caja')),
),
SimpleDialogOption(
onPressed: () => Navigator.pop(ctx, 'TARJETA'),
child: const ListTile(leading: Icon(Icons.credit_card), title: Text('Tarjeta Débito/Crédito')),
),
SimpleDialogOption(
onPressed: () => Navigator.pop(ctx, 'YAPE_PLIN'),
child: const ListTile(leading: Icon(Icons.qr_code), title: Text('Billetera Digital (Yape / Plin)')),
),
],
);
},
);
}
3. Sistema de Pestañas: DefaultTabController vs TabController
El sistema de pestañas en Flutter se compone de dos widgets gemelos perfectamente sincronizados:
TabBar: la barra de botones superior con el indicador deslizante.TabBarView: el contenedor de pantallas que conmuta las vistas mediante deslizamiento horizontal (*swipe*).
class PantallaCitasTabs extends StatelessWidget {
const PantallaCitasTabs({super.key});
@override
Widget build(BuildContext context) {
// DefaultTabController administra el estado de las 3 pestañas automáticamente
return DefaultTabController(
length: 3,
child: Scaffold(
appBar: AppBar(
title: const Text('Consultas Médicas'),
bottom: const TabBar(
indicatorSize: TabBarIndicatorSize.tab,
tabs: [
Tab(icon: Icon(Icons.schedule), text: 'Pendientes'),
Tab(icon: Icon(Icons.check_circle_outline), text: 'Atendidas'),
Tab(icon: Icon(Icons.highlight_off), text: 'Canceladas'),
],
),
),
body: const TabBarView(
children: [
VistaCitasPendientes(),
VistaCitasAtendidas(),
VistaCitasCanceladas(),
],
),
),
);
}
}
4. Persistencia de Vistas con AutomaticKeepAliveClientMixin
Por defecto, TabBarView destruye de memoria los widgets de las pestañas inactivas para ahorrar RAM.
El problema es que si el usuario había hecho scroll hasta el elemento 50 o estaba llenando un formulario, al volver a esa pestaña la vista se reconstruye desde cero.
Para ordenar a Flutter que conserve viva la vista en memoria utilizamos AutomaticKeepAliveClientMixin:
class VistaCitasPendientes extends StatefulWidget {
const VistaCitasPendientes({super.key});
@override
State<VistaCitasPendientes> createState() => _VistaCitasPendientesState();
}
// Aplicamos el mixin AutomaticKeepAliveClientMixin en la clase State
class _VistaCitasPendientesState extends State<VistaCitasPendientes>
with AutomaticKeepAliveClientMixin {
// 1. Declarar wantKeepAlive en true para preservar el estado
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
// 2. Es obligatorio invocar super.build(context) como primera línea
super.build(context);
return ListView.builder(
itemCount: 100,
itemBuilder: (context, i) => ListTile(title: Text('Cita #$i (Preservada)')),
);
}
}
Puntos clave del capítulo
showDialog<T>es una función asíncrona que retorna valores tipados medianteNavigator.pop(context, valor).- Configura
barrierDismissible: falsecuando requieras que el usuario elija obligatoriamente una opción de diálogo sin pulsar fuera. - Usa
SimpleDialogpara selecciones de lista únicas y limpias. DefaultTabControllersincroniza el encabezadoTabBarcon el cuerpo deslizanteTabBarViewsin controladores manuales.- Implementa
AutomaticKeepAliveClientMixinconwantKeepAlive => truepara evitar que las pestañas pierdan su estado o scroll al cambiar de vista.
Navegación básica e imperativa (Navigator 1.0)
Comprende el modelo de pila LIFO (Last-In, First-Out) que rige las pantallas en Flutter: transiciones nativas con MaterialPageRoute, transferencia de parámetros tipados, retorno asíncrono de resultados con Navigator.pop, manipulación del historial y protección contra salidas accidentales con PopScope.
- Gestionar la pila de rutas: apilar y desapilar pantallas con
Navigator.push()yNavigator.pop(). - Transferencia tipada de datos: inyección de argumentos por constructor y captura de retornos con
await. - Control de flujo e historial: reemplazar rutas con
pushReplacement()y vaciar la pila conpushAndRemoveUntil(). - Intercepción de retroceso moderno: control de salida y confirmación de abandono con
PopScope(Flutter 3.22+).
1. La Pila de Rutas (Stack LIFO)
En Flutter, las pantallas o vistas se denominan Routes y residen dentro de una pila de navegación
gestionada por el widget Navigator. El funcionamiento sigue el principio LIFO (Last-In, First-Out):
la última pantalla apilada es la visible para el usuario, y al pulsar el botón de retroceso se desapila para revelar la anterior.
// Comprobar si el usuario puede retroceder en la pila actual
if (Navigator.canPop(context)) {
Navigator.pop(context);
}
2. Navegación hacia Adelante con MaterialPageRoute
Para abrir una nueva pantalla utilizamos Navigator.push(), envolviendo el widget de destino en un
MaterialPageRoute. Esta clase se encarga de aplicar la animación de transición nativa
de la plataforma (deslizamiento lateral en iOS o elevación/desvanecimiento suave en Android Material 3):
import 'package:flutter/material.dart';
// Pantalla Origen (Lista de Médicos)
class PantallaMedicos extends StatelessWidget {
const PantallaMedicos({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Directorio Médico')),
body: Center(
child: FilledButton.icon(
icon: const Icon(Icons.person),
label: const Text('Ver Ficha del Dr. Morales'),
onPressed: () {
// Navegamos pasando parámetros tipados directamente al constructor
Navigator.push(
context,
MaterialPageRoute(
builder: (BuildContext ctx) => const PantallaDetalleMedico(
medicoId: 104,
nombre: 'Dr. Roberto Morales',
especialidad: 'Cardiología',
),
),
);
},
),
),
);
}
}
Evita usar rutas mágicas con cadenas de texto (Navigator.pushNamed('/detalle')) en proyectos grandes.
Pasar datos mediante los constructores de tus widgets garantiza que si un parámetro cambia de tipo o nombre,
el compilador de Dart detectará el error de inmediato sin fallar en tiempo de ejecución.
3. Retorno Asíncrono de Datos con Navigator.pop y await
El método Navigator.push<T>() devuelve un Future<T?> que se resuelve cuando la
pantalla apilada se cierra mediante Navigator.pop<T>(context, valor). Esto permite construir flujos
interactivos como selectores, filtros o formularios de edición con total tipado:
// Pantalla Destino que solicita una acción y retorna un booleano
class PantallaConfirmarTurno extends StatelessWidget {
final int turnoId;
const PantallaConfirmarTurno({super.key, required this.turnoId});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Confirmar Turno #$turnoId')),
body: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
OutlinedButton(
onPressed: () => Navigator.pop(context, false), // Retorna false
child: const Text('Rechazar'),
),
const SizedBox(width: 16),
FilledButton(
onPressed: () => Navigator.pop(context, true), // Retorna true
child: const Text('Aceptar Turno'),
),
],
),
),
);
}
}
// Invocación asíncrona desde la pantalla principal
Future<void> solicitarAprobacion(BuildContext context, int turnoId) async {
// 1. Esperamos la respuesta fuertemente tipada como bool?
final bool? resultado = await Navigator.push<bool>(
context,
MaterialPageRoute(
builder: (context) => PantallaConfirmarTurno(turnoId: turnoId),
),
);
// 2. Regla de oro: verificar context.mounted tras cualquier pausa asíncrona
if (!context.mounted) return;
if (resultado == true) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('¡Turno médico confirmado exitosamente!')),
);
}
}
4. Manipulación Avanzada de la Pila
En ciertos flujos de la aplicación es imprescindible reemplazar pantallas o vaciar el historial por completo:
| Método | Comportamiento de la Pila | Caso de Uso Típico |
|---|---|---|
pushReplacement() |
Elimina la ruta actual e inserta la nueva en el mismo nivel. El usuario no puede regresar a la anterior. | Pantalla de Splash o Bienvenida hacia el Login; completar un paso irreversible de un wizard. |
pushAndRemoveUntil() |
Elimina rutas previas según un predicado lógico (o vacía todas) y sitúa la nueva como raíz. | Cerrar sesión (logout) para volver al Login sin historial; finalizar una compra/checkout. |
// Cerrar sesión y vaciar completamente la pila de rutas
void cerrarSesion(BuildContext context) {
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(builder: (context) => const PantallaLogin()),
(Route<dynamic> route) => false, // El predicado false destruye todo el historial previo
);
}
5. Intercepción de Retroceso con PopScope (Flutter 3.22+)
Históricamente se utilizaba el widget WillPopScope para interceptar el botón Atrás de Android o el gesto
de deslizamiento de iOS. A partir de Flutter 3.12 y consolidado en Flutter 3.22+, PopScope
es el estándar oficial que ofrece compatibilidad plena con la navegación predictiva por gestos de Android 14+:
class FormularioPacienteConGuardia extends StatefulWidget {
const FormularioPacienteConGuardia({super.key});
@override
State<FormularioPacienteConGuardia> createState() => _FormularioPacienteConGuardiaState();
}
class _FormularioPacienteConGuardiaState extends State<FormularioPacienteConGuardia> {
bool _hayCambiosSinGuardar = true;
Future<bool> _mostrarDialogoConfirmacion() async {
final bool? abandonar = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('¿Descartar cambios?'),
content: const Text('Tienes datos ingresados que se perderán si sales ahora.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Continuar editando'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Descartar y salir'),
),
],
),
);
return abandonar ?? false;
}
@override
Widget build(BuildContext context) {
return PopScope(
// Si no hay cambios sin guardar, permitimos el pop directo (canPop: true).
// Si hay cambios, bloqueamos el pop automático (canPop: false) para disparar el diálogo.
canPop: !_hayCambiosSinGuardar,
onPopInvokedWithResult: (bool didPop, dynamic result) async {
// Si el pop ya se ejecutó con éxito por el sistema, no hacemos nada
if (didPop) return;
// Si fue bloqueado, consultamos al usuario
final bool seguro = await _mostrarDialogoConfirmacion();
if (seguro && context.mounted) {
// Desactivamos la guardia y ejecutamos el pop manualmente
setState(() => _hayCambiosSinGuardar = false);
Navigator.pop(context);
}
},
child: Scaffold(
appBar: AppBar(title: const Text('Registro Clínico')),
body: const Center(
child: Text('Formulario con cambios pendientes de guardar...'),
),
),
);
}
}
6. Transiciones de Pantalla y Animaciones Compartidas con Hero
Mientras que MaterialPageRoute proporciona la animación por defecto del sistema operativo
(deslizamiento horizontal en iOS o zoom/elevación en Android), Flutter permite personalizar completamente
las curvas de transición entre pantallas mediante PageRouteBuilder y conectar elementos
visuales mediante el widget Hero:
Hero: Flutter extrae automáticamente el widget coincidente por su tag hacia el Overlay, calculando la trayectoria espacial y la escala entre ambas rutas en el push y su retorno simétrico en el pop.
7. Código de Implementación: Hero y PageRouteBuilder
Para lograr este efecto visual de alta gama, envolvemos el widget origen y el widget destino con Hero,
compartiendo exactamente la misma etiqueta (tag):
// EN PANTALLA A: Avatar pequeño en la lista
ListTile(
leading: Hero(
tag: 'avatar-dr-morales', // Identificador único compartido
child: CircleAvatar(
radius: 20,
backgroundColor: Colors.teal,
child: Text('RM', style: TextStyle(color: Colors.white)),
),
),
title: const Text('Dr. Roberto Morales'),
subtitle: const Text('Cardiología'),
onTap: () {
// Navegación con animación personalizada de deslizamiento suave (Slide)
Navigator.push(
context,
PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 500),
pageBuilder: (context, animacion, animacionSecundaria) {
return const PantallaFichaDetalleMedico();
},
transitionsBuilder: (context, animacion, animacionSecundaria, child) {
// Curva de aceleración natural
final curva = CurvedAnimation(parent: animacion, curve: Curves.easeInOutCubic);
return SlideTransition(
position: Tween<Offset>(begin: const Offset(0.0, 0.1), end: Offset.zero).animate(curva),
child: FadeTransition(opacity: curva, child: child),
);
},
),
);
},
);
// EN PANTALLA B: Cabecera expandida en la vista de detalle
class PantallaFichaDetalleMedico extends StatelessWidget {
const PantallaFichaDetalleMedico({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Ficha Médica')),
body: Center(
child: Column(
children: [
const SizedBox(height: 24),
// El mismo tag hace que el avatar "vuele" desde la lista hasta aquí
Hero(
tag: 'avatar-dr-morales',
child: CircleAvatar(
radius: 60,
backgroundColor: Colors.teal,
child: Text('RM', style: TextStyle(fontSize: 36, color: Colors.white)),
),
),
const SizedBox(height: 16),
const Text('Dr. Roberto Morales', style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold)),
],
),
),
);
}
}
Puntos clave del capítulo
- El modelo
Navigatorgestiona las pantallas en una estructura de pila LIFO sobre unOverlay. MaterialPageRouteadapta la transición visual automáticamente al sistema operativo (Android o iOS).- Pasa datos mediante argumentos tipados en el constructor del widget para beneficiarte del análisis estático de Dart.
await Navigator.push<T>()captura de forma asíncrona los valores devueltos porNavigator.pop<T>(context, valor).- Usa
pushAndRemoveUntil()para resetear el historial tras un inicio o cierre de sesión. PopScopecononPopInvokedWithResultreemplaza al obsoletoWillPopScopey garantiza compatibilidad con gestos predictivos modernos.- El widget
Herojunto conPageRouteBuilderpermite crear transiciones espaciales de elementos compartidos de máxima fluidez visual vinculando widgets con unatagidéntica.
Navegación declarativa con GoRouter
Adopta el estándar oficial de navegación moderna respaldado por el equipo de Flutter: enrutamiento basado en URLs y paths, soporte nativo de Deep Linking para Android/iOS y sincronización web, paso de parámetros de ruta y consulta, protección de rutas (guards) y gestión centralizada de páginas 404.
- Configurar MaterialApp.router: vincular el motor declarativo
GoRoutera la raíz del proyecto. - Estructurar el árbol de rutas: definir rutas principales y sub-rutas anidadas con
GoRoute. - Extraer parámetros tipados: leer
pathParametersyqueryParametersdesdeGoRouterState. - Dominar los verbos de navegación: diferencias clave entre
context.go(),context.push()ycontext.goNamed(). - Implementar guardias de autenticación: redirección automática global mediante la función
redirect.
1. ¿Por qué GoRouter frente a Navigator 1.0?
En aplicaciones profesionales multiplataforma (iOS, Android, Web y Escritorio), el modelo imperativo de Navigator.push
presenta limitaciones críticas: no soporta la barra de direcciones del navegador web, no gestiona enlaces profundos (Deep Links)
provenientes de notificaciones o correos, y dificulta la protección centralizada de rutas protegidas.
go_router resuelve esto modelando la navegación de forma declarativa basada en URLs.
# Instalación de la versión oficial en pubspec.yaml
flutter pub add go_router
2. Configuración Base con MaterialApp.router
Para habilitar GoRouter, reemplazamos el constructor habitual de MaterialApp por MaterialApp.router
y le asignamos una instancia configurada de GoRouter:
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
void main() {
runApp(const MiAppClinica());
}
// 1. Instanciamos el enrutador con su configuración de rutas
final GoRouter _appRouter = GoRouter(
initialLocation: '/',
routes: [
GoRoute(
path: '/',
name: 'inicio',
builder: (BuildContext context, GoRouterState state) => const PantallaInicio(),
routes: [
// Sub-ruta anidada: la URL resultante será '/pacientes'
GoRoute(
path: 'pacientes',
name: 'lista-pacientes',
builder: (context, state) => const PantallaPacientes(),
),
],
),
],
);
// 2. Conectamos GoRouter a la raíz de la aplicación
class MiAppClinica extends StatelessWidget {
const MiAppClinica({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'Clínica San Juan',
theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.teal),
routerConfig: _appRouter,
);
}
}
3. Parámetros de Ruta y de Consulta (Path & Query Params)
A través del objeto GoRouterState podemos capturar segmentos dinámicos de la URL
(ej. /pacientes/:id) y parámetros de consulta de tipo clave-valor (ej. /citas?estado=pendiente):
final GoRouter _routerConParametros = GoRouter(
routes: [
GoRoute(
path: '/paciente/:id',
name: 'detalle-paciente',
builder: (BuildContext context, GoRouterState state) {
// 1. Parámetro de ruta obligatorio (Path Parameter)
final String pacienteId = state.pathParameters['id'] ?? '0';
// 2. Parámetro de consulta opcional (Query Parameter desde URI)
final String? pestanaInicial = state.uri.queryParameters['tab'];
return PantallaDetallePaciente(
id: int.parse(pacienteId),
tabActiva: pestanaInicial ?? 'historial',
);
},
),
],
);
4. Métodos de Navegación: go() frente a push()
Una de las dudas más frecuentes al usar GoRouter es comprender cuándo utilizar context.go()
y cuándo context.push():
| Método | Acción sobre la Pila | Efecto en la URL Web | Caso de Uso Recomendado |
|---|---|---|---|
context.go('/ruta') |
Reemplaza la jerarquía completa por la ruta declarada según su posición en el árbol. | Sincroniza la URL exacta en el navegador. | Navegación principal, tabs, menús laterales, enlaces profundos. |
context.push('/ruta') |
Apila una pantalla encima de la actual sin alterar el árbol base. | Añade un escalón a la pila del navegador. | Flujos modales, vistas de previsualización temporal o formularios emergentes. |
context.goNamed('nombre') |
Navega por el nombre único de la ruta pasando un mapa de parámetros. | Construye la URL automáticamente según la definición. | Mejor práctica para no depender de cadenas de texto rígidas si cambias la estructura de URLs. |
// Navegación nombrada con parámetros tipados
void verDetallePaciente(BuildContext context, int id) {
context.goNamed(
'detalle-paciente',
pathParameters: {'id': id.toString()},
queryParameters: {'tab': 'recetas'},
);
}
// Cerrar la vista actual o retroceder
void volverAtras(BuildContext context) {
if (context.canPop()) {
context.pop();
}
}
5. Guardias de Autenticación y Redirección Global
La propiedad redirect de GoRouter evalúa las condiciones de la aplicación
antes de renderizar cualquier vista. Si el usuario no ha iniciado sesión, es redirigido automáticamente a la pantalla
de acceso sin permitir fugas de seguridad:
bool usuarioAutenticado = false; // Estado global o servicio de auth
final GoRouter _routerSeguro = GoRouter(
initialLocation: '/',
redirect: (BuildContext context, GoRouterState state) {
final bool enPantallaLogin = state.matchedLocation == '/login';
// 1. Si no está autenticado y no está en login, redirigir a /login
if (!usuarioAutenticado && !enPantallaLogin) {
return '/login';
}
// 2. Si ya está autenticado e intenta ir a /login, redirigir al panel principal
if (usuarioAutenticado && enPantallaLogin) {
return '/';
}
// 3. Retornar null significa "permitir el paso sin redirección"
return null;
},
routes: [
GoRoute(path: '/login', builder: (ctx, state) => const PantallaLogin()),
GoRoute(path: '/', builder: (ctx, state) => const PantallaDashboard()),
],
// 4. Gestión centralizada de errores y rutas no encontradas (404)
errorBuilder: (context, state) => Scaffold(
appBar: AppBar(title: const Text('Página no encontrada')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 64, color: Colors.redAccent),
const SizedBox(height: 16),
Text('Error 404: Ruta desconocida (${state.matchedLocation})'),
const SizedBox(height: 24),
FilledButton(
onPressed: () => context.go('/'),
child: const Text('Volver al inicio'),
),
],
),
),
),
);
Puntos clave del capítulo
GoRouteres la solución oficial y recomendada para enrutamiento declarativo en Flutter.- Conecta el enrutador a la app mediante el constructor
MaterialApp.router(routerConfig: ...). - Usa
state.pathParameterspara variables de segmento ystate.uri.queryParameterspara filtros opcionales. - Prefiere
context.goNamed()para desacoplar el código de navegación de la estructura física de URLs. - La función
redirectcentraliza la seguridad y los permisos de acceso en un único punto auditable. - Provee siempre un
errorBuilderpara ofrecer una experiencia elegante ante rutas inexistentes (404).
Menús de navegación y vistas múltiples
Estructura la navegación principal de tu aplicación para móvil, tablet y escritorio: implementa la barra inferior NavigationBar de Material 3, menús laterales NavigationDrawer, comprende el dilema de la destrucción de estado entre pantallas y soluciónalo con IndexedStack y StatefulShellRoute de GoRouter.
- Implementar la barra inferior Material 3:
NavigationBarcon píldoras de selección y destinos semánticos. - Construir menús laterales expansivos:
NavigationDrawerpara navegación en paneles principales y tablets. - Diagnosticar la pérdida de estado: entender por qué alternar el
bodyde un Scaffold destruye tus vistas. - Preservar vistas en memoria: sincronizar pantallas montadas con
IndexedStack. - Rutas anidadas con GoRouter: crear pestañas con historial independiente usando
StatefulShellRoute.indexedStack.
1. Barra de Navegación Inferior M3: NavigationBar
En Material 3, el antiguo widget BottomNavigationBar ha sido reemplazado por NavigationBar.
Este componente incorpora indicadores visuales en forma de píldora (pill), soporta iconos diferenciados para los estados
activo/inactivo y cumple con las directrices ergonómicas modernas de accesibilidad táctil:
class PantallaPrincipalM3 extends StatefulWidget {
const PantallaPrincipalM3({super.key});
@override
State<PantallaPrincipalM3> createState() => _PantallaPrincipalM3State();
}
class _PantallaPrincipalM3State extends State<PantallaPrincipalM3> {
int _indiceActual = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
bottomNavigationBar: NavigationBar(
selectedIndex: _indiceActual,
onDestinationSelected: (int nuevoIndice) {
setState(() => _indiceActual = nuevoIndice);
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.calendar_month_outlined),
selectedIcon: Icon(Icons.calendar_month),
label: 'Citas',
),
NavigationDestination(
icon: Icon(Icons.people_outline),
selectedIcon: Icon(Icons.people),
label: 'Pacientes',
),
NavigationDestination(
icon: Icon(Icons.medication_outlined),
selectedIcon: Icon(Icons.medication),
label: 'Recetas',
),
],
),
body: Center(
child: Text('Vista seleccionada: $_indiceActual'),
),
);
}
}
2. Menú Lateral Material 3: NavigationDrawer
Para pantallas con mayor superficie o para agrupar secciones secundarias de administración, Material 3 ofrece
NavigationDrawer. Se integra de manera natural tanto en la propiedad drawer
de un Scaffold en dispositivos móviles como en layouts permanentes para tablets:
Drawer construirMenuLateral(BuildContext context, int indice, ValueChanged<int> onSelect) {
return Drawer(
child: NavigationDrawer(
selectedIndex: indice,
onDestinationSelected: (idx) {
onSelect(idx);
Navigator.pop(context); // Cierra el drawer al seleccionar
},
children: const [
Padding(
padding: EdgeInsets.fromLTRB(28, 16, 16, 10),
child: Text('Portal Médico San Juan', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
),
NavigationDrawerDestination(
icon: Icon(Icons.dashboard_outlined),
selectedIcon: Icon(Icons.dashboard),
label: Text('Panel General'),
),
NavigationDrawerDestination(
icon: Icon(Icons.medical_services_outlined),
selectedIcon: Icon(Icons.medical_services),
label: Text('Consultas Activas'),
),
Divider(indent: 16, endIndent: 16),
NavigationDrawerDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: Text('Ajustes y Perfil'),
),
],
),
);
}
3. El Problema de la Pérdida de Estado entre Pestañas
Un error muy extendido al implementar barras de navegación es alternar el cuerpo del Scaffold utilizando una lista simple de widgets:
// ANTIPATRÓN: Destruye y recrea la vista en cada pulsación
body: _vistas[_indiceActual],
Al cambiar el widget en el body, Flutter desmonta completamente el Element y su State.
El usuario experimenta tres problemas graves:
1) Se pierde la posición de scroll en las listas;
2) Los datos no guardados en formularios o filtros desaparecen;
3) Se vuelven a disparar llamadas HTTP a la API cada vez que se regresa a la pestaña.
4. Solución 1: IndexedStack para Conservar Vistas en Memoria
El widget IndexedStack mantiene todos sus hijos montados permanentemente en el árbol de elementos.
Solo dibuja y procesa eventos de interacción para el widget indicado en index, ocultando los demás sin destruirlos:
class PantallaConEstadoPreservado extends StatefulWidget {
const PantallaConEstadoPreservado({super.key});
@override
State<PantallaConEstadoPreservado> createState() => _PantallaConEstadoPreservadoState();
}
class _PantallaConEstadoPreservadoState extends State<PantallaConEstadoPreservado> {
int _indice = 0;
// Las vistas hijas se conservarán intactas en memoria
final List<Widget> _paginas = const [
VistaListaCitas(),
VistaDirectorioPacientes(),
VistaHistorialRecetas(),
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(
index: _indice,
children: _paginas,
),
bottomNavigationBar: NavigationBar(
selectedIndex: _indice,
onDestinationSelected: (i) => setState(() => _indice = i),
destinations: const [
NavigationDestination(icon: Icon(Icons.event), label: 'Citas'),
NavigationDestination(icon: Icon(Icons.people), label: 'Pacientes'),
NavigationDestination(icon: Icon(Icons.receipt_long), label: 'Recetas'),
],
),
);
}
}
5. Solución 2: Rutas Anidadas con GoRouter (StatefulShellRoute)
En aplicaciones con GoRouter, la técnica profesional para construir barras de navegación persistentes con
pilas de navegación independientes por cada pestaña es StatefulShellRoute.indexedStack.
Esto permite que, si un usuario entra al detalle de un paciente en la pestaña 2 y luego salta a la pestaña 1,
al regresar a la pestaña 2 siga exactamente donde estaba sin reiniciar la navegación:
final GoRouter _routerConPestanas = GoRouter(
initialLocation: '/citas',
routes: [
StatefulShellRoute.indexedStack(
builder: (BuildContext context, GoRouterState state, StatefulNavigationShell navigationShell) {
// navigationShell contiene la vista activa y gestiona el cambio de ramas
return Scaffold(
body: navigationShell,
bottomNavigationBar: NavigationBar(
selectedIndex: navigationShell.currentIndex,
onDestinationSelected: (int index) {
// Navegación con opción de regresar a la raíz de la pestaña si ya está activa
navigationShell.goBranch(
index,
initialLocation: index == navigationShell.currentIndex,
);
},
destinations: const [
NavigationDestination(icon: Icon(Icons.calendar_today), label: 'Citas'),
NavigationDestination(icon: Icon(Icons.group), label: 'Pacientes'),
],
),
);
},
branches: [
// Rama 1: Citas
StatefulShellBranch(
routes: [
GoRoute(
path: '/citas',
builder: (context, state) => const VistaListaCitas(),
),
],
),
// Rama 2: Pacientes (con su propio sub-árbol de detalle)
StatefulShellBranch(
routes: [
GoRoute(
path: '/pacientes',
builder: (context, state) => const VistaDirectorioPacientes(),
routes: [
GoRoute(
path: 'detalle/:id',
builder: (context, state) => VistaFichaPaciente(id: state.pathParameters['id']!),
),
],
),
],
),
],
),
],
);
Puntos clave del capítulo
NavigationBarsustituye aBottomNavigationBarcumpliendo las directrices oficiales de Material 3.- Usa
NavigationDrawerpara interfaces en tablets o paneles de administración con múltiples secciones. - Reemplazar el
bodydel Scaffold con un selector directo destruye el estado, perdiendo la posición de scroll y datos en formularios. IndexedStackconserva en memoria todos los widgets hijos y solo pinta el seleccionado por índice.StatefulShellRoute.indexedStacken GoRouter permite mantener barras de navegación globales con historial y pila propia en cada pestaña.
Estado efímero vs estado de aplicación (setState)
Distingue con claridad conceptual cuándo una variable pertenece únicamente a un widget local y cuándo debe compartirse entre pantallas: domina la mecánica interna de setState, identifica las trampas de sobre-reconstrucción que degradan los fotogramas y aprende a aislar componentes interactivos.
- Clasificar el estado: diferenciar entre estado efímero (local) y estado de aplicación (global/compartido).
- Mecánica interna de setState: cómo marca los elementos como sucios (dirty) y programa el siguiente frame de renderizado.
- Evitar cuellos de botella: diagnosticar y erradicar la sobre-reconstrucción de árboles masivos de widgets.
- Aislamiento quirúrgico: modularizar componentes interactivos y usar
StatefulBuilderpara optimizar la GPU.
1. Taxonomía del Estado en Flutter
En el desarrollo con Flutter, la documentación oficial divide el estado de una aplicación en dos grandes categorías según su ciclo de vida y su alcance de visibilidad:
| Criterio | Estado Efímero (Local / UI State) | Estado de Aplicación (Shared / App State) |
|---|---|---|
| Definición | Información contenida de forma exclusiva dentro de un único widget. Si el widget se desmonta, el dato puede descartarse sin consecuencias. | Información que se comparte entre múltiples pantallas, persiste en sesiones o afecta la lógica de negocio central. |
| Ejemplos típicos | Pestaña seleccionada en un TabBar, visibilidad de una contraseña (icono de ojo), estado de apertura de un acordeón, animación en curso. |
Usuario autenticado en la clínica, carrito de compras, listado de citas sincronizadas, ajustes de tema oscuro/claro y preferencias del sistema. |
| Herramienta ideal | StatefulWidget con setState() o ValueNotifier local. |
Gestores de estado: Provider, Riverpod, Bloc. |
2. Mecánica Interna de setState
Cuando invocas setState(() { ... }) dentro de la clase State, estás ejecutando dos acciones concretas:
primero, modificas el valor sincrónico de las variables de instancia; segundo, notificas al framework mediante
_element.markNeedsBuild(). Flutter marca ese nodo del árbol de elementos como dirty (sucio)
y programa una llamada a su método build() en el siguiente refresco de pantalla (a 60 o 120 Hz).
class CampoPasswordSeguro extends StatefulWidget {
const CampoPasswordSeguro({super.key});
@override
State<CampoPasswordSeguro> createState() => _CampoPasswordSeguroState();
}
class _CampoPasswordSeguroState extends State<CampoPasswordSeguro> {
// Estado puramente efímero: solo le importa a este campo de texto
bool _ocultarClave = true;
@override
Widget build(BuildContext context) {
return TextFormField(
obscureText: _ocultarClave,
decoration: InputDecoration(
labelText: 'Contraseña de Acceso',
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(_ocultarClave ? Icons.visibility_off : Icons.visibility),
onPressed: () {
// Modificamos el valor e indicamos que se requiere un nuevo frame
setState(() {
_ocultarClave = !_ocultarClave;
});
},
),
),
);
}
}
El callback que recibe setState() debe ser estrictamente síncrono y rápido.
Nunca ejecutes await, lecturas de bases de datos o peticiones HTTP dentro de setState(() { ... }).
Resuelve la operación asíncrona primero, verifica if (!mounted) return; y finalmente asigna el resultado síncrono dentro de setState.
3. La Trampa de la Sobre-Reconstrucción
El error de rendimiento más común de los desarrolladores novatos es situar setState() en la raíz
de un Scaffold que aloja cientos de widgets hijos estáticos:
// ANTIPATRÓN: Toda la pantalla se reconstruye cada vez que cambia el contador
class PantallaIneficienteState extends State<PantallaIneficiente> {
int _contadorClicks = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Clínica Central')),
body: Column(
children: [
// Banner costoso con imágenes y gradientes (se reconstruye sin necesidad)
const BannerPublicitarioPesado(),
// Gráficos y tablas estadísticas (se recalculan innecesariamente)
const GraficoEstadisticasMensuales(),
// El único elemento que realmente cambia de valor:
Text('Clicks registrados: $_contadorClicks'),
ElevatedButton(
onPressed: () => setState(() => _contadorClicks++),
child: const Text('Sumar'),
),
],
),
);
}
}
4. Aislamiento Quirúrgico del Estado
Para mantener la tasa de fotogramas a 60/120 fps constantes, la mejor práctica arquitectónica consiste en aislar el estado en un widget hijo independiente. De este modo, únicamente el nodo que cambia es marcado como sucio, dejando el resto de la interfaz completamente intacto:
// PATRÓN ÓPTIMO: Extraer el componente interactivo en su propia clase pequeña
class BotonContadorAislado extends StatefulWidget {
const BotonContadorAislado({super.key});
@override
State<BotonContadorAislado> createState() => _BotonContadorAisladoState();
}
class _BotonContadorAisladoState extends State<BotonContadorAislado> {
int _contador = 0;
@override
Widget build(BuildContext context) {
// Al invocar setState aquí, solo se reconstruye este pequeño sub-árbol
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('Clicks: $_contador', style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(width: 12),
FilledButton.tonal(
onPressed: () => setState(() => _contador++),
child: const Text('Incrementar'),
),
],
);
}
}
// Pantalla principal limpia: el método build es un StatelessWidget liviano
class PantallaEficiente extends StatelessWidget {
const PantallaEficiente({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Portal Eficiente')),
body: const Column(
children: [
BannerPublicitarioPesado(), // Widget constante: NUNCA se reconstruye
GraficoEstadisticasMensuales(),
Center(child: BotonContadorAislado()), // Único widget reactivo
],
),
);
}
}
Puntos clave del capítulo
- El estado efímero le pertenece a un solo widget y se maneja con
setState(). - El estado de aplicación trasciende pantallas y requiere herramientas como Provider o Riverpod.
setState()marca el nodo del elemento como dirty para que el motor ejecute su métodobuild()en el próximo frame.- Mantén el callback de
setState()síncrono; resuelve losFuturesfuera de él. - Nunca envuelvas una pantalla entera en un
StatefulWidgetmasivo si solo cambia un botón o texto: extrae los componentes reactivos a widgets aislados para no saturar la GPU.
Estado compartido con Provider
Implementa una arquitectura escalable para compartir datos entre pantallas sin caer en el paso manual de parámetros (prop drilling): domina el patrón Observer con ChangeNotifier, la inyección reactiva con ChangeNotifierProvider y MultiProvider, y la distinción crítica entre context.watch, context.read y Consumer.
- Definir modelos reactivos: encapsular la lógica de negocio con
ChangeNotifierynotifyListeners(). - Inyectar dependencias en el árbol: configurar
ChangeNotifierProvideryMultiProvider. - Consumir estado con precisión: dominar
context.watch()frente acontext.read(). - Reconstrucciones quirúrgicas: utilizar
Consumer<T>optimizando el argumentochild.
1. ¿Qué es Provider y por qué es el estándar inicial de Flutter?
En una aplicación real, múltiples pantallas alejadas entre sí necesitan acceder al mismo estado (por ejemplo,
el carrito de farmacia o el usuario logueado en la clínica). Pasar esas variables manualmente a través de los
constructores de decenas de widgets intermedios (*prop drilling*) ensucia el código y dificulta el mantenimiento.
provider es una envoltura elegante sobre InheritedWidget que permite
inyectar y escuchar objetos desde cualquier lugar del árbol de widgets con un rendimiento impecable.
# Instalación de provider en pubspec.yaml
flutter pub add provider
2. El Modelo de Negocio con ChangeNotifier
El primer paso consiste en crear una clase que extienda de ChangeNotifier.
Cada vez que el estado interno cambia, se invoca notifyListeners() para avisar a todos
los widgets suscritos que deben redibujarse con los nuevos datos:
import 'package:flutter/material.dart';
// Modelo de datos para las consultas del paciente
class ConsultaMedica {
final int id;
final String doctor;
final double costo;
const ConsultaMedica({required this.id, required this.doctor, required this.costo});
}
// Gestor de estado que notifica a la interfaz gráfica
class CarritoClinica with ChangeNotifier {
final List<ConsultaMedica> _items = [];
// Exponemos una lista inmutable para proteger el estado interno
List<ConsultaMedica> get items => List.unmodifiable(_items);
double get total => _items.fold(0.0, (acum, elem) => acum + elem.costo);
void agregarConsulta(ConsultaMedica consulta) {
_items.add(consulta);
// 1. Notifica de forma inmediata a los widgets que están escuchando
notifyListeners();
}
void vaciar() {
_items.clear();
notifyListeners();
}
}
3. Inyección en la Raíz con MultiProvider
Para que los widgets hijos puedan acceder al modelo, envolvemos nuestra aplicación en la raíz utilizando
MultiProvider y ChangeNotifierProvider:
import 'package:provider/provider.dart';
void main() {
runApp(
MultiProvider(
providers: [
// Instancia perezosa (lazy) creada automáticamente la primera vez que se lee
ChangeNotifierProvider(create: (BuildContext context) => CarritoClinica()),
],
child: const AppClinica(),
),
);
}
4. context.watch frente a context.read
Comprender la diferencia exacta entre estas dos extensiones de BuildContext es vital para evitar
bugs y fugas de rendimiento:
| Método | Suscripción al Rebuild | Ubicación Obligatoria | Propósito Principal |
|---|---|---|---|
context.watch<T>() |
Sí. El widget se reconstruye automáticamente cada vez que se llama a notifyListeners(). |
Dentro del método build() del widget. |
Mostrar datos reactivos (textos, listas, totales de precio). |
context.read<T>() |
No. Obtiene la instancia una sola vez sin suscribirse a futuros cambios. | Dentro de callbacks de eventos (onPressed, onTap, etc.). |
Disparar acciones o métodos del modelo sin redibujar el widget que contiene el botón. |
Nunca invoques context.read<T>() dentro de un método build(). Si los datos cambian,
el widget no se enterará y mostrará información desactualizada. Por el contrario, nunca uses context.watch<T>()
dentro de un onPressed, ya que causará excepciones en tiempo de ejecución.
5. Optimización Quirúrgica con Consumer
Cuando solo un pequeño fragmento de una pantalla compleja requiere actualizarse, el widget Consumer<T>
es la solución más limpia. Además, permite usar el parámetro child para inyectar
árboles de widgets pesados que nunca deben reconstruirse:
class PantallaCajaClinica extends StatelessWidget {
const PantallaCajaClinica({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Facturación de Consultas')),
body: Column(
children: [
// 1. Consumer reconstruye ÚNICAMENTE este contenedor cuando cambia el carrito
Consumer<CarritoClinica>(
builder: (context, carrito, child) {
return Card(
margin: const EdgeInsets.all(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Usamos el child estático para el icono y texto fijo
child!,
Text(
'S/ ${carrito.total.toStringAsFixed(2)}',
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.teal),
),
],
),
),
);
},
// Este icono y texto se instancian una sola vez y se reutilizan
child: const Row(
children: [
Icon(Icons.point_of_sale, size: 28),
SizedBox(width: 8),
Text('Total por Pagar:', style: TextStyle(fontSize: 16)),
],
),
),
// 2. Botón que añade una consulta usando context.read (sin suscribirse a rebuilds)
FilledButton.icon(
icon: const Icon(Icons.add),
label: const Text('Añadir Consulta Cardiológica (S/ 120.00)'),
onPressed: () {
context.read<CarritoClinica>().agregarConsulta(
const ConsultaMedica(id: 1, doctor: 'Dr. Morales', costo: 120.00),
);
},
),
],
),
);
}
}
Puntos clave del capítulo
Providerelimina el *prop drilling* proporcionando acceso desacoplado a modelos de estado en el árbol.- Extiende tus modelos con
ChangeNotifiery llama anotifyListeners()tras modificar datos. - Declara tus proveedores en la raíz de la aplicación mediante
MultiProvider. - Usa
context.watch<T>()en elbuild()para suscribir la pantalla a cambios reactivos. - Usa
context.read<T>()en callbacks comoonPressedpara ejecutar métodos sin suscripción. - Implementa
Consumer<T>con la propiedadchildpara aislar reconstrucciones y proteger componentes estáticos costosos.
Ecosistema moderno y asincronía (Future, Stream & Riverpod)
Integra flujos asíncronos y datos en tiempo real de forma reactiva: domina el manejo de estados con FutureBuilder y StreamBuilder erradicando el antipatrón de disparos infinitos, y descubre los fundamentos de Riverpod como la evolución natural para una arquitectura sin dependencias de BuildContext.
- Consumo asíncrono declarativo: manejar estados de carga, error y datos con
FutureBuilder<T>. - Erradicar el bucle de peticiones infinitas: preservar la referencia del Future en
initState(). - Procesar flujos continuos: actualización en tiempo real mediante
StreamBuilder<T>. - Introducción a Riverpod: entender sus ventajas de compilación frente a Provider con
ConsumerWidgetyWidgetRef.
1. Renderizado Asíncrono con FutureBuilder
En aplicaciones móviles, la obtención de datos de una API REST o base de datos es una operación asíncrona.
El widget FutureBuilder<T> se suscribe a un Future y redibuja
automáticamente la interfaz según las distintas fases de la conexión capturadas en el objeto AsyncSnapshot:
class VistaMedicosAsincrona extends StatefulWidget {
const VistaMedicosAsincrona({super.key});
@override
State<VistaMedicosAsincrona> createState() => _VistaMedicosAsincronaState();
}
class _VistaMedicosAsincronaState extends State<VistaMedicosAsincrona> {
// 1. REGLA DE ORO: La variable del Future se inicializa una sola vez en initState
late final Future<List<String>> _medicosFuture;
@override
void initState() {
super.initState();
_medicosFuture = _cargarDoctoresDesdeServidor();
}
Future<List<String>> _cargarDoctoresDesdeServidor() async {
// Simulamos un retardo de red de 2 segundos
await Future.delayed(const Duration(seconds: 2));
return ['Dr. Roberto Morales (Cardiología)', 'Dra. Elena Ramos (Pediatría)', 'Dr. Carlos Vega (Neurología)'];
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Equipo Médico')),
body: FutureBuilder<List<String>>(
future: _medicosFuture,
builder: (BuildContext context, AsyncSnapshot<List<String>> snapshot) {
// Fase 1: En espera de respuesta (cargando)
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
// Fase 2: Ocurrió un error en la comunicación
if (snapshot.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.cloud_off, size: 48, color: Colors.redAccent),
const SizedBox(height: 8),
Text('Error al conectar: ${snapshot.error}'),
],
),
);
}
// Fase 3: Datos obtenidos con éxito
if (snapshot.hasData) {
final medicos = snapshot.data!;
return ListView.builder(
itemCount: medicos.length,
itemBuilder: (context, i) => ListTile(
leading: const Icon(Icons.medical_services),
title: Text(medicos[i]),
),
);
}
return const Center(child: Text('No hay datos disponibles'));
},
),
);
}
}
Nunca escribas FutureBuilder(future: _cargarDoctoresDesdeServidor(), ...) dentro del método build().
Cada vez que el teclado se abra, la pantalla gire o un widget padre se actualice, build() se ejecutará de nuevo,
creando una nueva instancia del Future y saturando tu servidor con peticiones HTTP duplicadas e infinitas.
2. Flujos en Tiempo Real con StreamBuilder
A diferencia de un Future (que resuelve un único valor a futuro), un Stream
emite múltiples eventos a lo largo del tiempo (ej. mensajes de chat, telemetría cardíaca o WebSockets).
El widget StreamBuilder<T> reconstruye la interfaz cada vez que un nuevo dato llega por el flujo:
// Flujo reactivo simulando el pulso cardíaco de un paciente en UCI
Stream<int> monitorCardiacoStream() async* {
int ritmo = 72;
while (true) {
await Future.delayed(const Duration(seconds: 1));
yield ritmo++; // Emite un nuevo valor por segundo
}
}
class VistaMonitorUCI extends StatelessWidget {
const VistaMonitorUCI({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Monitoreo en Tiempo Real')),
body: Center(
child: StreamBuilder<int>(
stream: monitorCardiacoStream(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Text('Calibrando sensores médicos...');
}
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.favorite, color: Colors.redAccent, size: 64),
const SizedBox(height: 12),
Text(
'${snapshot.data} BPM',
style: const TextStyle(fontSize: 42, fontWeight: FontWeight.bold),
),
],
);
},
),
),
);
}
}
3. El Futuro del Estado: Introducción a Riverpod
Aunque Provider es robusto, presenta debilidades estructurales: depende estrictamente del
BuildContext para resolver dependencias y si intentas leer un proveedor que no existe en el sub-árbol,
lanza una excepción en tiempo de ejecución (ProviderNotFoundException).
flutter_riverpod fue creado por el mismo autor de Provider para solucionar esto de raíz:
los proveedores se declaran como variables globales inmutables y los errores se detectan en tiempo de compilación:
# Instalación de Riverpod oficial
flutter pub add flutter_riverpod
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// 1. Declaración global inmutable del proveedor (sin depender de BuildContext)
final nombreClinicaProvider = Provider<String>((ref) => 'Clínica San Juan Internacional');
// 2. Envolver la aplicación en ProviderScope en la raíz
void main() {
runApp(
const ProviderScope(
child: AppConRiverpod(),
),
);
}
class AppConRiverpod extends StatelessWidget {
const AppConRiverpod({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: PantallaRiverpodEjemplo(),
);
}
}
// 3. Heredamos de ConsumerWidget en lugar de StatelessWidget
class PantallaRiverpodEjemplo extends ConsumerWidget {
const PantallaRiverpodEjemplo({super.key});
// Recibe WidgetRef ref como parámetro adicional en el build
@override
Widget build(BuildContext context, WidgetRef ref) {
// Escuchamos el proveedor con ref.watch de forma segura
final nombreClinica = ref.watch(nombreClinicaProvider);
return Scaffold(
appBar: AppBar(title: const Text('Arquitectura Moderna')),
body: Center(
child: Text(
nombreClinica,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600),
),
),
);
}
}
Puntos clave del capítulo
FutureBuilderrenderiza interfaces declarativas basadas en los estados delAsyncSnapshot(cargando, error, datos).- Inicializa siempre el
Futureen el métodoinitState()para evitar peticiones duplicadas e infinitas al reconstruir elbuild(). StreamBuilderescucha flujos de datos continuos y actualiza la vista reactivamente en tiempo real.Riverpoddesacopla el estado delBuildContext, garantizando seguridad de tipos y detección de errores en tiempo de compilación.- En Riverpod, envuelve la app en
ProviderScopey usaConsumerWidgetjunto aref.watch()para suscribir tus vistas al estado.
Consumo de APIs REST y serialización JSON
Conecta tu aplicación con el mundo exterior de forma robusta y tipada: aprende a emitir peticiones HTTP seguras con el paquete http, decodificar respuestas JSON en modelos Dart inmutables mediante constructores factory fromJson, manejar códigos de estado y capturar fallos de conectividad sin colapsar la app.
- Configurar el cliente HTTP oficial: peticiones GET, POST, PUT y DELETE con
package:http/http.dart. - Modelado tipado inmutable: transformar payloads JSON en objetos Dart con
fromJson()ytoJson(). - Manejo de códigos de estado HTTP: discriminar entre éxitos 200, no autorizados 401 y errores de servidor 500.
- Resiliencia de red: capturar
SocketException, desconexiones y timeouts de red.
1. El Paquete Oficial http
Flutter no incluye un cliente HTTP de alto nivel en su SDK principal; en su lugar, el equipo oficial mantiene
el paquete http, una biblioteca liviana basada en componibilidad de peticiones:
# Instalación de la biblioteca HTTP oficial
flutter pub add http
En Android, recuerda verificar que android/app/src/main/AndroidManifest.xml contenga la etiqueta
<uses-permission android:name="android.permission.INTERNET"/>. En macOS Desktop, debes habilitar
el *network client entitlement* en macos/Runner/DebugProfile.entitlements.
2. Modelado de Datos: Patrón fromJson y toJson
En Dart, la mejor práctica consiste en evitar el uso de mapas dinámicos sueltos (Map<String, dynamic>)
en la capa de interfaz. Creamos una clase inmutable que convalide los tipos mediante un constructor de fábrica:
import 'dart:convert';
class MedicoModelo {
final int id;
final String nombre;
final String especialidad;
final String colegiatura;
final bool activo;
const MedicoModelo({
required this.id,
required this.nombre,
required this.especialidad,
required this.colegiatura,
required this.activo,
});
// Constructor de fábrica para instanciar a partir de un Map JSON deserializado
factory MedicoModelo.fromJson(Map<String, dynamic> json) {
return MedicoModelo(
id: json['id'] as int? ?? 0,
nombre: json['nombre'] as String? ?? 'Sin nombre',
especialidad: json['especialidad'] as String? ?? 'General',
colegiatura: json['colegiatura'] as String? ?? 'CMP-00000',
activo: json['activo'] as bool? ?? true,
);
}
// Método para serializar a JSON al emitir peticiones POST o PUT
Map<String, dynamic> toJson() {
return {
'id': id,
'nombre': nombre,
'especialidad': especialidad,
'colegiatura': colegiatura,
'activo': activo,
};
}
}
3. Servicio de Red y Manejo de Respuestas
Encapsulamos la comunicación en una clase de servicio desacoplada, asegurando el tipado y gestionando
las excepciones de red con bloques try-catch específicos:
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
class MedicosApiService {
static const String _baseUrl = 'https://api.clinicasanjuan.pe/v1';
Future<List<MedicoModelo>> obtenerMedicos() async {
final Uri url = Uri.parse('$_baseUrl/medicos');
try {
final response = await http.get(
url,
headers: {
'Accept': 'application/json',
'Authorization': 'Bearer TOKEN_JWT_CLINICA',
},
).timeout(const Duration(seconds: 10)); // Timeout preventivo
if (response.statusCode == 200) {
// Deserializamos el string en una lista de mapas
final List<dynamic> dataJson = jsonDecode(response.body);
return dataJson.map((item) => MedicoModelo.fromJson(item)).toList();
} else if (response.statusCode == 401) {
throw Exception('Sesión expirada. Por favor inicie sesión nuevamente.');
} else {
throw Exception('Error en el servidor: Código ${response.statusCode}');
}
} on SocketException {
// Sin acceso a internet o servidor inaccesible
throw Exception('No hay conexión a internet. Verifique su red Wi-Fi o datos móviles.');
} on TimeoutException {
throw Exception('El servidor tardó demasiado en responder.');
} catch (e) {
throw Exception('Ocurrió un error inesperado: $e');
}
}
// Petición POST para registrar una nueva cita
Future<bool> agendarTurno({required int pacienteId, required int medicoId, required String fecha}) async {
final Uri url = Uri.parse('$_baseUrl/citas');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'paciente_id': pacienteId,
'medico_id': medicoId,
'fecha_hora': fecha,
}),
);
return response.statusCode == 201; // 201 Created
}
}
Puntos clave del capítulo
- El paquete
httpproporciona la suite oficial para llamadas REST en Flutter. - Nunca consumas mapas JSON crudos en la interfaz gráfica; usa siempre modelos de datos inmutables con
fromJson(). - Configura cabeceras explícitas (
'Accept': 'application/json'y'Content-Type': 'application/json'). - Aplica siempre
.timeout()a tus llamadas de red para impedir que la aplicación quede congelada indefinidamente. - Captura de forma diferenciada
SocketExceptionpara mostrar alertas amigables de desconexión sin conexión Wi-Fi.
Persistencia local en el dispositivo
Conserva la configuración, sesiones y estado del usuario entre reinicios del dispositivo móvil: aprende a implementar SharedPreferences de forma asíncrona, encapsular un servicio de preferencias limpio y comprende los límites de seguridad frente al almacenamiento cifrado con Keystore y Keychain.
- Persistir pares clave-valor: almacenar cadenas, booleanos y números con
shared_preferences. - Guardar colecciones y objetos: serializar modelos JSON a strings en el disco local.
- Diseñar un servicio de preferencias: patrón Singleton / Repositorio desacoplado para la capa de UI.
- Criterio de seguridad móvil: diferencias críticas entre
SharedPreferencesyflutter_secure_storage.
1. El Paquete Oficial shared_preferences
Para almacenar pequeñas cantidades de datos que deben sobrevivir al cierre de la aplicación (como el tema oscuro,
el último documento de identidad consultado o el idioma preferido), Flutter ofrece el paquete oficial
shared_preferences, que se conecta con los mecanismos nativos de cada plataforma
(XML en Android, NSUserDefaults en iOS y localStorage en Web):
# Instalación del plugin de persistencia local
flutter pub add shared_preferences
2. Operaciones Fundamentales de Lectura y Escritura
El acceso a disco es asíncrono, por lo que primero obtenemos la instancia del almacenamiento antes de operar:
import 'package:shared_preferences/shared_preferences.dart';
Future<void> guardarAjustesUsuario(bool modoOscuro, String sedeClinica) async {
// 1. Obtenemos la instancia del motor de almacenamiento
final SharedPreferences prefs = await SharedPreferences.getInstance();
// 2. Guardamos tipos primitivos
await prefs.setBool('modo_oscuro_activo', modoOscuro);
await prefs.setString('sede_preferida', sedeClinica);
await prefs.setInt('ultimo_acceso_timestamp', DateTime.now().millisecondsSinceEpoch);
}
Future<Map<String, dynamic>> leerAjustesUsuario() async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
// Lectura síncrona desde el caché en memoria de prefs
final bool modoOscuro = prefs.getBool('modo_oscuro_activo') ?? false; // Valor por defecto
final String sede = prefs.getString('sede_preferida') ?? 'Sede Central San Isidro';
return {
'modoOscuro': modoOscuro,
'sede': sede,
};
}
3. Encapsulamiento en un Servicio de Preferencias
Para mantener la arquitectura limpia, aislamos la persistencia en un servicio dedicado que la interfaz gráfica pueda consultar sin acoplarse directamente al plugin:
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
class PreferenciasClinicaService {
static const String _keyUsuario = 'paciente_sesion';
// Guardar un objeto estructurado serializándolo a String JSON
static Future<void> guardarSesionPaciente(Map<String, dynamic> paciente) async {
final prefs = await SharedPreferences.getInstance();
final String jsonString = jsonEncode(paciente);
await prefs.setString(_keyUsuario, jsonString);
}
// Recuperar el objeto reconstruyéndolo desde JSON
static Future<Map<String, dynamic>?> obtenerSesionPaciente() async {
final prefs = await SharedPreferences.getInstance();
final String? jsonString = prefs.getString(_keyUsuario);
if (jsonString == null) return null;
return jsonDecode(jsonString) as Map<String, dynamic>;
}
// Eliminar datos al cerrar sesión
static Future<void> borrarSesion() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_keyUsuario);
}
}
Los datos guardados con SharedPreferences se almacenan en texto plano en el sistema de archivos
del teléfono. Cualquier usuario con un dispositivo rooteado o mediante copias de seguridad de Android puede leer el archivo XML.
NUNCA almacenes contraseñas, tokens JWT bancarios o historias clínicas privadas en SharedPreferences.
Para credenciales críticas, utiliza flutter_secure_storage, que delega en el Android Keystore
y el iOS Keychain con cifrado de hardware AES-256.
Puntos clave del capítulo
shared_preferenceses la solución oficial para datos ligeros de tipo clave-valor (ajustes, banderas, preferencias).- Almacena tipos primitivos:
String,int,double,boolyList<String>. - Para guardar objetos complejos, serialízalos a cadena de texto con
jsonEncode()y reconstrúyelos conjsonDecode(). - Abstrae siempre el acceso a través de un servicio o repositorio para no dispersar llamadas en tus widgets.
- No utilices SharedPreferences para información sensible; reserva
flutter_secure_storagepara tokens y claves criptográficas.
Proyecto integrador: App móvil completa
Integra todas las capas arquitectónicas aprendidas en una aplicación móvil real: estructura Material 3 con Scaffold, filtrado interactivo por chips, gestión de estado reactiva con Provider, listas recicladas de alto rendimiento con ListView.builder y diálogos modales para el agendamiento de turnos médicos.
- Arquitectura integral en capas: separar modelos de dominio, lógica de estado y componentes de presentación.
- Gestión de estado global: coordinar listados, filtros y cancelaciones con
ChangeNotifieryProvider. - UI Material 3 profesional: filtros dinámicos con
FilterChip, tarjetas limpias y paleta tonal accesible. - Interacción completa: diálogos de creación de citas, confirmaciones de borrado y notificaciones flotantes con
SnackBar.
1. Estructura y Capas del Proyecto
En este proyecto construiremos la aplicación móvil Clínica Móvil San Juan, que permite al personal médico y a los pacientes consultar las citas del día, filtrarlas por especialidad, registrar nuevas atenciones y cancelar turnos con confirmación en pantalla.
2. Modelo de Dominio y Gestor de Estado (Provider)
Definimos la entidad inmutable Cita y la clase GestorCitas que notifica a la interfaz:
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
// 1. Modelo inmutable de la cita médica
class Cita {
final String id;
final String paciente;
final String medico;
final String especialidad;
final String hora;
final double costo;
const Cita({
required this.id,
required this.paciente,
required this.medico,
required this.especialidad,
required this.hora,
required this.costo,
});
}
// 2. Gestor de estado que administra las citas y el filtro activo
class GestorCitas extends ChangeNotifier {
final List<Cita> _citas = [
const Cita(id: '1', paciente: 'Juan Pérez', medico: 'Dr. Roberto Morales', especialidad: 'Cardiología', hora: '09:00 AM', costo: 150.0),
const Cita(id: '2', paciente: 'María Gómez', medico: 'Dra. Elena Ramos', especialidad: 'Pediatría', hora: '10:30 AM', costo: 120.0),
const Cita(id: '3', paciente: 'Carlos Silva', medico: 'Dr. Roberto Morales', especialidad: 'Cardiología', hora: '11:45 AM', costo: 150.0),
const Cita(id: '4', paciente: 'Rosa Mendoza', medico: 'Dr. Carlos Vega', especialidad: 'Neurología', hora: '02:15 PM', costo: 180.0),
];
String _especialidadFiltro = 'Todas';
String get especialidadFiltro => _especialidadFiltro;
List<Cita> get citasFiltradas {
if (_especialidadFiltro == 'Todas') return List.unmodifiable(_citas);
return _citas.where((c) => c.especialidad == _especialidadFiltro).toList();
}
void cambiarFiltro(String nuevaEspecialidad) {
_especialidadFiltro = nuevaEspecialidad;
notifyListeners();
}
void agregarCita(Cita nuevaCita) {
_citas.insert(0, nuevaCita);
notifyListeners();
}
void cancelarCita(String id) {
_citas.removeWhere((c) => c.id == id);
notifyListeners();
}
}
3. Interfaz de Usuario Reactiva con Material 3
Implementamos la pantalla principal con barra superior, chips de filtrado horizontal, lista eficiente y botón flotante extendido para registrar nuevas citas:
void main() {
runApp(
ChangeNotifierProvider(
create: (_) => GestorCitas(),
child: const AppClinicaSanJuan(),
),
);
}
class AppClinicaSanJuan extends StatelessWidget {
const AppClinicaSanJuan({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Clínica San Juan',
theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.teal),
home: const PantallaPrincipalClinica(),
);
}
}
class PantallaPrincipalClinica extends StatelessWidget {
const PantallaPrincipalClinica({super.key});
@override
Widget build(BuildContext context) {
// Escuchamos el gestor de citas reactivamente
final gestor = context.watch<GestorCitas>();
final especialidades = ['Todas', 'Cardiología', 'Pediatría', 'Neurología'];
return Scaffold(
appBar: AppBar(
scrolledUnderElevation: 3.0,
title: const Text('Agenda Médica del Día', style: TextStyle(fontWeight: FontWeight.bold)),
actions: [
IconButton(
icon: const Icon(Icons.info_outline),
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Total de citas visibles: ${gestor.citasFiltradas.length}'),
behavior: SnackBarBehavior.floating,
),
);
},
),
],
),
body: Column(
children: [
// 1. Selector de filtros con FilterChip horizontal
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: especialidades.map((esp) {
final bool seleccionada = gestor.especialidadFiltro == esp;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: FilterChip(
selected: seleccionada,
label: Text(esp),
onSelected: (_) => gestor.cambiarFiltro(esp),
),
);
}).toList(),
),
),
// 2. Lista optimizada con ListView.builder
Expanded(
child: gestor.citasFiltradas.isEmpty
? const Center(child: Text('No hay citas registradas para este filtro.'))
: ListView.builder(
itemCount: gestor.citasFiltradas.length,
itemBuilder: (context, index) {
final cita = gestor.citasFiltradas[index];
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
elevation: 1,
child: ListTile(
leading: CircleAvatar(
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
child: const Icon(Icons.person, color: Colors.teal),
),
title: Text(cita.paciente, style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: Text('${cita.especialidad} · ${cita.medico}\nHora: ${cita.hora}'),
trailing: IconButton(
icon: const Icon(Icons.cancel_outlined, color: Colors.redAccent),
tooltip: 'Cancelar Cita',
onPressed: () async {
// Confirmación modal antes de cancelar
final bool? confirmar = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('¿Cancelar turno?'),
content: Text('¿Desea liberar el turno de las ${cita.hora} de ${cita.paciente}?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('No')),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('Sí, liberar')),
],
),
);
if (confirmar == true && context.mounted) {
gestor.cancelarCita(cita.id);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Cita liberada exitosamente.')),
);
}
},
),
),
);
},
),
),
],
),
floatingActionButton: FloatingActionButton.extended(
icon: const Icon(Icons.add),
label: const Text('Nuevo Turno'),
onPressed: () => _mostrarModalNuevaCita(context),
),
);
}
void _mostrarModalNuevaCita(BuildContext context) {
final pacienteCtrl = TextEditingController();
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Registrar Nuevo Turno'),
content: TextField(
controller: pacienteCtrl,
decoration: const InputDecoration(labelText: 'Nombre completo del paciente'),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancelar')),
FilledButton(
onPressed: () {
if (pacienteCtrl.text.trim().isNotEmpty) {
context.read<GestorCitas>().agregarCita(
Cita(
id: DateTime.now().millisecondsSinceEpoch.toString(),
paciente: pacienteCtrl.text.trim(),
medico: 'Dr. Roberto Morales',
especialidad: 'Cardiología',
hora: '04:00 PM',
costo: 150.0,
),
);
Navigator.pop(ctx);
}
},
child: const Text('Guardar'),
),
],
),
);
}
}
Puntos clave del capítulo
- La arquitectura limpia separa el modelo de datos (
Cita), la lógica de negocio (GestorCitas) y la presentación visual. FilterChipproporciona una experiencia táctil intuitiva y reactiva para el filtrado de datos.ListView.builderasegura que solo se rendericen los ítems visibles en pantalla, garantizando 60/120 fps constantes.- Los cuadros de diálogo tipados
showDialog<bool>protegen contra eliminaciones accidentales de información. - La combinación de
context.watch()en la vista ycontext.read()en los eventos ofrece un flujo de datos unidireccional impecable.
Testing, profiling, compilación y graduación
Lleva tu aplicación a los estándares más exigentes de la industria: automatiza pruebas unitarias y de widgets con flutter_test, detecta fugas de memoria y caídas de frames con Flutter DevTools, genera paquetes optimizados y ofuscados para Google Play y App Store, y consolida tus conocimientos en el catálogo general.
- Estrategia de testing automatizado: pruebas unitarias de modelos y pruebas de interfaz con
WidgetTester. - Diagnóstico con Flutter DevTools: inspección de árboles, métricas de GPU/Raster y control de fugas de memoria.
- Compilación para producción: generar App Bundles (AAB) para Android y paquetes IPA para iOS con ofuscación de código.
- Tabla de equivalencias móviles: comparativa entre Flutter, Jetpack Compose, SwiftUI y React Native.
1. La Pirámide de Pruebas en Flutter
Flutter incluye de fábrica una de las suites de testing más completas del desarrollo móvil moderno. Las pruebas se dividen en tres niveles de granularidad:
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
// 1. Prueba Unitaria: Valida la lógica pura sin renderizar UI
void main() {
group('Pruebas del Modelo Cita', () {
test('El cálculo de costo debe ser exacto', () {
const costoConsulta = 150.0;
expect(costoConsulta > 0, true);
});
});
// 2. Prueba de Widgets: Simula interacción en un entorno virtual sin emulador
testWidgets('El botón de nuevo turno debe mostrar el diálogo modal', (WidgetTester tester) async {
// Montamos un widget envuelto en MaterialApp para simular el entorno real
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: FilledButton(
onPressed: () {},
child: const Text('Nuevo Turno'),
),
),
),
),
);
// Verificamos que el texto esté presente en la pantalla
expect(find.text('Nuevo Turno'), findsOneWidget);
// Simulamos un toque táctil en el botón
await tester.tap(find.byType(FilledButton));
// pumpAndSettle espera a que todas las micro-animaciones finalicen
await tester.pumpAndSettle();
});
}
2. Auditoría y Profiling con Flutter DevTools
Durante el desarrollo, ejecuta flutter run y presiona v en la terminal para abrir
Flutter DevTools en el navegador. Esta suite incluye herramientas profesionales indispensables:
| Herramienta | Diagnóstico | Acción Correctiva |
|---|---|---|
| Flutter Inspector | Explora la jerarquía visual de Widgets y RenderObjects; detecta desbordamientos y márgenes erróneos. | Activa «Debug Paint» para visualizar cajas de colisión y márgenes espaciales en vivo. |
| Performance View | Monitorea fotogramas a 60/120 Hz. Identifica barras rojas (*jank*) causadas por bloqueos en el hilo de UI o Raster. | Reemplaza widgets dinámicos por constructores const y aísla setState() en nodos pequeños. |
| Memory Profiler | Toma capturas de montículo (*heap snapshots*) para identificar objetos que no se liberan en el Garbage Collector. | Comprueba que todos los TextEditingController y AnimationController invoquen su método dispose(). |
| Network Profiler | Monitorea cada petición HTTP saliente, cabeceras, tiempos de latencia y tamaños de payload JSON. | Optimiza transferencias de red y verifica que las respuestas del backend cuenten con compresión GZIP o Brotli. |
3. Compilación y Ofuscación para Producción
Nunca distribuyas una aplicación compilada en modo Debug (que incluye el compilador JIT y herramientas de desarrollo). Para publicar en las tiendas oficiales, utiliza los comandos de producción optimizados AOT:
# 1. Android: Generar Android App Bundle (AAB) para Google Play con ofuscación
flutter build appbundle --release --obfuscate --split-debug-info=build/app/outputs/symbols
# 2. Android: Generar APKs individuales divididos por arquitectura (reduce el peso un 60%)
flutter build apk --split-per-abi --release
# 3. iOS: Generar el paquete de distribución para TestFlight y App Store
flutter build ipa --release
# 4. Web: Compilar aplicación web optimizada para producción
flutter build web --release
4. Tabla Maestra de Equivalencias Móviles
Si provienes de otros entornos de desarrollo o colaboras con equipos multiplataforma, esta matriz te servirá de puente conceptual inmediato:
| Concepto / Función | Flutter (Dart) | Jetpack Compose (Kotlin) | SwiftUI (Swift) | React Native (TS/JS) |
|---|---|---|---|---|
| Componente visual base | Widget |
@Composable fun |
View |
Component |
| Estructura de pantalla | Scaffold |
Scaffold |
NavigationStack |
SafeAreaView |
| Flujo vertical | Column |
Column |
VStack |
View (flexDirection: column) |
| Flujo horizontal | Row |
Row |
HStack |
View (flexDirection: row) |
| Lista reciclada virtual | ListView.builder |
LazyColumn |
List / LazyVStack |
FlatList |
| Superposición en capas | Stack / Positioned |
Box |
ZStack |
View (position: absolute) |
| Estado local efímero | setState() |
remember { mutableStateOf() } |
@State |
useState() |
| Inyección y estado global | Provider / Riverpod |
CompositionLocal / ViewModel |
@EnvironmentObject |
Context API / Zustand |
Has dominado Flutter desde sus cimientos arquitectónicos con Impeller, su atlas visual de 22 widgets, maquetación Material 3, interacción, formularios con validaciones, navegación declarativa con GoRouter, gestión de estado con Provider y Riverpod, consumo de APIs REST, persistencia local y testing para tiendas de aplicaciones. ¡Estás listo para crear apps móviles profesionales!
Puntos clave del capítulo
- Combina pruebas unitarias para modelos con
testWidgetspara garantizar que los componentes de interfaz respondan como se espera. - Usa
pumpAndSettle()en tus pruebas para aguardar la culminación de todas las animaciones antes de verificar aserciones. - Inspecciona fotogramas en Flutter DevTools para diagnosticar *jank* y asegurar 60 o 120 fotogramas estables.
- Libera siempre recursos en
dispose()para evitar fugas de memoria en el Memory Profiler. - Genera paquetes Android App Bundle (AAB) con banderas
--obfuscatey--split-debug-infopara proteger tu código fuente en producción.