İçeriğe geç / Skip to content / Zum Inhalt
Ahmet Balaman LogoAhmet Balaman

Flutter: Bessere Nutzererfahrung mit Hero-Animationen und Seitenübergängen

Ahmet Balaman
FlutterAnimationHeroPage TransitionUI/UX

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:

  1. Denselben Tag: Die Hero-Widgets auf beiden Seiten müssen denselben Wert für tag haben
  2. 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.anticipate

Profi-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!

Kommentare