Flutter-Animationssystem: Implizite, explizite und benutzerdefinierte Animationen
# Flutters Animationssystem im Detail
Animationen machen den Unterschied zwischen einer App, die funktioniert, und einer App, die sich lebendig anfuehlt. Flutter liefert eines der leistungsfaehigsten Animationssysteme in der mobilen Entwicklung mit, doch die geschichtete Architektur kann ueberwältigend wirken, wenn man nicht weiss, wo man anfangen soll. Nachdem ich Apps entwickelt habe, bei denen nahezu jeder Bildschirm choreografierte Bewegungen enthielt, habe ich ein klares mentales Modell entwickelt, um jedes Mal die richtige Herangehensweise zu waehlen.
Die Animationsschichten
Flutters Animationssystem ist in Schichten aufgebaut. Ganz unten sitzt der `Ticker`, der bei jedem VSync-Signal feuert (normalerweise 60- oder 120-mal pro Sekunde). Darauf baut der `AnimationController` auf, der Timing und Werte-Interpolation verwaltet. Eine Ebene hoeher bilden `Tween`-Objekte Wertebereiche auf konkrete Werte ab. Und ganz oben verpacken implizite Animations-Widgets all diese Komplexitaet in eine einzige Zeile Code.
Wer diese Schichten versteht, weiss immer, wohin er greifen muss, wenn die bequeme Loesung nicht ausreicht.
Implizite Animationen: Der schnelle Gewinn
Implizite Animationen sind Flutters Geschenk an Entwickler, die Bewegung ohne grossen Aufwand wollen. Man beschreibt den Zielzustand, und das Framework kuemmert sich um den Uebergang.
AnimatedContainer
Das Arbeitstier der impliziten Animationen. Es interpoliert zwischen jeder Aenderung an Dekoration, Groesse, Padding, Margin, Alignment oder Transform.
class ExpandableCard extends StatefulWidget {
const ExpandableCard({super.key});
@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: class="code-number">400),
curve: Curves.easeInOutCubic,
width: _expanded ? class="code-number">300 : class="code-number">150,
height: _expanded ? class="code-number">200 : class="code-number">80,
decoration: BoxDecoration(
color: _expanded ? Colors.indigo : Colors.indigo.shade200,
borderRadius: BorderRadius.circular(_expanded ? class="code-number">24 : class="code-number">12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(_expanded ? class="code-number">0.3 : class="code-number">0.1),
blurRadius: _expanded ? class="code-number">20 : class="code-number">8,
offset: Offset(class="code-number">0, _expanded ? class="code-number">10 : class="code-number">4),
),
],
),
child: Center(
child: Text(
_expanded ? class="code-string">'Erweitert' : class="code-string">'Antippen',
style: const TextStyle(color: Colors.white, fontSize: class="code-number">18),
),
),
),
);
}
}Weitere implizite Widgets, die man kennen sollte
Jedes folgt dem gleichen Muster: Eine Eigenschaft aendern, und das Widget animiert zum neuen Wert.
class=class="code-string">"code-comment">// Ein Widget ein- und ausblenden
AnimatedOpacity(
opacity: _visible ? class="code-number">1.0 : class="code-number">0.0,
duration: const Duration(milliseconds: class="code-number">300),
child: const Text(class="code-string">'Jetzt sichtbar'),
)
class=class="code-string">"code-comment">// Ein Widget an eine neue Position verschieben
AnimatedPositioned(
duration: const Duration(milliseconds: class="code-number">500),
curve: Curves.elasticOut,
left: _moved ? class="code-number">200 : class="code-number">50,
top: _moved ? class="code-number">100 : class="code-number">50,
child: const CircleAvatar(radius: class="code-number">30),
)
class=class="code-string">"code-comment">// Sanft zwischen zwei Widgets wechseln
AnimatedSwitcher(
duration: const Duration(milliseconds: class="code-number">400),
transitionBuilder: (child, animation) => ScaleTransition(
scale: animation,
child: child,
),
child: Text(
class="code-string">'$_count',
key: ValueKey<int>(_count),
style: const TextStyle(fontSize: class="code-number">48),
),
)Bei Apps mit intensiven Animationen habe ich gelernt, dass implizite Animationen der erste Instinkt sein sollten. Etwa 70 % der Bewegung, die man in einer typischen App braucht, lassen sich realisieren, ohne jemals einen `AnimationController` anzufassen.
Explizite Animationen: Volle Kontrolle
Wenn praezises Timing, Sequenzierung oder die Moeglichkeit zum Abspielen, Pausieren, Umkehren oder Wiederholen gefragt ist, betritt man das Gebiet der expliziten Animationen.
Das Kern-Trio: AnimationController, Tween, CurvedAnimation
class PulsingButton extends StatefulWidget {
const PulsingButton({super.key});
@override
State<PulsingButton> createState() => _PulsingButtonState();
}
class _PulsingButtonState extends State<PulsingButton>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _scaleAnimation;
late final Animation<Color?> _colorAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: class="code-number">1200),
vsync: this,
)..repeat(reverse: true);
final curvedAnimation = CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
);
_scaleAnimation = Tween<double>(begin: class="code-number">1.0, end: class="code-number">1.15).animate(curvedAnimation);
_colorAnimation = ColorTween(
begin: Colors.blue,
end: Colors.purple,
).animate(curvedAnimation);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: _colorAnimation.value,
padding: const EdgeInsets.symmetric(horizontal: class="code-number">32, vertical: class="code-number">16),
),
onPressed: () {},
child: child,
),
);
},
child: const Text(class="code-string">'Abonnieren', style: TextStyle(fontSize: class="code-number">18)),
);
}
}Wichtige Punkte: Immer `SingleTickerProviderStateMixin` verwenden (oder `TickerProviderStateMixin` fuer mehrere Controller), Controller stets disposen, und wann immer moeglich ein statisches `child` an `AnimatedBuilder` uebergeben, um den Subtree nicht bei jedem Frame neu zu bauen.
TweenAnimationBuilder: Der Mittelweg
Wenn man Tween-basierte Kontrolle will, ohne einen `AnimationController` zu verwalten, ist `TweenAnimationBuilder` die richtige Wahl.
TweenAnimationBuilder<double>(
tween: Tween(begin: class="code-number">0.0, end: _targetAngle),
duration: const Duration(milliseconds: class="code-number">800),
curve: Curves.easeOutBack,
builder: (context, angle, child) {
return Transform.rotate(
angle: angle,
child: child,
);
},
child: const Icon(Icons.refresh, size: class="code-number">48),
)Staggered Animations: Choreografierte Bewegung
Staggered Animations nutzen einen einzelnen `AnimationController` mit mehreren `Interval`-basierten Kurven. Jedes Element startet an einem anderen Punkt der Zeitleiste und erzeugt so einen kaskadierenden Effekt.
class StaggeredListAnimation extends StatefulWidget {
const StaggeredListAnimation({super.key});
@override
State<StaggeredListAnimation> createState() => _StaggeredListAnimationState();
}
class _StaggeredListAnimationState extends State<StaggeredListAnimation>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
final int _itemCount = class="code-number">5;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: class="code-number">1500),
vsync: this,
)..forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Animation<double> _buildSlideAnimation(int index) {
final start = index * class="code-number">0.1;
final end = start + class="code-number">0.4;
return Tween<double>(begin: class="code-number">80.0, end: class="code-number">0.0).animate(
CurvedAnimation(
parent: _controller,
curve: Interval(start, end.clamp(class="code-number">0.0, class="code-number">1.0), curve: Curves.easeOutCubic),
),
);
}
Animation<double> _buildFadeAnimation(int index) {
final start = index * class="code-number">0.1;
final end = start + class="code-number">0.4;
return Tween<double>(begin: class="code-number">0.0, end: class="code-number">1.0).animate(
CurvedAnimation(
parent: _controller,
curve: Interval(start, end.clamp(class="code-number">0.0, class="code-number">1.0), curve: Curves.easeOut),
),
);
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, _) {
return Column(
children: List.generate(_itemCount, (index) {
final slide = _buildSlideAnimation(index);
final fade = _buildFadeAnimation(index);
return Opacity(
opacity: fade.value,
child: Transform.translate(
offset: Offset(slide.value, class="code-number">0),
child: ListTile(
leading: CircleAvatar(child: Text(class="code-string">'${index + class="code-number">1}')),
title: Text(class="code-string">'Element ${index + class="code-number">1}'),
),
),
);
}),
);
},
);
}
}Der Trick besteht darin, alle Intervalle im Bereich von 0.0 bis 1.0 zu halten und sie leicht ueberlappen zu lassen, damit die Bewegung fliessend statt sequenziell wirkt.
Eigene Animationen mit CustomPainter
Wenn Widgets nicht ausdruecken koennen, was man braucht, steigt man auf die Canvas-Ebene hinab. `CustomPainter` erlaubt es, alles zu zeichnen und Frame fuer Frame zu animieren.
class WaveAnimation extends StatefulWidget {
const WaveAnimation({super.key});
@override
State<WaveAnimation> createState() => _WaveAnimationState();
}
class _WaveAnimationState extends State<WaveAnimation>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(seconds: class="code-number">3),
vsync: this,
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, _) {
return CustomPaint(
size: const Size(double.infinity, class="code-number">200),
painter: WavePainter(progress: _controller.value),
);
},
);
}
}
class WavePainter extends CustomPainter {
final double progress;
WavePainter({required this.progress});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.blue.withOpacity(class="code-number">0.6)
..style = PaintingStyle.fill;
final path = Path()..moveTo(class="code-number">0, size.height * class="code-number">0.5);
for (double x = class="code-number">0; x <= size.width; x++) {
final y = size.height * class="code-number">0.5 +
sin((x / size.width * class="code-number">2 * pi) + (progress * class="code-number">2 * pi)) * class="code-number">30 +
sin((x / size.width * class="code-number">4 * pi) + (progress * class="code-number">4 * pi)) * class="code-number">15;
path.lineTo(x, y);
}
path.lineTo(size.width, size.height);
path.lineTo(class="code-number">0, size.height);
path.close();
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(WavePainter oldDelegate) {
return oldDelegate.progress != progress;
}
}Die Methode `shouldRepaint` ist entscheidend. Man sollte nur `true` zurueckgeben, wenn sich die Daten, die der Painter verwendet, tatsaechlich geaendert haben. Bedingungsloses `true` erzwingt bei jedem Frame ein Neuzeichnen, selbst wenn sich nichts bewegt hat, und verschwendet GPU-Zeit.
Hero-Animationen und Seitenuebergaenge
Hero-Animationen schaffen visuelle Kontinuitaet zwischen Bildschirmen, indem sie ein gemeinsames Element waehrend der Navigation transformieren.
class=class="code-string">"code-comment">// Quell-Bildschirm
GestureDetector(
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => DetailScreen(item: item)),
),
child: Hero(
tag: class="code-string">'item-${item.id}',
child: ClipRRect(
borderRadius: BorderRadius.circular(class="code-number">12),
child: Image.network(item.imageUrl, width: class="code-number">100, height: class="code-number">100, fit: BoxFit.cover),
),
),
)
class=class="code-string">"code-comment">// Ziel-Bildschirm
Hero(
tag: class="code-string">'item-${item.id}',
child: ClipRRect(
borderRadius: BorderRadius.circular(class="code-number">0),
child: Image.network(item.imageUrl, width: double.infinity, height: class="code-number">300, fit: BoxFit.cover),
),
)Eigene Seitenuebergaenge
Fuer mehr Kontrolle darueber, wie Seiten erscheinen und verschwinden, erstellt man einen eigenen `PageRouteBuilder`.
Route createFadeScaleRoute(Widget page) {
return PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionDuration: const Duration(milliseconds: class="code-number">500),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
final curved = CurvedAnimation(parent: animation, curve: Curves.easeInOutCubic);
return FadeTransition(
opacity: curved,
child: ScaleTransition(
scale: Tween<double>(begin: class="code-number">0.92, end: class="code-number">1.0).animate(curved),
child: child,
),
);
},
);
}
class=class="code-string">"code-comment">// Verwendung
Navigator.push(context, createFadeScaleRoute(const DetailScreen()));Tipps zur Animationsperformance
1. RepaintBoundary einsetzen
Wenn nur ein Teil des Bildschirms animiert wird, sollte man ihn mit einer `RepaintBoundary` umschliessen. Das weist die Engine an, diesen Subtree auf einer eigenen Ebene zu compositen, sodass der Rest der UI nicht neu gezeichnet wird.
RepaintBoundary(
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.rotate(
angle: _controller.value * class="code-number">2 * pi,
child: child,
);
},
child: const Icon(Icons.sync, size: class="code-number">64),
),
)2. shouldRepaint korrekt implementieren
Bei `CustomPainter` ist ein fehlerhaftes `shouldRepaint` ein stiller Performance-Killer. Man vergleicht nur die Felder, die die `paint`-Methode tatsaechlich verwendet.
@override
bool shouldRepaint(WavePainter oldDelegate) {
return oldDelegate.progress != progress;
class=class="code-string">"code-comment">// NICHT immer true zurueckgeben, es sei denn, man weiss sicher, dass sich jedes Frame etwas aendert
}3. Transform statt Layout-Aenderungen bevorzugen
`Transform.translate`, `Transform.scale` und `Transform.rotate` arbeiten auf der Compositing-Ebene. Sie loesen keine Layout-Neuberechnungen aus. Im Gegensatz dazu erzwingt das Animieren von `width`, `height` oder `padding` einen vollstaendigen Layout-Durchlauf.
4. Den child-Parameter in AnimatedBuilder nutzen
Alles, was sich waehrend der Animation nicht aendert, sollte als `child`-Parameter uebergeben werden. Dieser Subtree wird einmal gebaut und bei jedem Frame wiederverwendet.
5. Opacity nach Moeglichkeit vermeiden
Das `Opacity`-Widget erzeugt einen Offscreen-Puffer. Fuer einfache Einblendungen ist `FadeTransition` mit einer expliziten Animation vorzuziehen, oder man nutzt `AnimatedOpacity`, das dies intern optimiert.
Welcher Animationstyp wann?
| Szenario | Empfohlener Ansatz |
|---|---|
| Eigenschaftsaenderung bei Tap (Farbe, Groesse, Rahmen) | `AnimatedContainer` oder anderes implizites Widget |
| Widget ein-/ausblenden | `AnimatedOpacity`, `AnimatedSlide` |
| Zwischen zwei Widgets wechseln | `AnimatedSwitcher` |
| Schleifen- oder Wiederholungsanimation | Explizit mit `AnimationController.repeat()` |
| Praezise Play/Pause/Reverse-Steuerung | Explizit mit `AnimationController` |
| Mehrere Elemente nacheinander einblenden | Staggered Animation mit `Interval` |
| Formen, Diagramme, Partikel zeichnen | `CustomPainter` + `AnimationController` |
| Gemeinsames Element zwischen Bildschirmen | `Hero`-Widget |
| Eigener Seitenuebergang | `PageRouteBuilder` |
| Einfacher Tween ohne Controller-Boilerplate | `TweenAnimationBuilder` |
Im Zweifelsfall mit einer impliziten Animation beginnen. Man kann jederzeit auf explizite Kontrolle upgraden, ohne die Widget-Struktur umbauen zu muessen.
Haeufige Fehler und Stolperfallen
1. Controller nicht disposen
Jeder `AnimationController` muss in `dispose()` aufgeraeumt werden. Ein nicht disposeter Controller haelt seinen `Ticker` am Leben, der Callbacks auf einem Widget feuert, das nicht mehr existiert. Das Ergebnis: der gefuerchtete Fehler "setState called after dispose".
2. Das falsche Mixin verwenden
`SingleTickerProviderStateMixin` unterstuetzt einen Controller. Braucht man zwei oder mehr, muss man zu `TickerProviderStateMixin` wechseln. Die Single-Variante mit mehreren Controllern verursacht einen Laufzeitfehler, der nicht sofort offensichtlich ist.
3. Den gesamten Subtree neu bauen
Das gesamte `Scaffold` in einen `AnimatedBuilder` zu packen bedeutet, dass der komplette Bildschirm 60 Mal pro Sekunde neu gebaut wird. Den animierten Bereich so eng wie moeglich eingrenzen.
4. Layout-Eigenschaften in Listen animieren
Das Animieren der Hoehe von Elementen in einer `ListView` loest bei jedem Frame fuer alle sichtbaren Elemente ein Layout aus. Stattdessen `SizeTransition` oder `AnimatedList` verwenden, die damit effizient umgehen.
5. Einstellungen fuer reduzierte Bewegung ignorieren
Manche Nutzer haben auf Systemebene reduzierte Bewegung aktiviert. Das sollte man respektieren.
final reduceMotion = MediaQuery.of(context).disableAnimations;
final duration = reduceMotion
? Duration.zero
: const Duration(milliseconds: class="code-number">400);6. Dauern ueberall hart kodieren
Animationsdauern sollten als Konstanten an einem zentralen Ort definiert werden. Wenn man das Gefuehl der App anpassen will, aendert man einen Wert statt dutzende Dateien zu durchsuchen.
abstract class AppAnimations {
static const fast = Duration(milliseconds: class="code-number">200);
static const medium = Duration(milliseconds: class="code-number">400);
static const slow = Duration(milliseconds: class="code-number">800);
static const pageTransition = Duration(milliseconds: class="code-number">500);
}Persoenliche Erfahrungen
Bei Apps mit intensiven Animationen habe ich gelernt, dass der groesste Produktivitaetsgewinn darin liegt, eine kleine Bibliothek wiederverwendbarer Animations-Wrapper zu erstellen. Ein `FadeSlideIn`-Widget, das den Controller-Lebenszyklus verwaltet und Parameter wie `delay`, `duration` und `offset` akzeptiert, erspart es einem, immer wieder dasselbe `SingleTickerProviderStateMixin`-Boilerplate zu schreiben.
Ausserdem habe ich auf die harte Tour gelernt, dass Animations-Performanceprobleme auf High-End-Entwicklungsgeraeten fast nie sichtbar werden. Man sollte immer auf dem schwächsten Geraet testen, das die eigenen Nutzer haben koennten. Eine Animation, die auf dem Pixel 8 mit 60fps laeuft, kann auf einem Budget-Handy bei 30fps ruckeln, und genau dann wird die Platzierung von `RepaintBoundary` entscheidend.
Ein letzter Punkt: Kurven sind wichtiger als die Dauer. Eine 400ms-Animation mit `Curves.easeOutCubic` fuehlt sich schneller an als eine 200ms-Animation mit `Curves.linear`. Man sollte Zeit mit dem Experimentieren von Kurven verbringen, bevor man zu kuerzeren Dauern greift.
Fazit
Flutters Animationssystem ist bemerkenswert flexibel, sobald man die Schichten versteht. Mit impliziten Animationen fuer schnelle Erfolge starten, bei Bedarf auf explizite Kontrolle zurueckgreifen und fuer alles, was das Widget-System nicht ausdruecken kann, auf `CustomPainter` hinabsteigen. Kombiniert man das mit disziplinierten Performance-Praktiken, fuehlen sich die eigenen Apps genauso reaktionsschnell an, wie sie aussehen.
Wenn Sie Unterstuetzung beim Design eines ausgefeilten Animationssystems fuer Ihre Flutter-App benoetigen, nehmen Sie gerne Kontakt auf.
Verwandte Artikel
Was ist Flutter? Ein vollständiger Leitfaden für Einsteiger
Erfahren Sie, was Flutter ist, wie es funktioniert und warum moderne Produktteams darauf setzen. Entdecken Sie Dart, Widget-Architektur und plattformübergreifende Entwicklung.
Flutter Performance-Optimierung: Vollständiger Leitfaden
Steigern Sie die Performance Ihrer Flutter-App systematisch. Lernen Sie Rebuild-Optimierung, Speichermanagement, Lazy Loading und Profiling.
Flutter Widget Lifecycle: Von initState bis dispose
Verstehen Sie den StatefulWidget-Lebenszyklus und vermeiden Sie typische Lifecycle-Fehler.
Haben Sie ein Flutter-Projekt?
Ich entwickle hochleistungsfähige Flutter-Anwendungen für iOS, Android und Web.
Kontakt aufnehmen