Flutter Animations: Physics, Implicit and Custom Painters
Animations in Flutter are built on a three-layer system: implicit animations (AnimatedContainer, AnimatedOpacity) for simple transitions with zero setup, explicit animations (AnimationController, Tween, AnimatedBuilder) for complete timing control, curve and direction, e physical simulations (SpringSimulation, FrictionSimulation) for movements that seem real because they follow the laws of physics. Added to this is the CustomPainter to draw custom graphics directly on Canvas.
Choosing the right level makes the difference between maintainable code and code complex for no reason. This guide covers all four levels with examples practical that you can use directly in your projects.
What You Will Learn
- Implicit animations: when they are enough and when they limit
- AnimationController + Tween: Granular control with TickerProvider
- Animation curves: Curves.easeInOut, bounceOut, elasticIn and custom
- SpringSimulation: Physical springs for natural movements
- FrictionSimulation: deceleration simulation for scroll and swipe
- CustomPainter: draw on Canvas with Path, Paint, drawArc
- Hero animation: Shared transitions between screens
Implicit Animations: The Simple Way
Le animated widget of Flutter handle it automatically
animation when a property changes: no controller, no ticker,
just one duration and one curve. Perfect for
90% of daily UI transitions.
// Catalog delle animated widgets piu utili
// AnimatedContainer: anima width, height, color, border, padding
class ExpandableCard extends StatefulWidget {
@override
State<ExpandableCard> createState() => _ExpandableCardState();
}
class _ExpandableCardState extends State<ExpandableCard> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => setState(() => _expanded = !_expanded),
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
height: _expanded ? 200 : 80,
decoration: BoxDecoration(
color: _expanded ? Colors.blue : Colors.grey.shade200,
borderRadius: BorderRadius.circular(_expanded ? 16 : 8),
boxShadow: _expanded
? [BoxShadow(blurRadius: 12, color: Colors.blue.withOpacity(0.3))]
: [],
),
padding: EdgeInsets.all(_expanded ? 24 : 12),
child: const Text('Tap to expand'),
),
);
}
}
// AnimatedOpacity: fade in/out
AnimatedOpacity(
opacity: _isVisible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: const SomeWidget(),
)
// AnimatedSwitcher: anima il cambio di widget (crossfade)
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, animation) => FadeTransition(
opacity: animation,
child: child,
),
child: _showFirst
? const Text('First', key: ValueKey('first'))
: const Text('Second', key: ValueKey('second')),
)
// TweenAnimationBuilder: anima un valore personalizzato
TweenAnimationBuilder<double>(
tween: Tween(begin: 0.0, end: _progress),
duration: const Duration(milliseconds: 500),
curve: Curves.easeOut,
builder: (context, value, child) => LinearProgressIndicator(value: value),
)
AnimationController: Explicit Control
// AnimationController: timing e direzione manuale
class PulseButton extends StatefulWidget {
final VoidCallback onPressed;
const PulseButton({required this.onPressed, super.key});
@override
State<PulseButton> createState() => _PulseButtonState();
}
class _PulseButtonState extends State<PulseButton>
with SingleTickerProviderStateMixin {
// SingleTickerProviderStateMixin: fornisce il vsync al controller
late final AnimationController _controller;
late final Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this, // collegato al VSync per 60fps
duration: const Duration(milliseconds: 150),
);
// Tween: mappa 0.0 - 1.0 del controller a 1.0 - 0.9 (shrink on press)
_scaleAnimation = Tween<double>(begin: 1.0, end: 0.9).animate(
CurvedAnimation(
parent: _controller,
curve: Curves.easeOut,
reverseCurve: Curves.easeIn,
),
);
}
@override
void dispose() {
// CRITICO: disponi sempre il controller per evitare memory leak
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: (_) => _controller.forward(),
onTapUp: (_) async {
await _controller.reverse();
widget.onPressed();
},
onTapCancel: () => _controller.reverse(),
child: AnimatedBuilder(
animation: _scaleAnimation,
builder: (context, child) => Transform.scale(
scale: _scaleAnimation.value,
child: child,
),
child: ElevatedButton(
onPressed: null, // Gestito da GestureDetector
child: const Text('Press Me'),
),
),
);
}
}
// Animazione in loop con repeat()
class LoadingDots extends StatefulWidget {
@override
State<LoadingDots> createState() => _LoadingDotsState();
}
class _LoadingDotsState extends State<LoadingDots>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
)..repeat(); // Loop infinito
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, _) {
return Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(3, (index) {
// Ogni punto ha un delay diverso
final delay = index * 0.2;
final value = (_controller.value - delay).clamp(0.0, 1.0);
final scale = Tween(begin: 0.5, end: 1.0)
.evaluate(CurvedAnimation(
parent: AlwaysStoppedAnimation(
(math.sin(value * math.pi * 2) + 1) / 2,
),
curve: Curves.easeInOut,
));
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Transform.scale(
scale: scale,
child: Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: Colors.blue,
shape: BoxShape.circle,
),
),
),
);
}),
);
},
);
}
}
SpringSimulation: Real Physics
// SpringSimulation: molla fisica per movimenti naturali
class SpringCard extends StatefulWidget {
@override
State<SpringCard> createState() => _SpringCardState();
}
class _SpringCardState extends State<SpringCard>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
double _dragOffset = 0;
late double _startOffset;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 1), // massima durata della simulazione
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _onPanStart(DragStartDetails details) {
_controller.stop();
_startOffset = _dragOffset;
}
void _onPanUpdate(DragUpdateDetails details) {
setState(() {
_dragOffset += details.delta.dx;
});
}
void _onPanEnd(DragEndDetails details) {
// Simula una molla che riporta la card alla posizione originale
final spring = SpringSimulation(
SpringDescription(
mass: 1.0, // massa della card (kg)
stiffness: 200.0, // rigidita della molla (N/m) - piu alto = piu rigida
damping: 20.0, // smorzamento - piu alto = meno rimbalzi
),
_dragOffset, // posizione iniziale
0.0, // posizione target (0 = centro)
details.velocity.pixelsPerSecond.dx, // velocita iniziale
);
_controller.animateWith(spring).then((_) {
setState(() => _dragOffset = 0.0);
});
_controller.addListener(() {
setState(() {
_dragOffset = spring.x(_controller.value);
});
});
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onPanStart: _onPanStart,
onPanUpdate: _onPanUpdate,
onPanEnd: _onPanEnd,
child: Transform.translate(
offset: Offset(_dragOffset, 0),
child: Card(
child: const Padding(
padding: EdgeInsets.all(24),
child: Text('Drag me - I spring back!'),
),
),
),
);
}
}
CustomPainter: Draw on Canvas
// CustomPainter: grafico a ciambella animato
class DonutChart extends StatelessWidget {
final double progress; // 0.0 - 1.0
final Color color;
final String label;
const DonutChart({
required this.progress,
required this.color,
required this.label,
super.key,
});
@override
Widget build(BuildContext context) {
return CustomPaint(
size: const Size(120, 120),
painter: _DonutPainter(progress: progress, color: color),
child: Center(
child: Text(
'${(progress * 100).round()}%',
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
),
);
}
}
class _DonutPainter extends CustomPainter {
final double progress;
final Color color;
const _DonutPainter({required this.progress, required this.color});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = size.width / 2 - 10;
const strokeWidth = 12.0;
// Paint per il background (grigio)
final bgPaint = Paint()
..color = Colors.grey.shade200
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round;
// Paint per il progresso
final progressPaint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round;
// Disegna il cerchio background
canvas.drawCircle(center, radius, bgPaint);
// Disegna l'arco di progresso
final sweepAngle = 2 * math.pi * progress;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
-math.pi / 2, // start: ore 12 (top)
sweepAngle, // angolo spazzato
false, // non collegare al centro (false = arco solo)
progressPaint,
);
}
@override
bool shouldRepaint(_DonutPainter oldDelegate) {
// Ridisegna solo se i dati sono cambiati
return oldDelegate.progress != progress || oldDelegate.color != color;
}
}
// Animazione del grafico con TweenAnimationBuilder
class AnimatedDonutChart extends StatelessWidget {
final double targetProgress;
final Color color;
const AnimatedDonutChart({
required this.targetProgress,
required this.color,
super.key,
});
@override
Widget build(BuildContext context) {
return TweenAnimationBuilder<double>(
tween: Tween(begin: 0.0, end: targetProgress),
duration: const Duration(milliseconds: 1200),
curve: Curves.easeInOut,
builder: (context, value, _) => DonutChart(
progress: value,
color: color,
label: '${(value * 100).round()}%',
),
);
}
}
Which Type of Animation to Use
- Implicit (Animated*): simple ownership transitions (80% of cases)
- Explicit (AnimationController): Looped animations, triggered by events, with sync between multiple properties
- Physics (SpringSimulation): drag-and-drop, swipe with return, movements that must seem physical
- CustomPainter: graphs, custom progress indicators, visual effects not achievable with standard widgets
- Hero: Shared transitions between screens for prominent visual elements
Conclusions
The Flutter animation system is one of the most powerful and flexible among the mobile framework: from the implicit widget that requires 5 lines of code, to physical simulation that produces movements indistinguishable from reality, up to to the CustomPainter which makes the entire Canvas API available to the Dart. The key is choosing the correct abstraction level for each use case, without over-engineering when an AnimatedContainer is enough.







