Hero animations en Flutter: ejercicio resuelto

Hero animations en Flutter: ejercicio resuelto

El widget Hero crea una transición visual continua de un elemento entre dos rutas. Flutter anima automáticamente la posición, tamaño y forma del widget de origen al destino — sin una sola línea de código de animación. Es la técnica más impactante para conectar pantallas con un elemento visual compartido.

Enunciado

Implementa una galería de imágenes donde:

  • Las miniaturas de la lista tienen un Hero con etiqueta única.
  • Al pulsar una miniatura, la imagen se anima hacia la pantalla de detalle.
  • Al volver, la imagen regresa a su posición original.
  • El detalle incluye el título animado con Hero de texto.

Dependencias

Solo Flutter SDK.

Solución completa

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
// lib/main.dart
import 'package:flutter/material.dart';

void main() => runApp(const MaterialApp(home: GalleryPage()));

// ── Modelo ─────────────────────────────────────────────────────────────────────
class Photo {
  final String id;
  final String title;
  final Color color;
  final IconData icon;

  const Photo({
    required this.id,
    required this.title,
    required this.color,
    required this.icon,
  });
}

const _photos = [
  Photo(id: 'mountains', title: 'Montañas', color: Color(0xFF1a3a5c), icon: Icons.terrain),
  Photo(id: 'ocean', title: 'Océano', color: Color(0xFF006994), icon: Icons.waves),
  Photo(id: 'forest', title: 'Bosque', color: Color(0xFF2d6a4f), icon: Icons.park),
  Photo(id: 'desert', title: 'Desierto', color: Color(0xFFc9963a), icon: Icons.wb_sunny),
  Photo(id: 'city', title: 'Ciudad', color: Color(0xFF4a4e69), icon: Icons.location_city),
  Photo(id: 'aurora', title: 'Aurora', color: Color(0xFF6a0572), icon: Icons.auto_awesome),
];

// ── Pantalla de galería ────────────────────────────────────────────────────────
class GalleryPage extends StatelessWidget {
  const GalleryPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Galería Hero')),
      body: GridView.builder(
        padding: const EdgeInsets.all(12),
        gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: 2,
          crossAxisSpacing: 12,
          mainAxisSpacing: 12,
        ),
        itemCount: _photos.length,
        itemBuilder: (context, index) {
          final photo = _photos[index];
          return GestureDetector(
            onTap: () => Navigator.push(
              context,
              MaterialPageRoute(
                builder: (_) => PhotoDetailPage(photo: photo),
              ),
            ),
            child: ClipRRect(
              borderRadius: BorderRadius.circular(12),
              child: Hero(
                tag: 'photo-${photo.id}',
                child: _PhotoCard(photo: photo),
              ),
            ),
          );
        },
      ),
    );
  }
}

// ── Pantalla de detalle ────────────────────────────────────────────────────────
class PhotoDetailPage extends StatelessWidget {
  final Photo photo;
  const PhotoDetailPage({super.key, required this.photo});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black,
      appBar: AppBar(
        backgroundColor: Colors.transparent,
        foregroundColor: Colors.white,
        elevation: 0,
      ),
      body: Column(
        children: [
          // Imagen Hero — ocupa 60% de la pantalla
          Expanded(
            flex: 3,
            child: Hero(
              tag: 'photo-${photo.id}',
              child: _PhotoCard(photo: photo, large: true),
            ),
          ),
          // Información debajo de la imagen
          Expanded(
            flex: 2,
            child: Padding(
              padding: const EdgeInsets.all(24),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  // Título con Hero para animarlo también
                  Hero(
                    tag: 'title-${photo.id}',
                    child: Material(
                      color: Colors.transparent,
                      child: Text(
                        photo.title,
                        style: const TextStyle(
                          color: Colors.white,
                          fontSize: 32,
                          fontWeight: FontWeight.bold,
                        ),
                      ),
                    ),
                  ),
                  const SizedBox(height: 12),
                  const Text(
                    'Una descripción detallada de este lugar increíble aparecería aquí. '
                    'Puedes expandir esta sección con contenido real desde tu API.',
                    style: TextStyle(color: Colors.white70, fontSize: 14, height: 1.5),
                  ),
                  const Spacer(),
                  FilledButton.icon(
                    onPressed: () {},
                    icon: const Icon(Icons.favorite_border),
                    label: const Text('Guardar en favoritos'),
                    style: FilledButton.styleFrom(
                      backgroundColor: photo.color,
                      minimumSize: const Size(double.infinity, 48),
                    ),
                  ),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }
}

// ── Widget compartido (thumbnail y detail usan el mismo) ──────────────────────
class _PhotoCard extends StatelessWidget {
  final Photo photo;
  final bool large;

  const _PhotoCard({required this.photo, this.large = false});

  @override
  Widget build(BuildContext context) {
    return Container(
      color: photo.color,
      child: Stack(
        fit: StackFit.expand,
        children: [
          // Icono de fondo
          Center(
            child: Icon(
              photo.icon,
              size: large ? 120 : 60,
              color: Colors.white.withOpacity(0.3),
            ),
          ),
          // Gradiente + título en la miniatura
          if (!large)
            Positioned(
              bottom: 0,
              left: 0,
              right: 0,
              child: Container(
                padding: const EdgeInsets.all(8),
                decoration: const BoxDecoration(
                  gradient: LinearGradient(
                    begin: Alignment.bottomCenter,
                    end: Alignment.topCenter,
                    colors: [Colors.black54, Colors.transparent],
                  ),
                ),
                child: Hero(
                  tag: 'title-${photo.id}',
                  child: Material(
                    color: Colors.transparent,
                    child: Text(
                      photo.title,
                      style: const TextStyle(
                        color: Colors.white,
                        fontWeight: FontWeight.bold,
                        fontSize: 14,
                      ),
                    ),
                  ),
                ),
              ),
            ),
        ],
      ),
    );
  }
}

Cómo funciona internamente

  1. Flutter identifica los pares de Hero con la misma tag en la ruta de origen y la de destino.
  2. Crea una capa de overlay temporal y “vuela” el widget desde el Rect de origen al de destino durante la transición.
  3. Al completar, el widget ocupa su posición final en el árbol de destino.

Personalizar el vuelo con flightShuttleBuilder

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
Hero(
  tag: 'photo-${photo.id}',
  flightShuttleBuilder: (flightContext, animation, direction, fromContext, toContext) {
    return AnimatedBuilder(
      animation: animation,
      builder: (_, child) => Opacity(
        opacity: animation.value,
        child: _PhotoCard(photo: photo, large: true),
      ),
    );
  },
  child: _PhotoCard(photo: photo),
),

Úsalo para mostrar una versión diferente del widget durante el vuelo (por ejemplo, la versión en alta resolución).

Errores frecuentes

  • Tags duplicados en la misma ruta: dos Hero con la misma tag en la misma pantalla lanzan FlutterError: There are multiple heroes that share the same tag. Las tags deben ser únicas por ruta.
  • Text con Hero sin Material: el texto hereda el estilo de la pantalla de origen durante el vuelo, lo que produce texto blanco/negro inesperado. Envuelve el Text en Material(color: Colors.transparent).
  • Hero dentro de Opacity con value 0: el widget de origen debe ser visible para que el vuelo funcione. Si lo ocultas con Opacity(opacity: 0), usa Visibility o elimínalo directamente.

Hero en navegación con go_router

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// go_router con Hero funciona igual — el Hero animation
// detecta los widgets coincidentes entre rutas automáticamente.
// Solo asegúrate de que las rutas estén bajo el mismo Navigator.
GoRoute(
  path: '/photo/:id',
  pageBuilder: (context, state) => CustomTransitionPage(
    child: PhotoDetailPage(photo: getPhoto(state.pathParameters['id']!)),
    transitionsBuilder: (_, animation, __, child) =>
        FadeTransition(opacity: animation, child: child),
  ),
),

Aplicación práctica

Hero animations se usan en galerías de imágenes, tarjetas de producto que se expanden al toque, avatares de usuario que crecen al ver el perfil y cualquier elemento compartido que conecta dos pantallas visualmente.

Siguiente ejercicio recomendado

Práctica guiada y siguiente paso

FAQ

¿Hero funciona con go_router?

Sí, siempre que las rutas compartan el mismo Navigator. En go_router con ShellRoute o rutas anidadas, asegúrate de que ambas páginas estén bajo el mismo nivel de Navigator.

¿Puedo animar varios elementos Hero a la vez en la misma transición?

Sí. Puedes tener tantos pares de Hero como quieras en la misma transición de ruta. Cada uno “vuela” de forma independiente.

¿El Hero funciona con showDialog o showModalBottomSheet?

No directamente — estos métodos crean un Navigator secundario. Para animaciones de tipo Hero en dialogs, usa un overlay personalizado o una transición de página completa con PageRouteBuilder.