Animations
Flutter has three layers of animation: AnimatedFoo widgets for one-shot tween transitions, AnimationController + Tween for custom curves, and the Flutter animations packages Hero / shared-axis / fade-through transitions for screen-level polish. Default to implicit widgets, drop to controllers when timing matters, reach for the package for transitions.
Implicit, explicit, and hero animations
EXAMPLE
import 'package:flutter/material.dart';
// 1) AnimatedContainer — easiest possible animation
class ImplicitBox extends StatefulWidget {
const ImplicitBox({super.key});
@override
State<ImplicitBox> createState() => _ImplicitBoxState();
}
class _ImplicitBoxState extends State<ImplicitBox> {
bool big = false;
@override
Widget build(BuildContext c) {
return GestureDetector(
onTap: () => setState(() => big = !big),
child: AnimatedContainer(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
width: big ? 240 : 120,
height: big ? 240 : 120,
decoration: BoxDecoration(
color: big ? Colors.indigo : Colors.indigoAccent,
borderRadius: BorderRadius.circular(big ? 24 : 8),
),
),
);
}
}
// 2) AnimationController — full control of timing
class PulseRing extends StatefulWidget {
const PulseRing({super.key});
@override
State<PulseRing> createState() => _PulseRingState();
}
class _PulseRingState extends State<PulseRing> with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
late final Animation<double> _scale;
@override
void initState() {
super.initState();
_ctrl = AnimationController(vsync: this, duration: const Duration(seconds: 2))
..repeat(reverse: true);
_scale = CurvedAnimation(parent: _ctrl, curve: Curves.easeInOutSine);
}
@override
void dispose() { _ctrl.dispose(); super.dispose(); }
@override
Widget build(BuildContext c) {
return ScaleTransition(
scale: Tween<double>(begin: 1, end: 1.15).animate(_scale),
child: const CircleAvatar(radius: 40, child: Icon(Icons.favorite)),
);
}
}
// 3) Tweens + Listener — drive arbitrary values
class CountUp extends StatefulWidget {
const CountUp({super.key, required this.to});
final int to;
@override
State<CountUp> createState() => _CountUpState();
}
class _CountUpState extends State<CountUp> with SingleTickerProviderStateMixin {
late final AnimationController _c;
@override
void initState() {
super.initState();
_c = AnimationController(vsync: this, duration: const Duration(seconds: 2))..forward();
}
@override
void dispose() { _c.dispose(); super.dispose(); }
@override
Widget build(BuildContext ctx) {
return AnimatedBuilder(
animation: _c,
builder: (_, __) => Text('${(widget.to * _c.value).round()}', style: const TextStyle(fontSize: 48)),
);
}
}
// 4) Hero — shared element across routes
class ListPage extends StatelessWidget {
const ListPage({super.key});
@override
Widget build(BuildContext c) => Scaffold(
body: ListView(children: [
for (final id in ['a','b','c']) ListTile(
leading: Hero(tag: 'avatar-$id',
child: CircleAvatar(child: Text(id.toUpperCase()))),
title: Text('Item $id'),
onTap: () => Navigator.of(c).push(MaterialPageRoute(
builder: (_) => DetailPage(id: id))),
),
]),
);
}
class DetailPage extends StatelessWidget {
const DetailPage({super.key, required this.id});
final String id;
@override
Widget build(BuildContext c) => Scaffold(
appBar: AppBar(),
body: Center(child: Hero(tag: 'avatar-$id',
child: CircleAvatar(radius: 80, child: Text(id.toUpperCase(), style: const TextStyle(fontSize: 48))))),
);
}
// 5) Page transitions via the 'animations' package
// pubspec.yaml -> animations: ^2.0.0
// import 'package:animations/animations.dart';
// Navigator.of(c).push(PageRouteBuilder(
// transitionsBuilder: (_, anim, sec, child) => SharedAxisTransition(
// animation: anim, secondaryAnimation: sec,
// transitionType: SharedAxisTransitionType.horizontal,
// child: child),
// pageBuilder: (_, __, ___) => const DetailPage(id: 'a'),
// ));
Why it matters
Always pass `vsync: this` (a TickerProvider) to an AnimationController and call `dispose()` in dispose(). The vsync prevents ticking when the route is off-screen; forgetting it drains battery and produces "the dial keeps spinning even though the user navigated away" reports. Dispose stops the controller cleanly — without it, you leak the ticker per route push.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
AnimatedContainer(
duration: const Duration(milliseconds: 400),
curve: Curves.easeOut,
width: open ? 200 : 100,
height: open ? 200 : 100,
color: open ? Colors.green : Colors.blue,
)
Try it Yourself »
Discussion
Loading…