Animacje Flutter: Fizyka, niejawni i niestandardowi malarze
Animacje w Flutter zbudowane są w systemie trójwarstwowym: ukryte animacje (Animowany kontener, AnimatedOpacity) dla proste przejścia przy zerowej konfiguracji, wyraźne animacje (AnimationController, Tween, AnimatedBuilder) dla pełnej kontroli czasu, krzywa i kierunek, np symulacje fizyczne (Symulacja wiosny, FrictionSimulation) dla ruchów, które wydają się realne, ponieważ są zgodne z prawami fizyki. Do tego dochodzi Niestandardowy malarz rysować niestandardowa grafika bezpośrednio na płótnie.
Wybór odpowiedniego poziomu robi różnicę między kodem możliwym do utrzymania a kodem skomplikowane bez powodu. Ten przewodnik obejmuje wszystkie cztery poziomy wraz z przykładami praktyczne, które możesz wykorzystać bezpośrednio w swoich projektach.
Czego się nauczysz
- Ukryte animacje: kiedy wystarczą, a kiedy ograniczają
- AnimationController + Tween: Szczegółowa kontrola za pomocą TickerProvider
- Krzywe animacji: Curves.easeInOut, BounceOut, ElasticIn i niestandardowe
- SpringSimulation: Fizyczne sprężyny zapewniające naturalne ruchy
- FrictionSimulation: symulacja zwalniania podczas przewijania i przesuwania
- CustomPainter: rysuj na płótnie za pomocą ścieżki, farby, rysuj łuk
- Animacja bohatera: wspólne przejścia między ekranami
Ukryte animacje: prosty sposób
Le animowany widget Flutter obsługuje to automatycznie
animacja przy zmianie właściwości: brak kontrolera, brak tickera,
tylko jeden duration i jeden curve. Idealny dla
90% codziennych przejść interfejsu użytkownika.
// 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: Jawna kontrola
// 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: Prawdziwa fizyka
// 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: rysuj na płótnie
// 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()}%',
),
);
}
}
Jakiego rodzaju animacji użyć
- Niejawne (animowane*): proste przejścia własności (80% przypadków)
- Jawne (AnimationController): Zapętlone animacje, wyzwalane przez zdarzenia, z synchronizacją między wieloma właściwościami
- Fizyka (symulacja wiosny): przeciągnij i upuść, przesuń z powrotem, ruchy, które muszą wydawać się fizyczne
- Niestandardowy malarz: wykresy, niestandardowe wskaźniki postępu, efekty wizualne nieosiągalne przy użyciu standardowych widżetów
- Bohater: Wspólne przejścia między ekranami dla wyróżniających się elementów wizualnych
Wnioski
System animacji Flutter jest jednym z najpotężniejszych i najbardziej elastycznych wśród framework mobilny: od ukrytego widżetu wymagającego 5 linii kodu, do symulacja fizyczna, która wytwarza ruchy nie do odróżnienia od rzeczywistości, aż do do CustomPainter, który udostępnia Dartowi całe API Canvas. Kluczem jest wybór odpowiedniego poziomu abstrakcji dla każdego przypadku użycia, bez nadmiernej inżynierii, gdy wystarczy AnimatedContainer.







