Animaciones implícitas en Flutter: ejercicio resuelto

Animaciones implícitas en Flutter: ejercicio resuelto

Las animaciones implícitas son las más fáciles de implementar en Flutter: cambias un valor, el widget anima la transición automáticamente. No necesitas AnimationController ni Tween explícitos. La regla: si existe un AnimatedFoo para tu widget Foo, úsalo.

Enunciado

Construye una pantalla demo que muestre:

  1. AnimatedContainer: tarjeta que cambia de tamaño, color y radio de borde.
  2. AnimatedOpacity: panel que aparece y desaparece suavemente.
  3. AnimatedSwitcher: número que hace flip al cambiar.
  4. TweenAnimationBuilder: barra de progreso personalizada animada.

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
// lib/main.dart
import 'package:flutter/material.dart';

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

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

  @override
  State<AnimationsDemo> createState() => _AnimationsDemoState();
}

class _AnimationsDemoState extends State<AnimationsDemo> {
  // Estado para AnimatedContainer
  bool _expanded = false;

  // Estado para AnimatedOpacity
  bool _visible = true;

  // Estado para AnimatedSwitcher
  int _counter = 0;

  // Estado para TweenAnimationBuilder
  double _targetProgress = 0.3;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Animaciones implícitas')),
      body: ListView(
        padding: const EdgeInsets.all(20),
        children: [
          // ── 1. AnimatedContainer ─────────────────────────────────────────
          _SectionTitle('1. AnimatedContainer'),
          Center(
            child: AnimatedContainer(
              duration: const Duration(milliseconds: 400),
              curve: Curves.easeInOut,
              width: _expanded ? 280 : 140,
              height: _expanded ? 140 : 70,
              decoration: BoxDecoration(
                color: _expanded ? Colors.indigo : Colors.blue.shade200,
                borderRadius: BorderRadius.circular(_expanded ? 24 : 8),
                boxShadow: [
                  BoxShadow(
                    color: Colors.black.withOpacity(0.15),
                    blurRadius: _expanded ? 16 : 4,
                    offset: const Offset(0, 4),
                  ),
                ],
              ),
              child: Center(
                child: AnimatedDefaultTextStyle(
                  duration: const Duration(milliseconds: 400),
                  style: TextStyle(
                    color: Colors.white,
                    fontSize: _expanded ? 22 : 14,
                    fontWeight: FontWeight.bold,
                  ),
                  child: Text(_expanded ? '¡Expandido!' : 'Toca para expandir'),
                ),
              ),
            ),
          ),
          const SizedBox(height: 8),
          Center(
            child: ElevatedButton(
              onPressed: () => setState(() => _expanded = !_expanded),
              child: Text(_expanded ? 'Colapsar' : 'Expandir'),
            ),
          ),

          const SizedBox(height: 32),

          // ── 2. AnimatedOpacity ───────────────────────────────────────────
          _SectionTitle('2. AnimatedOpacity'),
          AnimatedOpacity(
            opacity: _visible ? 1.0 : 0.0,
            duration: const Duration(milliseconds: 500),
            child: Container(
              padding: const EdgeInsets.all(16),
              decoration: BoxDecoration(
                color: Colors.green.shade50,
                border: Border.all(color: Colors.green),
                borderRadius: BorderRadius.circular(12),
              ),
              child: const Row(
                children: [
                  Icon(Icons.check_circle, color: Colors.green),
                  SizedBox(width: 8),
                  Text('Panel con fade in/out'),
                ],
              ),
            ),
          ),
          const SizedBox(height: 8),
          Center(
            child: ElevatedButton(
              onPressed: () => setState(() => _visible = !_visible),
              child: Text(_visible ? 'Ocultar' : 'Mostrar'),
            ),
          ),

          const SizedBox(height: 32),

          // ── 3. AnimatedSwitcher ──────────────────────────────────────────
          _SectionTitle('3. AnimatedSwitcher'),
          Center(
            child: AnimatedSwitcher(
              duration: const Duration(milliseconds: 300),
              transitionBuilder: (child, animation) => ScaleTransition(
                scale: animation,
                child: child,
              ),
              child: Text(
                '$_counter',
                key: ValueKey(_counter), // clave necesaria para detectar cambio
                style: const TextStyle(fontSize: 64, fontWeight: FontWeight.bold),
              ),
            ),
          ),
          const SizedBox(height: 8),
          Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              IconButton(
                icon: const Icon(Icons.remove),
                onPressed: () => setState(() => _counter--),
              ),
              const SizedBox(width: 16),
              IconButton(
                icon: const Icon(Icons.add),
                onPressed: () => setState(() => _counter++),
              ),
            ],
          ),

          const SizedBox(height: 32),

          // ── 4. TweenAnimationBuilder ─────────────────────────────────────
          _SectionTitle('4. TweenAnimationBuilder'),
          TweenAnimationBuilder<double>(
            tween: Tween(begin: 0, end: _targetProgress),
            duration: const Duration(milliseconds: 600),
            curve: Curves.easeOut,
            builder: (context, value, _) {
              return Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    '${(value * 100).toInt()}%',
                    style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
                  ),
                  const SizedBox(height: 6),
                  ClipRRect(
                    borderRadius: BorderRadius.circular(8),
                    child: LinearProgressIndicator(
                      value: value,
                      minHeight: 20,
                      backgroundColor: Colors.grey.shade200,
                      valueColor: AlwaysStoppedAnimation(
                        Color.lerp(Colors.red, Colors.green, value)!,
                      ),
                    ),
                  ),
                ],
              );
            },
          ),
          const SizedBox(height: 12),
          Slider(
            value: _targetProgress,
            onChanged: (v) => setState(() => _targetProgress = v),
          ),

          const SizedBox(height: 40),
        ],
      ),
    );
  }
}

class _SectionTitle extends StatelessWidget {
  final String text;
  const _SectionTitle(this.text);

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 12),
      child: Text(
        text,
        style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
      ),
    );
  }
}

Resultado esperado

  • La tarjeta azul se expande suavemente a tamaño doble con borde redondeado al pulsar el botón.
  • El panel verde se desvanece y reaparece con AnimatedOpacity.
  • El número hace un efecto de escala al cambiar (no un corte brusco).
  • La barra de progreso se llena animadamente con el color cambiando de rojo a verde.

Tabla de widgets implícitos más usados

WidgetQué anima
AnimatedContainerCualquier propiedad de Container
AnimatedOpacityOpacidad
AnimatedPaddingPadding
AnimatedAlignAlineación
AnimatedDefaultTextStyleEstilo de texto en árbol
AnimatedPositionedPosición en Stack
AnimatedSwitcherTransición entre widgets distintos
TweenAnimationBuilderCualquier valor con interpolación customizable

Cuándo usar animaciones explícitas

Si necesitas:

  • Control fino del ciclo (pause, reverse, loop): AnimationController.
  • Animaciones coordinadas entre varios widgets: AnimatedBuilder.
  • Animaciones de física (spring, rebote): SpringSimulation.

Errores frecuentes

  • AnimatedSwitcher sin ValueKey: si el nuevo widget tiene el mismo tipo que el anterior, Flutter reutiliza el elemento y no anima la transición.
  • Cambiar curve sin cambiar duration: las curvas con Curves.bounceOut necesitan duración suficiente o el bounce se corta.
  • TweenAnimationBuilder con tween que siempre crea nueva instancia: mueve el Tween fuera del build o usa begin: null para animar desde el valor anterior.

Aplicación práctica

Las animaciones implícitas convierten una UI plana en una experiencia fluida sin complejidad extra. Úsalas por defecto; pasa a animaciones explícitas solo cuando necesites control que los widgets implícitos no ofrecen.

Siguiente ejercicio recomendado

Práctica guiada y siguiente paso

FAQ

¿Las animaciones implícitas afectan al rendimiento?

Mínimamente. Flutter optimiza los repaints al área que cambia. Si tienes muchas tarjetas animándose a la vez, asegúrate de que cada AnimatedContainer está en un widget separado para aislar los rebuilds.

¿Puedo combinar AnimatedContainer con GestureDetector?

Sí. AnimatedContainer es un widget normal; envuélvelo en GestureDetector o InkWell para hacer la tarjeta tappable.

¿AnimatedSwitcher funciona con cualquier widget?

Sí, pero cada widget que entra y sale debe tener una Key diferente (normalmente ValueKey) para que Flutter detecte el cambio y aplique la transición.