Home / Frontend / Cómo implementar cualquier interfaz en Flutter paso a paso
Cómo implementar cualquier interfaz en Flutter paso a paso

Cómo implementar cualquier interfaz en Flutter paso a paso

19 October 2026 · 18 min de lectura ··

Del diseño al código sin quedarse en blanco

Recibir un diseño en Figma, una captura de una aplicación de referencia o un boceto en papel y tener que convertirlo en una pantalla funcional es una de las tareas más habituales del desarrollo móvil. También es una de las que más bloquean a quien empieza con Flutter. La documentación describe cientos de widgets, pero rara vez explica por dónde empezar cuando tienes delante una interfaz completa con imágenes superpuestas, listas desplazables, tarjetas con sombras y botones con efectos.

La buena noticia es que existe un método sistemático que funciona con prácticamente cualquier interfaz. No depende de memorizar el catálogo de widgets, sino de aprender a mirar un diseño de una forma concreta: descomponerlo en piezas, decidir cómo se agrupan, extraer las partes repetidas y, solo al final, ajustar los detalles visuales.

En este artículo vamos a recorrer ese método paso a paso. Repasaremos los fundamentos imprescindibles de Flutter, como los widgets y el árbol de widgets, y después aplicaremos el proceso sobre ejemplos reales: una tarjeta de producto, una cabecera con elementos superpuestos, una pantalla con scroll avanzado y un componente dibujado a mano. Terminaremos con los errores de layout más frecuentes y cómo resolverlos.

Los ejemplos están escritos para Flutter 3.47, la versión estable más reciente, con Material 3 como sistema de diseño predeterminado. La mayoría de los conceptos son válidos para cualquier versión reciente del framework.

Flutter en pocas palabras: todo es un widget

Flutter es el framework de código abierto de Google para crear aplicaciones multiplataforma con una sola base de código. Con el mismo proyecto puedes generar aplicaciones para Android, iOS, web, Windows, macOS y Linux. Utiliza Dart como único lenguaje, tanto para la lógica como para la interfaz, sin archivos de marcado ni hojas de estilo separadas.

La idea central del framework es que todo es un widget. Un texto es un widget, un botón es un widget, el relleno alrededor de un elemento es un widget e incluso la alineación y el tema de la aplicación son widgets. Un widget es una clase de Dart que describe una parte de la interfaz, y existen dos tipos principales.

Los widgets sin estado extienden StatelessWidget. Reciben datos a través de su constructor y siempre dibujan lo mismo para los mismos datos:

class EtiquetaPrecio extends StatelessWidget {
  const EtiquetaPrecio({super.key, required this.importe});

  final double importe;

  @override
  Widget build(BuildContext context) {
    return Text(
      '${importe.toStringAsFixed(2)} €',
      style: Theme.of(context).textTheme.titleMedium,
    );
  }
}

Los widgets con estado extienden StatefulWidget y mantienen datos que pueden cambiar durante la vida del widget. Al llamar a setState, Flutter vuelve a ejecutar el método build y actualiza la pantalla:

class BotonFavorito extends StatefulWidget {
  const BotonFavorito({super.key});

  @override
  State<BotonFavorito> createState() => _BotonFavoritoState();
}

class _BotonFavoritoState extends State<BotonFavorito> {
  bool _marcado = false;

  @override
  Widget build(BuildContext context) {
    return IconButton(
      icon: Icon(_marcado ? Icons.favorite : Icons.favorite_border),
      color: _marcado ? Colors.red : null,
      onPressed: () => setState(() => _marcado = !_marcado),
    );
  }
}

Para implementar interfaces, la mayor parte del trabajo se hace con widgets sin estado. Los widgets con estado se reservan para las partes que reaccionan a la interacción o a datos que cambian.

El árbol de widgets

Como cada widget puede contener a otros, una pantalla de Flutter se representa como un árbol. Cada widget tiene un padre y puede tener uno o varios hijos. Los widgets que admiten un único descendiente lo reciben en la propiedad child, y los que admiten varios, en la propiedad children.

Entender el árbol de widgets en Flutter es lo que permite pasar de una imagen a código. Veamos la estructura de una pantalla sencilla con una barra superior, un contenido centrado y un botón flotante:

Scaffold(
  appBar: AppBar(title: const Text('Mis pedidos')),
  body: const Center(
    child: Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        Text('No tienes pedidos activos'),
        Text('Explora el catálogo para empezar'),
      ],
    ),
  ),
  floatingActionButton: FloatingActionButton(
    onPressed: () {},
    child: const Icon(Icons.add),
  ),
)

Ese código corresponde al siguiente árbol:

Scaffold
├── AppBar
│   └── Text "Mis pedidos"
├── Center
│   └── Column
│       ├── Text "No tienes pedidos activos"
│       └── Text "Explora el catálogo para empezar"
└── FloatingActionButton
    └── Icon

Dibujar este árbol, aunque sea de forma aproximada en un papel, es el primer gesto útil antes de escribir cualquier línea. Te obliga a identificar qué está dentro de qué y qué elementos comparten contenedor.

El método en cinco pasos

El proceso que vamos a aplicar a lo largo del artículo se resume en cinco pasos. Conviene memorizarlo porque sirve igual para una pantalla de inicio de sesión que para un panel de estadísticas:

  • Recorrer el diseño en un orden fijo, de arriba abajo y de izquierda a derecha.
  • Asignar a cada elemento visual el widget que mejor lo describe.
  • Agrupar los widgets en contenedores de layout según su disposición.
  • Extraer en widgets propios los bloques grandes o repetidos.
  • Ajustar estilos, espaciados y comportamientos al final.

El orden de recorrido evita olvidar elementos y produce un código que se lee en el mismo sentido que la interfaz. Si la aplicación está en un idioma que se escribe de derecha a izquierda, como el árabe o el hebreo, el recorrido horizontal se invierte, aunque Flutter gestiona gran parte de esa inversión automáticamente a través de la dirección del texto.

Para elegir el widget de cada elemento, la regla práctica es buscar el que tiene el nombre más parecido a lo que ves. Si es un texto, Text. Si es una imagen, Image. Si es un icono, Icon. Si es un botón con relleno, FilledButton o ElevatedButton. Si es una barra de navegación inferior, NavigationBar. Si no lo tienes claro, buscar en la documentación o en el catálogo de widgets el nombre del elemento suele dar con la respuesta en segundos.

Agrupar con Row, Column y Stack

Una vez identificados los elementos, hay que decidir cómo se colocan unos respecto a otros. Casi cualquier interfaz se construye combinando tres widgets de layout.

Column coloca sus hijos uno debajo de otro. Row los coloca uno al lado del otro. Stack los superpone, uno encima de otro, como capas. Elegir bien entre los tres resuelve la mayor parte del trabajo de maquetación.

Tanto Row como Column tienen un eje principal, en el que se colocan los hijos, y un eje transversal, perpendicular al anterior. La alineación en cada eje se controla con mainAxisAlignment y crossAxisAlignment, y el espacio entre hijos se puede definir directamente con la propiedad spacing, disponible en las versiones actuales del framework, sin necesidad de intercalar SizedBox:

Row(
  mainAxisAlignment: MainAxisAlignment.spaceBetween,
  crossAxisAlignment: CrossAxisAlignment.center,
  spacing: 12,
  children: [
    const CircleAvatar(radius: 24, child: Text('AR')),
    const Expanded(
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        spacing: 4,
        children: [
          Text('Andrea Ruiz', style: TextStyle(fontWeight: FontWeight.w600)),
          Text('Diseñadora de producto'),
        ],
      ),
    ),
    IconButton(icon: const Icon(Icons.more_vert), onPressed: () {}),
  ],
)

El widget Expanded merece una mención especial. Dentro de una fila o columna, hace que su hijo ocupe todo el espacio sobrante en el eje principal. En el ejemplo, la columna con el nombre se estira para que el botón de opciones quede siempre pegado a la derecha. Su pariente Flexible permite que el hijo ocupe como máximo ese espacio, pero sin obligarlo a llenarlo, y ambos aceptan un factor flex para repartir el espacio de forma proporcional.

Para elementos superpuestos, Stack se combina con Positioned, que coloca un hijo a una distancia concreta de los bordes, o con Align, que lo alinea en una posición relativa:

Stack(
  clipBehavior: Clip.none,
  children: [
    ClipRRect(
      borderRadius: BorderRadius.circular(16),
      child: Image.asset('assets/zapatillas.jpg', height: 180, fit: BoxFit.cover),
    ),
    Positioned(
      top: 12,
      left: 12,
      child: Chip(label: const Text('-20 %')),
    ),
    Positioned(
      bottom: -20,
      right: 16,
      child: FloatingActionButton.small(
        onPressed: () {},
        child: const Icon(Icons.add_shopping_cart),
      ),
    ),
  ],
)

Por defecto, Stack recorta los hijos que sobresalen de sus límites. La propiedad clipBehavior: Clip.none desactiva ese recorte, lo que permite efectos como el del botón que se sale ligeramente de la imagen.

Caso práctico: una tarjeta de producto

Apliquemos el método completo sobre un elemento muy habitual: una tarjeta de producto con imagen, nombre, valoración, precio y botón de compra.

Recorriendo de arriba abajo identificamos una imagen con una etiqueta de descuento superpuesta, un nombre, una fila con estrellas y número de opiniones, y una fila final con el precio a la izquierda y un botón a la derecha. Eso nos da directamente la estructura: una columna principal, un Stack para la imagen y dos filas.

class TarjetaProducto extends StatelessWidget {
  const TarjetaProducto({
    super.key,
    required this.nombre,
    required this.imagen,
    required this.precio,
    required this.valoracion,
    required this.opiniones,
    this.descuento,
    this.onComprar,
  });

  final String nombre;
  final String imagen;
  final double precio;
  final double valoracion;
  final int opiniones;
  final int? descuento;
  final VoidCallback? onComprar;

  @override
  Widget build(BuildContext context) {
    final tema = Theme.of(context);

    return Card(
      clipBehavior: Clip.antiAlias,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          Stack(
            children: [
              AspectRatio(
                aspectRatio: 4 / 3,
                child: Image.network(imagen, fit: BoxFit.cover),
              ),
              if (descuento != null)
                Positioned(
                  top: 8,
                  left: 8,
                  child: Container(
                    padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
                    decoration: BoxDecoration(
                      color: tema.colorScheme.error,
                      borderRadius: BorderRadius.circular(8),
                    ),
                    child: Text(
                      '-$descuento %',
                      style: TextStyle(color: tema.colorScheme.onError),
                    ),
                  ),
                ),
            ],
          ),
          Padding(
            padding: const EdgeInsets.all(12),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              spacing: 8,
              children: [
                Text(nombre, style: tema.textTheme.titleMedium, maxLines: 2,
                    overflow: TextOverflow.ellipsis),
                Row(
                  spacing: 4,
                  children: [
                    const Icon(Icons.star, size: 16, color: Colors.amber),
                    Text(valoracion.toStringAsFixed(1)),
                    Text('($opiniones)', style: tema.textTheme.bodySmall),
                  ],
                ),
                Row(
                  children: [
                    Expanded(
                      child: Text('${precio.toStringAsFixed(2)} €',
                          style: tema.textTheme.titleLarge),
                    ),
                    FilledButton(onPressed: onComprar, child: const Text('Comprar')),
                  ],
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

Observa cómo los colores salen del colorScheme del tema en lugar de estar escritos a mano. Así la tarjeta se adapta automáticamente al modo oscuro y a cualquier cambio de paleta de la aplicación.

Extraer widgets propios para mantener el código legible

El paso que más diferencia un código mantenible de uno caótico es la extracción de componentes. A medida que una pantalla crece, el método build se llena de niveles de sangría que hacen difícil saber dónde termina cada bloque. Esa profundidad es la señal de que conviene crear widgets personalizados en Flutter con nombre propio.

Los criterios para extraer un widget son sencillos:

  • El bloque se repite en varios lugares de la aplicación.
  • El bloque tiene un significado propio que puedes nombrar, como “cabecera de perfil” o “fila de valoración”.
  • La sangría del método build supera cinco o seis niveles.
  • Una parte de la pantalla necesita estado propio que no afecta al resto.

En la tarjeta anterior, la fila de valoración es un buen candidato:

class FilaValoracion extends StatelessWidget {
  const FilaValoracion({super.key, required this.valor, required this.total});

  final double valor;
  final int total;

  @override
  Widget build(BuildContext context) {
    return Row(
      spacing: 4,
      children: [
        const Icon(Icons.star, size: 16, color: Colors.amber),
        Text(valor.toStringAsFixed(1)),
        Text('($total)', style: Theme.of(context).textTheme.bodySmall),
      ],
    );
  }
}

Hay una tentación habitual que conviene evitar: extraer partes de la interfaz como métodos que devuelven widgets, por ejemplo Widget _construirValoracion(). Funciona, pero es peor que crear una clase. Las clases pueden declararse con constructores const, lo que permite a Flutter reutilizarlas sin reconstruirlas; tienen su propio BuildContext; y el framework puede optimizar su reconstrucción de forma independiente.

Una estructura de carpetas ordenada ayuda a encontrar los componentes cuando el proyecto crece:

lib/
├── main.dart
├── app/
│   ├── app.dart
│   └── tema.dart
├── features/
│   └── catalogo/
│       ├── pantalla_catalogo.dart
│       └── widgets/
│           ├── tarjeta_producto.dart
│           ├── fila_valoracion.dart
│           └── cabecera_catalogo.dart
└── shared/
    └── widgets/
        └── boton_principal.dart

Personalizar el aspecto con Container y BoxDecoration

Con la estructura terminada, llega el momento de los detalles visuales. El widget más versátil para ello es Container, que combina en un solo lugar tamaño, márgenes, relleno, alineación, transformaciones y decoración.

La decoración se define con BoxDecoration, que admite color de fondo, bordes, esquinas redondeadas, sombras, degradados e imágenes de fondo:

Container(
  margin: const EdgeInsets.symmetric(horizontal: 16),
  padding: const EdgeInsets.all(20),
  decoration: BoxDecoration(
    gradient: const LinearGradient(
      colors: [Color(0xFF4F46E5), Color(0xFF7C3AED)],
      begin: Alignment.topLeft,
      end: Alignment.bottomRight,
    ),
    borderRadius: BorderRadius.circular(20),
    boxShadow: [
      BoxShadow(
        color: Colors.black.withValues(alpha: 0.15),
        blurRadius: 16,
        offset: const Offset(0, 8),
      ),
    ],
  ),
  child: const Text(
    'Saldo disponible\n1.245,80 €',
    style: TextStyle(color: Colors.white, fontSize: 22, height: 1.4),
  ),
)

Un detalle de actualización importante: el método withOpacity de la clase Color está obsoleto en las versiones actuales de Flutter. La forma recomendada de ajustar la transparencia es withValues(alpha: ...), como en el ejemplo, que además trabaja con mayor precisión de color.

Container es cómodo, pero no siempre es la mejor opción. Si solo necesitas relleno, Padding es más ligero y expresa mejor la intención. Si solo necesitas un tamaño fijo o un espacio vacío, usa SizedBox. Si solo necesitas una decoración, DecoratedBox. Reservar Container para cuando combinas varias de estas funciones hace el código más claro.

Para aplicar estilos de forma coherente en toda la aplicación, lo correcto es definirlos en el tema y no en cada widget:

MaterialApp(
  theme: ThemeData(
    colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF4F46E5)),
    cardTheme: const CardThemeData(margin: EdgeInsets.all(8)),
  ),
  darkTheme: ThemeData(
    colorScheme: ColorScheme.fromSeed(
      seedColor: const Color(0xFF4F46E5),
      brightness: Brightness.dark,
    ),
  ),
  home: const PantallaCatalogo(),
)

Con ColorScheme.fromSeed, Material 3 genera una paleta completa y accesible a partir de un único color, tanto para el modo claro como para el oscuro.

Interacción: GestureDetector e InkWell

Una interfaz estática rara vez es suficiente. Para que cualquier elemento responda a toques, pulsaciones largas o deslizamientos se utilizan dos widgets principales.

GestureDetector detecta gestos sin añadir ningún efecto visual. Admite decenas de callbacks, desde onTap hasta onHorizontalDragUpdate o onScaleUpdate:

GestureDetector(
  behavior: HitTestBehavior.translucent,
  onTap: () => Navigator.of(context).pushNamed('/perfil'),
  onLongPress: () => mostrarOpciones(context),
  child: const Column(
    children: [
      Text('Andrea Ruiz'),
      SizedBox(height: 24),
      Text('Ver perfil completo'),
    ],
  ),
)

La propiedad behavior resuelve un problema muy común. Por defecto, GestureDetector solo responde cuando el toque cae sobre un hijo que pinta algo en pantalla, así que el espacio vacío del SizedBox del ejemplo no reaccionaría. Con HitTestBehavior.translucent u HitTestBehavior.opaque, todo el área del detector se vuelve sensible al toque.

InkWell ofrece casi los mismos callbacks, pero añade el efecto de onda característico de Material Design. Necesita un ancestro Material para dibujar ese efecto; si no lo encuentra, la onda no se verá o quedará oculta detrás de un fondo opaco:

Material(
  color: Theme.of(context).colorScheme.surfaceContainerHighest,
  borderRadius: BorderRadius.circular(12),
  child: InkWell(
    borderRadius: BorderRadius.circular(12),
    onTap: () {},
    child: const Padding(
      padding: EdgeInsets.all(16),
      child: Row(
        spacing: 12,
        children: [Icon(Icons.local_shipping), Text('Seguir mi envío')],
      ),
    ),
  ),
)

La regla práctica es usar InkWell para elementos que el usuario debe percibir como pulsables, como filas de listas o tarjetas, y GestureDetector para gestos más complejos o para elementos sin feedback visual, como un área de dibujo.

Interfaces con scroll: de ListView a CustomScrollView

Cuando el contenido no cabe en la pantalla, hay que hacerlo desplazable. Flutter ofrece varias opciones según la complejidad.

SingleChildScrollView hace desplazable un único hijo, normalmente una columna. Es adecuado para formularios o pantallas con contenido fijo y moderado, pero construye todos sus elementos de una vez, por lo que no conviene usarlo con listas largas.

ListView es la opción para listas de elementos similares. Su constructor builder crea los elementos de forma perezosa, solo cuando van a aparecer en pantalla, lo que permite manejar miles de elementos sin problemas de memoria. El constructor separated añade un separador entre cada par:

ListView.separated(
  padding: const EdgeInsets.all(16),
  itemCount: pedidos.length,
  separatorBuilder: (_, __) => const Divider(height: 1),
  itemBuilder: (context, index) {
    final pedido = pedidos[index];
    return ListTile(
      leading: const Icon(Icons.receipt_long),
      title: Text('Pedido ${pedido.codigo}'),
      subtitle: Text(pedido.estado),
      trailing: Text('${pedido.total.toStringAsFixed(2)} €'),
    );
  },
)

GridView aplica la misma idea a una cuadrícula, ideal para catálogos de productos o galerías de imágenes.

Las interfaces más elaboradas combinan una cabecera que se contrae al desplazar, una cuadrícula y una lista en la misma pantalla. Para ellas está CustomScrollView, que trabaja con slivers. Un sliver es una porción de un área desplazable que sabe cómo comportarse durante el scroll. Dominar CustomScrollView y los slivers permite reproducir casi cualquier pantalla de las aplicaciones más conocidas:

CustomScrollView(
  slivers: [
    SliverAppBar.large(
      title: const Text('Catálogo'),
      pinned: true,
    ),
    SliverPadding(
      padding: const EdgeInsets.all(12),
      sliver: SliverGrid.builder(
        gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
          maxCrossAxisExtent: 220,
          mainAxisSpacing: 12,
          crossAxisSpacing: 12,
          childAspectRatio: 0.62,
        ),
        itemCount: productos.length,
        itemBuilder: (context, i) => TarjetaProducto(
          nombre: productos[i].nombre,
          imagen: productos[i].imagen,
          precio: productos[i].precio,
          valoracion: productos[i].valoracion,
          opiniones: productos[i].opiniones,
        ),
      ),
    ),
    SliverList.builder(
      itemCount: recomendados.length,
      itemBuilder: (context, i) => ListTile(title: Text(recomendados[i])),
    ),
    const SliverFillRemaining(
      hasScrollBody: false,
      child: Center(child: Text('Has llegado al final')),
    ),
  ],
)

El delegado SliverGridDelegateWithMaxCrossAxisExtent merece atención: en lugar de fijar el número de columnas, define el ancho máximo de cada celda, de modo que la cuadrícula muestra dos columnas en un teléfono y cinco o seis en una tableta sin escribir ningún código adicional.

Otros slivers útiles son SliverPersistentHeader, para cabeceras personalizadas que se fijan al desplazar, y SliverToBoxAdapter, que permite incluir cualquier widget normal dentro de un CustomScrollView. Para paneles que el usuario arrastra desde la parte inferior existe DraggableScrollableSheet.

CustomPaint para lo que no existe

De vez en cuando, un diseño incluye un elemento que ningún widget reproduce: una gráfica con formas peculiares, un indicador de progreso circular segmentado o una ola decorativa en la cabecera. Para esos casos, CustomPaint permite dibujar directamente sobre un lienzo.

Se define una clase que extiende CustomPainter y se implementan dos métodos: paint, que dibuja, y shouldRepaint, que indica cuándo hay que volver a dibujar:

class IndicadorProgreso extends CustomPainter {
  IndicadorProgreso({required this.progreso, required this.color});

  final double progreso; // entre 0 y 1
  final Color color;

  @override
  void paint(Canvas canvas, Size size) {
    final centro = size.center(Offset.zero);
    final radio = size.shortestSide / 2 - 8;

    final fondo = Paint()
      ..color = color.withValues(alpha: 0.15)
      ..style = PaintingStyle.stroke
      ..strokeWidth = 12;

    final arco = Paint()
      ..color = color
      ..style = PaintingStyle.stroke
      ..strokeWidth = 12
      ..strokeCap = StrokeCap.round;

    canvas.drawCircle(centro, radio, fondo);
    canvas.drawArc(
      Rect.fromCircle(center: centro, radius: radio),
      -math.pi / 2,
      2 * math.pi * progreso,
      false,
      arco,
    );
  }

  @override
  bool shouldRepaint(IndicadorProgreso old) =>
      old.progreso != progreso || old.color != color;
}
import 'dart:math' as math;

CustomPaint(
  size: const Size.square(140),
  painter: IndicadorProgreso(progreso: 0.72, color: Theme.of(context).colorScheme.primary),
  child: const SizedBox.square(
    dimension: 140,
    child: Center(child: Text('72 %', style: TextStyle(fontSize: 24))),
  ),
)

Implementar shouldRepaint correctamente es importante para el rendimiento: devolver siempre true obliga a redibujar en cada fotograma aunque nada haya cambiado. CustomPaint debe ser el último recurso; antes de usarlo, conviene comprobar si el efecto se consigue combinando widgets existentes como ClipPath, DecoratedBox o los indicadores de progreso de Material.

Errores de layout más frecuentes

Casi todos los desarrolladores de Flutter se encuentran con los mismos errores de maquetación. Entender su causa ahorra mucho tiempo.

El más famoso es el desbordamiento, que aparece como franjas amarillas y negras en la pantalla y como este mensaje en la consola:

A RenderFlex overflowed by 48 pixels on the right.
The relevant error-causing widget was:
  Row Row:file:///proyecto/lib/features/catalogo/widgets/tarjeta_producto.dart:62:17

Ocurre cuando los hijos de una fila o columna necesitan más espacio del disponible. Se soluciona envolviendo el hijo que puede encogerse en Expanded o Flexible, limitando las líneas de los textos con maxLines y overflow, o haciendo la zona desplazable.

El segundo es el de altura sin límites, típico al colocar una lista dentro de una columna:

Vertical viewport was given unbounded height.
Viewports expand in the scrolling direction to fill their container.

Una columna no impone una altura máxima a sus hijos, y una lista intenta ocupar toda la altura posible, así que el cálculo no tiene solución. Envuelve la lista en Expanded para que ocupe el espacio restante de la columna o, si la pantalla completa debe desplazarse, sustituye la estructura por un CustomScrollView con slivers.

El tercero aparece al usar Expanded fuera de una fila, columna o Flex:

Incorrect use of ParentDataWidget.
The ParentDataWidget Expanded(flex: 1) wants to apply ParentData of type
FlexParentData to a RenderObject, which has been set up to accept ParentData
of incompatible type BoxParentData.

Expanded y Flexible solo funcionan como hijos directos de Row, Column o Flex. Del mismo modo, Positioned solo funciona como hijo directo de un Stack.

El cuarto es el efecto de onda invisible de InkWell, que casi siempre se debe a que falta un Material como ancestro o a que un Container con color de fondo se coloca entre el Material y el InkWell, tapando la onda.

Para diagnosticar cualquier problema de layout, la herramienta de referencia es el inspector de widgets de Flutter DevTools. Muestra el árbol real, las restricciones de tamaño de cada widget y una vista del layout de las filas y columnas que explica de forma visual por qué algo no cabe:

flutter run
# Con la aplicación en marcha, pulsa "v" en la terminal para abrir DevTools

Buenas prácticas de rendimiento y adaptabilidad

Implementar una interfaz correctamente no consiste solo en que se vea igual que el diseño, sino en que funcione bien en todos los dispositivos.

Usa constructores const siempre que sea posible. Un widget constante se crea una única vez y Flutter puede omitir su reconstrucción. El analizador de Dart sugiere automáticamente dónde añadirlos.

Construye las listas largas con constructores perezosos como ListView.builder o SliverList.builder, en lugar de pasar una lista completa de hijos.

Diseña pensando en distintos tamaños de pantalla. Con MediaQuery.sizeOf(context) obtienes el tamaño de la ventana, y con LayoutBuilder las restricciones del espacio disponible para un widget concreto, lo que permite cambiar la disposición según el ancho:

LayoutBuilder(
  builder: (context, restricciones) {
    if (restricciones.maxWidth >= 840) {
      return const Row(children: [MenuLateral(), Expanded(child: Contenido())]);
    }
    return const Contenido();
  },
)

Cuida la accesibilidad. Respeta el escalado de texto del sistema evitando alturas fijas en contenedores de texto, añade etiquetas semánticas a los iconos pulsables y mantén áreas táctiles de al menos 48 píxeles lógicos. Los componentes de Material 3 cumplen estas pautas de serie, lo que es otra buena razón para priorizarlos frente a widgets construidos desde cero.

Prefiere los componentes actuales de Material 3. Widgets como NavigationBar, FilledButton, SegmentedButton o SearchBar sustituyen a alternativas más antiguas y se integran mejor con el sistema de colores y tipografía del tema.

Conclusiones

Implementar cualquier interfaz en Flutter deja de ser intimidante cuando se aplica un método en lugar de improvisar. Recorrer el diseño en un orden fijo, asignar a cada elemento el widget que mejor lo describe, agruparlos con Row, Column y Stack, extraer los bloques repetidos en widgets propios y ajustar los estilos al final es un proceso que funciona igual para una tarjeta sencilla que para una pantalla completa.

El árbol de widgets es la herramienta mental que conecta la imagen con el código, y los widgets de layout, junto con Expanded, Flexible y la propiedad spacing, resuelven la mayor parte de la maquetación. Container y BoxDecoration aportan el acabado visual, mientras que GestureDetector e InkWell dan vida a los elementos interactivos.

Para pantallas desplazables complejas, CustomScrollView y los slivers permiten combinar cabeceras animadas, cuadrículas y listas con un rendimiento excelente, y CustomPaint cubre los casos excepcionales que ningún widget resuelve.

Por último, conocer los errores de layout más habituales y saber diagnosticarlos con DevTools, apoyarse en el tema de Material 3 y pensar desde el principio en distintos tamaños de pantalla y en la accesibilidad es lo que convierte una interfaz que se parece al diseño en una interfaz lista para producción.