Flutter: Bessere Nutzererfahrung mit Hero-Animationen und Seitenübergängen
Ist Ihnen schon aufgefallen, wie in einer Shopping-App ein kleines Produktbild beim Antippen geschmeidig wächst und auf der Detailseite erscheint? Oder wie sich ein Foto auf Instagram beim Tippen so flüssig im Vollbild öffnet? Diese Magie wird mit Hero-Animationen möglich.
Animationen gehören in Flutter zu den stärksten Mitteln, um die Nutzererfahrung von gewöhnlich zu außergewöhnlich zu heben. In diesem Artikel sehen wir uns Techniken an, die Übergänge in Kinoqualität in Ihre Anwendungen bringen.
Was ist eine Hero-Animation?
Die Hero-Animation lässt ein Widget, das zwei Seiten gemeinsam haben, geschmeidig übergehen. Das Widget „fliegt“ nahtlos von seiner Position auf der ersten Seite zu seiner Position auf der zweiten.
Der Name stammt von den Helden der Mythologie – wie ein Held reist das Widget zwischen zwei Welten (Seiten).
Warum Hero verwenden?
Bei Seitenwechseln erleben Nutzer einen Kontextverlust. Hero-Animationen lösen dieses Problem:
- Visuelle Kontinuität: Nutzer wissen jederzeit, wo sie sich befinden
- Professioneller Eindruck: Ihre App wirkt wie die eines Marktführers
- Wenig Aufwand, große Wirkung: Ein paar Zeilen Code, beeindruckendes Ergebnis
Der Aufbau von Hero
Für eine Hero-Animation brauchen Sie zwei Dinge:
- Denselben Tag: Die Hero-Widgets auf beiden Seiten müssen denselben Wert für
taghaben - Das Hero-Widget: Umschließen Sie Ihr Widget auf beiden Seiten mit Hero
Hier das einfachste Beispiel:
// First page
Hero(
tag: 'profile-photo',
child: CircleAvatar(
backgroundImage: NetworkImage('https://picsum.photos/200'),
radius: 30,
),
)
// Second page
Hero(
tag: 'profile-photo', // Same tag!
child: CircleAvatar(
backgroundImage: NetworkImage('https://picsum.photos/200'),
radius: 100, // Different size - Flutter automatically animates
),
)Der Tag ist gewissermaßen die DNA der Animation. Flutter findet Widgets mit demselben Tag und erzeugt dazwischen eine Animation.
Live-Demo
Sie können diese Widgets im interaktiven Beispiel unten ausprobieren:
💡 Falls das Beispiel oben nicht lädt, klicken Sie auf DartPad, um es in einem neuen Tab auszuführen.
Praxisbeispiel: von der Produktkarte zur Detailseite
Der häufigste Einsatzfall in Shopping-Apps:
class ProductCard extends StatelessWidget {
final String productId;
final String imageUrl;
final String name;
const ProductCard({
required this.productId,
required this.imageUrl,
required this.name,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ProductDetailPage(
productId: productId,
imageUrl: imageUrl,
name: name,
),
),
);
},
child: Card(
child: Column(
children: [
Hero(
tag: 'product-$productId', // Unique tag
child: Image.network(
imageUrl,
height: 150,
width: double.infinity,
fit: BoxFit.cover,
),
),
Padding(
padding: EdgeInsets.all(8),
child: Text(name, style: TextStyle(fontWeight: FontWeight.bold)),
),
],
),
),
);
}
}
class ProductDetailPage extends StatelessWidget {
final String productId;
final String imageUrl;
final String name;
const ProductDetailPage({
required this.productId,
required this.imageUrl,
required this.name,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(name)),
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Hero(
tag: 'product-$productId', // Same tag!
child: Image.network(
imageUrl,
height: 300,
width: double.infinity,
fit: BoxFit.cover,
),
),
Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
SizedBox(height: 16),
Text(
'Product description goes here...',
style: TextStyle(fontSize: 16),
),
],
),
),
],
),
);
}
}Die Hero-Animation anpassen
1. Die Dauer der Animation ändern
Die Standarddauer beträgt 300 ms. Ändern Sie sie über transitionDuration:
Navigator.push(
context,
PageRouteBuilder(
transitionDuration: Duration(milliseconds: 600), // Slower
pageBuilder: (context, animation, secondaryAnimation) => DetailPage(),
),
);2. Eigener FlightShuttleBuilder
Steuern Sie, wie das Widget während des „Flugs“ aussieht:
Hero(
tag: 'custom-hero',
flightShuttleBuilder: (flightContext, animation, direction, fromContext, toContext) {
// Show different widget during animation
return Material(
color: Colors.transparent,
child: ScaleTransition(
scale: animation.drive(Tween(begin: 0.0, end: 1.0)),
child: Icon(Icons.favorite, size: 100, color: Colors.red),
),
);
},
child: Icon(Icons.favorite_border),
)3. Den Weg mit CreateRectTween anpassen
Legen Sie fest, welchen Weg das Widget nimmt:
Hero(
tag: 'curved-hero',
createRectTween: (begin, end) {
return MaterialRectArcTween(begin: begin, end: end); // Curved path
},
child: Container(width: 100, height: 100, color: Colors.blue),
)Animationen beim Seitenwechsel
Hero allein ist großartig – aber was, wenn Sie zusätzlich den Seitenübergang anpassen? Genau dafür gibt es PageRouteBuilder.
SlideTransition – gleitender Übergang
Seiten können von rechts nach links, von links nach rechts oder von oben nach unten gleiten:
Navigator.push(
context,
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) => NextPage(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
const begin = Offset(1.0, 0.0); // Start from right
const end = Offset.zero; // End at center
const curve = Curves.easeInOut;
var tween = Tween(begin: begin, end: end).chain(
CurveTween(curve: curve),
);
var offsetAnimation = animation.drive(tween);
return SlideTransition(
position: offsetAnimation,
child: child,
);
},
),
);Beispiele für Richtungen:
// Right to left
const begin = Offset(1.0, 0.0);
// Left to right
const begin = Offset(-1.0, 0.0);
// Bottom to top
const begin = Offset(0.0, 1.0);
// Top to bottom
const begin = Offset(0.0, -1.0);
// Diagonal (from bottom right)
const begin = Offset(1.0, 1.0);FadeTransition – Überblenden
Ein weicher Übergang im iOS-Stil:
Navigator.push(
context,
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) => NextPage(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(
opacity: animation,
child: child,
);
},
),
);ScaleTransition – Skalieren
Die Seite öffnet sich von klein nach groß:
Navigator.push(
context,
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation, child) => NextPage(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
var scaleTween = Tween<double>(begin: 0.0, end: 1.0).chain(
CurveTween(curve: Curves.elasticOut),
);
return ScaleTransition(
scale: animation.drive(scaleTween),
child: child,
);
},
),
);RotationTransition – Drehen
Die Seite kommt drehend ins Bild:
Navigator.push(
context,
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) => NextPage(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return RotationTransition(
turns: animation,
child: child,
);
},
),
);Mehrere Animationen kombinieren
Die eigentliche Magie entsteht, wenn Sie Animationen kombinieren:
Navigator.push(
context,
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) => NextPage(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
// Both fade and slide
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween<Offset>(
begin: Offset(0.0, 0.3),
end: Offset.zero,
).animate(animation),
child: child,
),
);
},
),
);Eigener Übergang: „Zoom Fade Slide“
Die Kombination aus Zoom, Überblenden und Gleiten, wie man sie aus professionellen Apps kennt:
class ZoomFadeSlideTransition extends StatelessWidget {
final Animation<double> animation;
final Widget child;
const ZoomFadeSlideTransition({
required this.animation,
required this.child,
});
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween<Offset>(
begin: Offset(0, 0.1),
end: Offset.zero,
).animate(CurvedAnimation(
parent: animation,
curve: Curves.easeOut,
)),
child: ScaleTransition(
scale: Tween<double>(
begin: 0.9,
end: 1.0,
).animate(CurvedAnimation(
parent: animation,
curve: Curves.easeOut,
)),
child: child,
),
),
);
}
}
// Usage:
Navigator.push(
context,
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) => NextPage(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return ZoomFadeSlideTransition(
animation: animation,
child: child,
);
},
),
);Charakter durch Kurven
Die Kurve bestimmt das Beschleunigungs- und Abbremsverhalten der Animation:
// Linear - Gives mechanical feel
Curves.linear
// Slow start, speed up, slow end - Most natural
Curves.easeInOut
// Smooth start
Curves.easeIn
// Smooth end
Curves.easeOut
// Elastic - Playful feel
Curves.elasticOut
// Bounce back - Eye-catching
Curves.bounceOut
// Decelerate
Curves.decelerate
// Overshoot
Curves.anticipateProfi-Tipp: Verwenden Sie für Oberflächen Curves.easeInOut oder Curves.easeOut. Extreme Kurven wie Curves.bounceOut fallen auf, können aber schnell ermüden.
Tipps für die Performance
1. AnimatedOpacity statt Opacity
// ❌ Bad - Rebuilds every frame
Opacity(
opacity: _controller.value,
child: HeavyWidget(),
)
// ✅ Good - Optimized rebuild
AnimatedOpacity(
opacity: isVisible ? 1.0 : 0.0,
duration: Duration(milliseconds: 300),
child: HeavyWidget(),
)2. Schwere Widgets zwischenspeichern
Navigator.push(
context,
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) => NextPage(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(
opacity: animation,
child: child, // child is already cached
);
},
),
);3. RepaintBoundary verwenden
Während der Animation sollten nur die nötigen Teile neu gezeichnet werden:
RepaintBoundary(
child: Hero(
tag: 'image',
child: Image.network('...'),
),
)Einsatzfälle aus der Praxis
1. Fotogalerie
GridView.builder(
itemCount: photos.length,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 4,
mainAxisSpacing: 4,
),
itemBuilder: (context, index) {
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PhotoViewPage(
photoUrl: photos[index],
tag: 'photo-$index',
),
),
);
},
child: Hero(
tag: 'photo-$index',
child: Image.network(
photos[index],
fit: BoxFit.cover,
),
),
);
},
)2. Profilbild vergrößern
// List item
ListTile(
leading: GestureDetector(
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ProfilePage(user: user),
),
),
child: Hero(
tag: 'avatar-${user.id}',
child: CircleAvatar(
backgroundImage: NetworkImage(user.photoUrl),
),
),
),
title: Text(user.name),
)
// Profile page
Scaffold(
body: Column(
children: [
Hero(
tag: 'avatar-${user.id}',
child: CircleAvatar(
backgroundImage: NetworkImage(user.photoUrl),
radius: 80,
),
),
// Other info...
],
),
)3. Vom FAB zur Detailseite
FloatingActionButton(
heroTag: 'fab', // Special tag for FAB
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => CreatePostPage()),
);
},
child: Hero(
tag: 'create-icon',
child: Icon(Icons.add),
),
)Häufige Fehler und ihre Lösungen
Fehler 1: doppelter Hero-Tag
Problem: Derselbe Tag wird für mehrere Heroes verwendet.
// ❌ Wrong
ListView.builder(
itemBuilder: (context, index) {
return Hero(
tag: 'item', // All have same tag!
child: ListTile(title: Text('Item $index')),
);
},
)Lösung: Verwenden Sie eindeutige Tags.
// ✅ Correct
ListView.builder(
itemBuilder: (context, index) {
return Hero(
tag: 'item-$index', // Each item is unique
child: ListTile(title: Text('Item $index')),
);
},
)Fehler 2: Hero und Material-Widget vertragen sich nicht
Problem: Seltsame Animationen, wenn ein Material-Widget innerhalb von Hero verwendet wird.
Lösung: Verwenden Sie Material auf beiden Seiten – oder gar nicht.
// First page
Hero(
tag: 'card',
child: Material(
child: Card(child: ...),
),
)
// Second page
Hero(
tag: 'card',
child: Material( // Same Material wrapping
child: Card(child: ...),
),
)Fehler 3: Animation zu schnell oder zu langsam
Lösung: Die optimale Dauer liegt zwischen 200 und 400 ms.
// Too fast: 100ms
// Optimal: 300ms
// Too slow: 800ms+
PageRouteBuilder(
transitionDuration: Duration(milliseconds: 300), // Ideal
...
)Bonus: ein wiederverwendbares Übergangs-Widget
Eine Hilfsklasse, die Sie im ganzen Projekt nutzen können:
class PageTransitionHelper {
static Route createRoute(Widget page, {PageTransitionType type = PageTransitionType.fade}) {
return PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionsBuilder: (context, animation, secondaryAnimation, child) {
switch (type) {
case PageTransitionType.fade:
return FadeTransition(opacity: animation, child: child);
case PageTransitionType.slide:
return SlideTransition(
position: Tween<Offset>(
begin: Offset(1.0, 0.0),
end: Offset.zero,
).animate(animation),
child: child,
);
case PageTransitionType.scale:
return ScaleTransition(
scale: Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: animation, curve: Curves.easeOut),
),
child: child,
);
case PageTransitionType.rotation:
return RotationTransition(turns: animation, child: child);
default:
return child;
}
},
);
}
}
enum PageTransitionType { fade, slide, scale, rotation }
// Usage:
Navigator.push(
context,
PageTransitionHelper.createRoute(
NextPage(),
type: PageTransitionType.slide,
),
);Zusammenfassung
- Hero-Animation: Widgets „fliegen“ zwischen Seiten (erfordert denselben
tag) - SlideTransition: Gleiteffekt (Richtung über Offset steuern)
- FadeTransition: Überblendeffekt (Animation der Deckkraft)
- ScaleTransition: Skaliereffekt (vergrößern/verkleinern)
- RotationTransition: Dreheffekt
- PageRouteBuilder: Grundlage für eigene Übergänge
- Curves: Bestimmt den Charakter der Animation (easeInOut, elasticOut usw.)
- Kombination: Mehrere Übergänge lassen sich verbinden
- Performance: AnimatedOpacity, RepaintBoundary und Zwischenspeichern nutzen
- Eindeutige Tags: Vergeben Sie für jedes Hero einen eigenen Tag
Animationen sind jene Details, die die Nutzererfahrung verzaubern. Maßvoll und passend eingesetzt lassen sie Ihre App professionell wirken. Jetzt sind Sie dran – erwecken Sie Ihre Apps zum Leben!