Animații flutter: fizică, pictori impliciti și personalizat
Animațiile din Flutter sunt construite pe un sistem cu trei straturi: animatii implicite (AnimatedContainer, AnimatedOpacity) pentru tranziții simple cu setări zero, animații explicite (AnimationController, Tween, AnimatedBuilder) pentru control complet al sincronizarii, curbă și direcție, de ex simulări fizice (Simulare de primăvară, FrictionSimulation) pentru mișcări care par reale deoarece respectă legile a fizicii. La aceasta se adaugă CustomPainter a desena grafică personalizată direct pe Canvas.
Alegerea nivelului potrivit face diferența între codul care poate fi întreținut și cod complex fără motiv. Acest ghid acoperă toate cele patru niveluri cu exemple practic pe care îl poți folosi direct în proiectele tale.
Ce vei învăța
- Animații implicite: când sunt suficiente și când limitează
- AnimationController + Tween: control granular cu TickerProvider
- Curbe de animație: Curves.easeInOut, bounceOut, elasticIn și personalizate
- SpringSimulation: Izvoare fizice pentru mișcări naturale
- FrictionSimulation: simulare de decelerare pentru defilare și glisare
- CustomPainter: desenați pe Canvas cu Path, Paint, drawArc
- Animație erou: tranziții partajate între ecrane
Animații implicite: calea simplă
Le widget animat de Flutter se ocupă automat
animație când o proprietate se schimbă: fără controler, fără ticker,
doar unul duration si unul curve. Perfect pentru
90% din tranzițiile zilnice ale UI.
// 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: Control explicit
// 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: Fizica reală
// 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: Desenează pe pânză
// 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()}%',
),
);
}
}
Ce tip de animație să utilizați
- Implicit (animat*): tranziții simple de proprietate (80% din cazuri)
- Explicit (AnimationController): Animații în buclă, declanșate de evenimente, cu sincronizare între mai multe proprietăți
- Fizica (Simulare de primăvară): drag-and-drop, glisare cu revenire, mișcări care trebuie să pară fizice
- CustomPainter: grafice, indicatori personalizați de progres, efecte vizuale care nu pot fi realizate cu widget-uri standard
- Erou: Tranziții partajate între ecrane pentru elemente vizuale proeminente
Concluzii
Sistemul de animație Flutter este unul dintre cele mai puternice și flexibile dintre cadru mobil: de la widgetul implicit care necesită 5 linii de cod, la simulare fizică care produce mișcări care nu se pot distinge de realitate, până la la CustomPainter, care pune la dispoziție întregul API Canvas pentru Dart. Cheia este alegerea nivelului corect de abstractizare pentru fiecare caz de utilizare, fără supra-inginerie atunci când un AnimatedContainer este suficient.







