Flutter: Maximale Wirkung ohne Aufwand mit impliziten Animationen
Denken Sie bei Animationen an komplexe Formeln, AnimationController und seitenweise Code? Was, wenn ich Ihnen sage, dass sich Ihre Widgets schon durch eine einzige Änderung geschmeidig animieren lassen?
Genau das leisten die impliziten Animationen von Flutter. Wenn Sie setState aufrufen, wechselt Ihr Widget automatisch vom alten zum neuen Wert. Magie? Nein, Flutter-Ingenieurskunst.
Explizit oder implizit: Wo liegt der Unterschied?
Es gibt zwei Arten von Animationen:
Explizite Animationen
- Erfordern einen AnimationController
- Werden eingesetzt, wenn Sie volle Kontrolle wollen
- Mehr Code, mehr Flexibilität
- Beispiel: komplexe, verkettete Animationen
Implizite Animationen
- Kein Controller nötig
- Für 90 % der Fälle ausreichend
- Weniger Code, tolle Ergebnisse
- Beispiel: Buttonfarbe, Größenänderung, Positionswechsel
Goldene Regel: Wenn Sie die Animation nicht von Anfang bis Ende steuern müssen, nehmen Sie implizite Animationen.
Live-Demo
Sie können dieses Widget im interaktiven Beispiel unten ausprobieren:
💡 Falls das Beispiel oben nicht lädt, klicken Sie auf DartPad, um es in einem neuen Tab auszuführen.
AnimatedContainer: das Schweizer Taschenmesser
AnimatedContainer ist die animierte Variante des Container. Sobald Sie eine Eigenschaft ändern, animiert er automatisch.
Grundlegende Verwendung
class AnimatedBoxDemo extends StatefulWidget {
@override
_AnimatedBoxDemoState createState() => _AnimatedBoxDemoState();
}
class _AnimatedBoxDemoState extends State<AnimatedBoxDemo> {
bool _isExpanded = false;
@override
Widget build(BuildContext context) {
return Center(
child: GestureDetector(
onTap: () {
setState(() {
_isExpanded = !_isExpanded;
});
},
child: AnimatedContainer(
duration: Duration(milliseconds: 300),
width: _isExpanded ? 200 : 100,
height: _isExpanded ? 200 : 100,
decoration: BoxDecoration(
color: _isExpanded ? Colors.blue : Colors.red,
borderRadius: BorderRadius.circular(_isExpanded ? 50 : 10),
),
child: Center(
child: Text(
'Tap Me',
style: TextStyle(color: Colors.white),
),
),
),
),
);
}
}Ein einziger setState-Aufruf animiert drei Eigenschaften gleichzeitig:
- Breite und Höhe
- Farbe
- Eckenradius
Was kann AnimatedContainer?
AnimatedContainer kann alle Eigenschaften des Container animieren:
1. Größenanimation
AnimatedContainer(
duration: Duration(seconds: 1),
width: isLarge ? 300 : 100,
height: isLarge ? 300 : 100,
child: FlutterLogo(),
)2. Farbübergang
AnimatedContainer(
duration: Duration(milliseconds: 500),
color: isActive ? Colors.green : Colors.grey,
child: Text('Status'),
)3. Padding-Animation
AnimatedContainer(
duration: Duration(milliseconds: 300),
padding: EdgeInsets.all(isExpanded ? 32 : 8),
child: Icon(Icons.star),
)4. Gradient-Animation
AnimatedContainer(
duration: Duration(milliseconds: 800),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: isDarkMode
? [Colors.black, Colors.grey[900]!]
: [Colors.blue, Colors.purple],
),
),
)5. Transform (Drehen/Skalieren)
AnimatedContainer(
duration: Duration(milliseconds: 400),
transform: Matrix4.rotationZ(isRotated ? 3.14 : 0),
child: Icon(Icons.refresh),
)Weitere implizite Animations-Widgets
Flutter bietet für unterschiedliche Anforderungen spezialisierte implizite Animations-Widgets:
AnimatedOpacity – Übergänge der Sichtbarkeit
Perfekt für „Fade-in“-Effekte wie auf Websites:
class FadeInDemo extends StatefulWidget {
@override
_FadeInDemoState createState() => _FadeInDemoState();
}
class _FadeInDemoState extends State<FadeInDemo> {
bool _isVisible = false;
@override
void initState() {
super.initState();
// Show after 1 second
Future.delayed(Duration(seconds: 1), () {
setState(() => _isVisible = true);
});
}
@override
Widget build(BuildContext context) {
return AnimatedOpacity(
opacity: _isVisible ? 1.0 : 0.0,
duration: Duration(milliseconds: 600),
child: Text(
'Hello World!',
style: TextStyle(fontSize: 32),
),
);
}
}Einsatzfälle:
- Hinweismeldungen
- Inhalte nach dem Laden einblenden
- Hover-Effekte
- Onboarding-Screens
AnimatedPositioned – Positionswechsel im Stack
class SlidingMenuDemo extends StatefulWidget {
@override
_SlidingMenuDemoState createState() => _SlidingMenuDemoState();
}
class _SlidingMenuDemoState extends State<SlidingMenuDemo> {
bool _isMenuOpen = false;
@override
Widget build(BuildContext context) {
return Stack(
children: [
// Main content
Container(color: Colors.white),
// Sliding menu
AnimatedPositioned(
duration: Duration(milliseconds: 300),
curve: Curves.easeInOut,
left: _isMenuOpen ? 0 : -250,
top: 0,
bottom: 0,
width: 250,
child: Container(
color: Colors.blue,
child: ListView(
children: [
ListTile(title: Text('Menu 1', style: TextStyle(color: Colors.white))),
ListTile(title: Text('Menu 2', style: TextStyle(color: Colors.white))),
ListTile(title: Text('Menu 3', style: TextStyle(color: Colors.white))),
],
),
),
),
// Hamburger button
Positioned(
top: 50,
left: 20,
child: IconButton(
icon: Icon(Icons.menu),
onPressed: () {
setState(() => _isMenuOpen = !_isMenuOpen);
},
),
),
],
);
}
}Einsatzfälle:
- Drawer-Menüs
- Position des Floating Action Button
- Spielfiguren
- Parallax-Effekte
AnimatedAlign – Übergänge der Ausrichtung
Widgets an eine andere Position innerhalb des Elternteils bewegen:
class BouncingBallDemo extends StatefulWidget {
@override
_BouncingBallDemoState createState() => _BouncingBallDemoState();
}
class _BouncingBallDemoState extends State<BouncingBallDemo> {
Alignment _alignment = Alignment.topLeft;
void _moveBall() {
setState(() {
_alignment = _alignment == Alignment.topLeft
? Alignment.bottomRight
: Alignment.topLeft;
});
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _moveBall,
child: Container(
width: 300,
height: 300,
color: Colors.grey[200],
child: AnimatedAlign(
alignment: _alignment,
duration: Duration(seconds: 1),
curve: Curves.bounceOut,
child: Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
),
),
),
),
);
}
}AnimatedPadding – Animation der Abstände
AnimatedPadding(
duration: Duration(milliseconds: 300),
padding: EdgeInsets.all(isSelected ? 20 : 8),
child: Card(
child: Text('Selected Card'),
),
)AnimatedDefaultTextStyle – Übergänge des Textstils
class TextStyleDemo extends StatefulWidget {
@override
_TextStyleDemoState createState() => _TextStyleDemoState();
}
class _TextStyleDemoState extends State<TextStyleDemo> {
bool _isLarge = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => setState(() => _isLarge = !_isLarge),
child: AnimatedDefaultTextStyle(
duration: Duration(milliseconds: 300),
style: TextStyle(
fontSize: _isLarge ? 48 : 24,
color: _isLarge ? Colors.red : Colors.blue,
fontWeight: _isLarge ? FontWeight.bold : FontWeight.normal,
),
child: Text('Touch Me'),
),
);
}
}AnimatedCrossFade – Übergang zwischen zwei Widgets
Wechselt geschmeidig zwischen zwei Widgets:
class ToggleViewDemo extends StatefulWidget {
@override
_ToggleViewDemoState createState() => _ToggleViewDemoState();
}
class _ToggleViewDemoState extends State<ToggleViewDemo> {
bool _showFirst = true;
@override
Widget build(BuildContext context) {
return Column(
children: [
AnimatedCrossFade(
firstChild: Container(
width: 200,
height: 200,
color: Colors.blue,
child: Center(child: Text('First Widget', style: TextStyle(color: Colors.white))),
),
secondChild: Container(
width: 200,
height: 200,
color: Colors.red,
child: Center(child: Text('Second Widget', style: TextStyle(color: Colors.white))),
),
crossFadeState: _showFirst
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
duration: Duration(milliseconds: 500),
),
SizedBox(height: 20),
ElevatedButton(
onPressed: () => setState(() => _showFirst = !_showFirst),
child: Text('Switch'),
),
],
);
}
}Einsatzfälle:
- Zwischen Listen- und Rasteransicht umschalten
- Leeren Zustand ein- und ausblenden
- Wechsel zwischen Login- und Registrierungsformular
- Spielmodus wechseln
AnimatedSwitcher – beliebiger Widget-Wechsel
Flexibler als AnimatedCrossFade:
class CounterWithAnimation extends StatefulWidget {
@override
_CounterWithAnimationState createState() => _CounterWithAnimationState();
}
class _CounterWithAnimationState extends State<CounterWithAnimation> {
int _count = 0;
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedSwitcher(
duration: Duration(milliseconds: 300),
transitionBuilder: (child, animation) {
return ScaleTransition(scale: animation, child: child);
},
child: Text(
'$_count',
key: ValueKey<int>(_count), // Important: Key required!
style: TextStyle(fontSize: 72, fontWeight: FontWeight.bold),
),
),
SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: Icon(Icons.remove),
onPressed: () => setState(() => _count--),
),
SizedBox(width: 20),
IconButton(
icon: Icon(Icons.add),
onPressed: () => setState(() => _count++),
),
],
),
],
);
}
}Profi-Tipp: Der Parameter key ist bei AnimatedSwitcher erforderlich. Flutter erkennt den Widget-Wechsel anhand des Keys.
Duration und Curve: die Seele der Animation
Duration
Legt fest, wie lange die Animation dauert:
// Very fast - Change without drawing attention
Duration(milliseconds: 150)
// Standard - Ideal for most uses
Duration(milliseconds: 300)
// Slow - To draw attention
Duration(milliseconds: 600)
// Very slow - Dramatic effects
Duration(seconds: 1)Tipp zur Nutzererfahrung:
- Kleine Änderungen: 150–200 ms
- Mittlere Änderungen: 300–400 ms
- Große Änderungen: 500–800 ms
- Länger als eine Sekunde: lässt Nutzer warten
Curve
Das Beschleunigungs- und Abbremsverhalten der Animation:
// Linear - Constant speed (generally not used)
curve: Curves.linear
// Slow start, speed up, slow end - Most natural
curve: Curves.easeInOut
// Slow start
curve: Curves.easeIn
// Slow end
curve: Curves.easeOut
// Elastic - Bounce back feel
curve: Curves.elasticOut
// Bounce - Bouncing effect
curve: Curves.bounceOut
// Fast start, slow end
curve: Curves.decelerate
// Overshoot and return
curve: Curves.anticipateKurven im Vergleich
class CurveComparison extends StatefulWidget {
@override
_CurveComparisonState createState() => _CurveComparisonState();
}
class _CurveComparisonState extends State<CurveComparison> {
bool _expanded = false;
Widget _buildAnimatedBox(String label, Curve curve) {
return Column(
children: [
Text(label, style: TextStyle(fontSize: 12)),
AnimatedContainer(
duration: Duration(seconds: 1),
curve: curve,
width: _expanded ? 200 : 50,
height: 50,
color: Colors.blue,
),
],
);
}
@override
Widget build(BuildContext context) {
return Column(
children: [
_buildAnimatedBox('linear', Curves.linear),
SizedBox(height: 10),
_buildAnimatedBox('easeInOut', Curves.easeInOut),
SizedBox(height: 10),
_buildAnimatedBox('bounceOut', Curves.bounceOut),
SizedBox(height: 10),
_buildAnimatedBox('elasticOut', Curves.elasticOut),
SizedBox(height: 20),
ElevatedButton(
onPressed: () => setState(() => _expanded = !_expanded),
child: Text('Start Animations'),
),
],
);
}
}Einsatzfälle aus der Praxis
1. Ausklappbare Karte
class ExpandableCard extends StatefulWidget {
final String title;
final String content;
ExpandableCard({required this.title, required this.content});
@override
_ExpandableCardState createState() => _ExpandableCardState();
}
class _ExpandableCardState extends State<ExpandableCard> {
bool _isExpanded = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => setState(() => _isExpanded = !_isExpanded),
child: AnimatedContainer(
duration: Duration(milliseconds: 300),
curve: Curves.easeInOut,
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black12,
blurRadius: _isExpanded ? 10 : 5,
spreadRadius: _isExpanded ? 2 : 0,
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
widget.title,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
AnimatedRotation(
duration: Duration(milliseconds: 300),
turns: _isExpanded ? 0.5 : 0,
child: Icon(Icons.expand_more),
),
],
),
AnimatedCrossFade(
firstChild: SizedBox.shrink(),
secondChild: Padding(
padding: EdgeInsets.only(top: 12),
child: Text(widget.content),
),
crossFadeState: _isExpanded
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
duration: Duration(milliseconds: 300),
),
],
),
),
);
}
}2. Animation eines Like-Buttons
class LikeButton extends StatefulWidget {
@override
_LikeButtonState createState() => _LikeButtonState();
}
class _LikeButtonState extends State<LikeButton> {
bool _isLiked = false;
int _likeCount = 42;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
setState(() {
_isLiked = !_isLiked;
_likeCount += _isLiked ? 1 : -1;
});
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedContainer(
duration: Duration(milliseconds: 200),
curve: Curves.easeInOut,
padding: EdgeInsets.all(8),
decoration: BoxDecoration(
color: _isLiked ? Colors.red.withOpacity(0.2) : Colors.transparent,
shape: BoxShape.circle,
),
child: AnimatedSwitcher(
duration: Duration(milliseconds: 200),
transitionBuilder: (child, animation) {
return ScaleTransition(scale: animation, child: child);
},
child: Icon(
_isLiked ? Icons.favorite : Icons.favorite_border,
key: ValueKey<bool>(_isLiked),
color: _isLiked ? Colors.red : Colors.grey,
size: 28,
),
),
),
SizedBox(width: 4),
AnimatedDefaultTextStyle(
duration: Duration(milliseconds: 200),
style: TextStyle(
color: _isLiked ? Colors.red : Colors.grey,
fontWeight: _isLiked ? FontWeight.bold : FontWeight.normal,
fontSize: 16,
),
child: Text('$_likeCount'),
),
],
),
);
}
}3. Platzhalter beim Laden (Skeleton Screen)
class ShimmerLoading extends StatefulWidget {
@override
_ShimmerLoadingState createState() => _ShimmerLoadingState();
}
class _ShimmerLoadingState extends State<ShimmerLoading> {
bool _isLoading = true;
@override
void initState() {
super.initState();
// Show content after 3 seconds
Future.delayed(Duration(seconds: 3), () {
setState(() => _isLoading = false);
});
}
@override
Widget build(BuildContext context) {
return AnimatedCrossFade(
firstChild: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: double.infinity,
height: 200,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(12),
),
),
SizedBox(height: 12),
Container(
width: 200,
height: 20,
color: Colors.grey[300],
),
SizedBox(height: 8),
Container(
width: 150,
height: 20,
color: Colors.grey[300],
),
],
),
secondChild: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Image.network(
'https://picsum.photos/400/200',
height: 200,
width: double.infinity,
fit: BoxFit.cover,
),
SizedBox(height: 12),
Text('Title Goes Here', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
SizedBox(height: 8),
Text('Subtitle goes here', style: TextStyle(color: Colors.grey)),
],
),
crossFadeState: _isLoading
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
duration: Duration(milliseconds: 500),
);
}
}4. Floating Action Button verwandeln
class MorphingFAB extends StatefulWidget {
@override
_MorphingFABState createState() => _MorphingFABState();
}
class _MorphingFABState extends State<MorphingFAB> {
bool _isExpanded = false;
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: Duration(milliseconds: 300),
curve: Curves.easeInOut,
width: _isExpanded ? 200 : 56,
height: 56,
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(28),
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(28),
onTap: () => setState(() => _isExpanded = !_isExpanded),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.add, color: Colors.white),
if (_isExpanded) ...[
SizedBox(width: 12),
Text(
'Add New',
style: TextStyle(color: Colors.white, fontSize: 16),
),
],
],
),
),
),
),
);
}
}Tipps für die Performance
1. Verwenden Sie const-Konstruktoren
// ❌ Bad - New instance every rebuild
AnimatedContainer(
duration: Duration(milliseconds: 300),
child: Text('Static Text'),
)
// ✅ Good - Const child is cached
AnimatedContainer(
duration: Duration(milliseconds: 300),
child: const Text('Static Text'),
)2. Nutzen Sie AnimatedWidget nicht ohne Grund
// ❌ Bad - Container is enough if only color changes
AnimatedContainer(
duration: Duration(milliseconds: 300),
width: 100, // Never changes
height: 100, // Never changes
color: selectedColor,
)
// ✅ Good - Only animate what's needed
Container(
width: 100,
height: 100,
child: AnimatedContainer(
duration: Duration(milliseconds: 300),
color: selectedColor,
),
)3. Optimieren Sie mit RepaintBoundary
RepaintBoundary(
child: AnimatedOpacity(
opacity: isVisible ? 1.0 : 0.0,
duration: Duration(milliseconds: 300),
child: HeavyWidget(),
),
)4. Animieren Sie nicht zu vieles gleichzeitig
// ❌ Bad - 100 widgets animating at once
ListView.builder(
itemCount: 100,
itemBuilder: (context, index) {
return AnimatedContainer(
duration: Duration(seconds: 1),
height: heights[index],
child: ListTile(title: Text('Item $index')),
);
},
)
// ✅ Good - Only visible ones should animateHäufige Fehler und ihre Lösungen
Fehler 1: Key beim AnimatedSwitcher vergessen
// ❌ Wrong - Animation won't work
AnimatedSwitcher(
duration: Duration(milliseconds: 300),
child: Text('$count'), // No key!
)
// ✅ Correct
AnimatedSwitcher(
duration: Duration(milliseconds: 300),
child: Text('$count', key: ValueKey(count)),
)Fehler 2: zu lange Dauer
// ❌ User waits 3 seconds
AnimatedContainer(
duration: Duration(seconds: 3),
color: newColor,
)
// ✅ Fast and responsive
AnimatedContainer(
duration: Duration(milliseconds: 300),
color: newColor,
)Fehler 3: falsche Wahl der Kurve
// ❌ Linear looks too mechanical
AnimatedContainer(
duration: Duration(milliseconds: 300),
curve: Curves.linear,
width: newWidth,
)
// ✅ Natural looking
AnimatedContainer(
duration: Duration(milliseconds: 300),
curve: Curves.easeInOut,
width: newWidth,
)Alle impliziten Animations-Widgets
Flutter bietet über 15 implizite Animations-Widgets:
| Widget | Was es animiert |
|---|---|
| AnimatedContainer | Alle Eigenschaften des Container |
| AnimatedOpacity | Deckkraft (Transparenz) |
| AnimatedPadding | Padding (Abstände) |
| AnimatedPositioned | Position im Stack |
| AnimatedAlign | Ausrichtung |
| AnimatedDefaultTextStyle | Textstil |
| AnimatedPhysicalModel | Schatten und Elevation |
| AnimatedCrossFade | Übergang zwischen zwei Widgets |
| AnimatedSwitcher | Beliebiger Widget-Wechsel |
| AnimatedSize | Größe des Widgets |
| AnimatedRotation | Drehwinkel |
| AnimatedScale | Skalierung |
| AnimatedSlide | Verschiebung (Offset) |
| AnimatedFractionallySizedBox | Größe relativ zum Elternteil |
| AnimatedTheme | Theme-Änderungen |
Zusammenfassung
- Implizite Animationen: Animation ohne Zusatzcode (setState genügt)
- AnimatedContainer: Das vielseitigste implizite Animations-Widget
- Duration: Dauer der Animation (ideal: 200–400 ms)
- Curve: Beschleunigungs- und Abbremsverhalten (ideal: easeInOut)
- AnimatedOpacity: Für Ein- und Ausblendeffekte
- AnimatedPositioned: Positionswechsel im Stack
- AnimatedCrossFade: Übergang zwischen zwei Widgets
- AnimatedSwitcher: Beliebiger Widget-Wechsel (Key erforderlich!)
- Performance: const, RepaintBoundary, keine unnötigen Animationen
- UX: Kurze Dauer, natürliche Kurven, gezielter Einsatz
Implizite Animationen gehören zu den stärksten Funktionen von Flutter. Sie erzielen mit minimalem Code maximale Wirkung. Die Kombination aus setState und einem Animated*-Widget ist alles, was Sie brauchen, um Ihre App lebendig zu machen!