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

Flutter Hero Animasyonları ve Sayfa Geçiş Efektleri

Ahmet Balaman

Son güncelleme:

9 dk okuma

FlutterAnimationHeroPage TransitionUI/UX
Flutter Hero Animasyonları ve Sayfa Geçiş Efektleri

Bir e-ticaret uygulamasında ürün görseline dokunduğunuzda küçük resmin detay sayfasında büyüyerek yerine oturduğunu fark ettiniz mi? Ya da bir galeride fotoğrafa dokunduğunuzda tam ekrana akıcı biçimde açıldığını? Bu etkinin adı Hero animasyonu.

Bu yazıda Hero'nun iki sayfayı nasıl eşleştirdiğini, uçuşun süresini ve eğrisini neyin belirlediğini, en sık görülen hataları ve PageRouteBuilder ile özel sayfa geçişlerini birlikte ele alıyorum. Değer değiştiğinde kendiliğinden çalışan implicit animasyonları biliyorsanız, Hero bunun sayfa geçişindeki karşılığı.

Hero Animasyonu Nedir?

Hero animasyonu, iki sayfada da bulunan ortak bir widget'ın bir sayfadan diğerine akıcı biçimde geçmesini sağlar. Widget, ilk sayfadaki konumundan ikinci sayfadaki konumuna "uçar".

Adını mitolojideki kahramanlardan alır: kahraman gibi widget da iki dünya (sayfa) arasında yolculuk yapar.

Neden Hero Kullanmalısınız?

Sayfa değiştiğinde kullanıcı neye dokunduğunu ve nereye geldiğini kolayca kaybeder. Hero bu bağlamı korur:

  • Görsel süreklilik: Dokunulan öğe yeni sayfada da aynı öğe olarak görünür.
  • Az kod: İki widget'ı aynı tag ile sarmak yeterli, animasyonu Flutter üretir.

Hero'nun Anatomisi

Hero animasyonu için iki şey gerekir:

  1. Aynı tag: İki sayfadaki Hero widget'ları aynı tag değerine sahip olmalı
  2. Hero widget: Her iki sayfada da widget'ınızı Hero ile sarmalayın

Liste sayfasındaki küçük Hero ile detay sayfasındaki büyük Hero aynı tag değerini taşıdığı için Flutter geçiş animasyonunu kendisi üretir

İşte en basit örnek:

// İlk sayfa
Hero(
  tag: 'profile-photo',
  child: CircleAvatar(
    backgroundImage: NetworkImage('https://picsum.photos/200'),
    radius: 30,
  ),
)

// İkinci sayfa
Hero(
  tag: 'profile-photo', // Aynı tag!
  child: CircleAvatar(
    backgroundImage: NetworkImage('https://picsum.photos/200'),
    radius: 100, // Farklı boyut: Flutter aradaki animasyonu kendisi üretir
  ),
)

Sayfa geçişi başladığında Flutter iki sayfadaki Hero'ları tag'e göre eşleştirir ve eşleşen her çift için bir "uçuş" başlatır. Tag herhangi bir nesne olabilir (String, int ya da kendi id tipiniz), yeter ki iki tarafta == ile eşit olsun.

İki koşul daha var. Birincisi, Hero yalnızca iki PageRoute arasında çalışır: MaterialPageRoute, CupertinoPageRoute ve PageRouteBuilder bu gruptadır, showDialog ve showModalBottomSheet ile açılan rotalar değildir. İkincisi, iki taraftaki child'ların görünüşü benzer olmalı. Flutter dokümantasyonunun ifadesiyle ölçek ve en-boy oranı değişiklikleri Hero'da iyi çalışır, yerleşim ya da içerik değişiklikleri çalışmaz. Küçük bir resmin büyük resme dönüşmesi idealdir; bir resmin bir karta dönüşmesi değildir.

Canlı Demo

Aşağıdaki interaktif örnekte bu widget'ları deneyebilirsiniz:

💡 Yukarıdaki örnek yüklenmiyorsa, yeni sekmede çalıştırmak için DartPad linkine tıklayın.

Gerçek Dünya Örneği: Ürün Kartından Detay Sayfasına

E-ticaret uygulamalarında en yaygın kullanım senaryosu:

class ProductCard extends StatelessWidget {
  final String productId;
  final String imageUrl;
  final String name;

  const ProductCard({
    super.key,
    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', // Benzersiz tag
              child: Image.network(
                imageUrl,
                height: 150,
                width: double.infinity,
                fit: BoxFit.cover,
              ),
            ),
            Padding(
              padding: const EdgeInsets.all(8),
              child: Text(name, style: const TextStyle(fontWeight: FontWeight.bold)),
            ),
          ],
        ),
      ),
    );
  }
}

class ProductDetailPage extends StatelessWidget {
  final String productId;
  final String imageUrl;
  final String name;

  const ProductDetailPage({
    super.key,
    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', // Aynı tag!
            child: Image.network(
              imageUrl,
              height: 300,
              width: double.infinity,
              fit: BoxFit.cover,
            ),
          ),
          Padding(
            padding: const EdgeInsets.all(16),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  name,
                  style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
                ),
                const SizedBox(height: 16),
                const Text(
                  'Ürün açıklaması buraya gelecek...',
                  style: TextStyle(fontSize: 16),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

Tag'e ürün id'sini koymak iki işe yarar: liste sayfasında her kart benzersiz kalır ve detay sayfası hangi kartla eşleşeceğini bilir. Hero yalnızca görseli sarıyor, kartın tamamını değil; görsel her iki sayfada aynı URL'den geliyor. İkisinin de nedenini hatalar bölümünde anlatıyorum.

Hero Uçuşunu Özelleştirmek

1. Süre: Hero'dan Değil, Route'tan Gelir

Hero'nun kendi süre parametresi yoktur. Uçuş, sayfa geçişini yürüten route animasyonuna bağlıdır: açılışta route'un transitionDuration, kapanışta reverseTransitionDuration değeri kullanılır. PageRouteBuilder'da ikisinin de varsayılanı 300 ms'dir:

Navigator.push(
  context,
  PageRouteBuilder(
    transitionDuration: const Duration(milliseconds: 450),
    reverseTransitionDuration: const Duration(milliseconds: 350),
    pageBuilder: (context, animation, secondaryAnimation) => const DetailPage(),
  ),
);

Burada transitionsBuilder verilmediği için sayfa ek bir efekt olmadan açılır; ekrandaki tek hareket Hero'nun uçuşudur. MaterialPageRoute kullanıyorsanız süre, platformun sayfa geçiş stilinden gelir ve Flutter sürümüne göre değişebilir (örneğin iOS stili geçiş 500 ms sürer). Süreyi kendiniz belirlemek istiyorsanız PageRouteBuilder ya da kendi PageRoute sınıfınızı kullanın.

Hangi süreyi seçmeli? Flutter'ın platformlar için hazır sayfa geçişleri 300 ile 500 ms arasında sürer; bu aralık iyi bir başlangıç noktasıdır. Süre çok kısa olursa kullanıcı hareketi takip edemez, çok uzun olursa her dokunuşta bekler. Kararı gerçek cihazda, geçişi birkaç kez tekrarlayarak verin.

2. Eğri: transitionsBuilder'daki Curve Hero'yu Değiştirmez

En sık karıştırılan nokta burası. Aşağıdaki kodda sayfa geçişine Curves.easeOutCubic veriliyor:

Navigator.push(
  context,
  PageRouteBuilder(
    transitionDuration: const Duration(milliseconds: 500),
    pageBuilder: (context, animation, secondaryAnimation) => const DetailPage(),
    transitionsBuilder: (context, animation, secondaryAnimation, child) {
      final curved = CurvedAnimation(
        parent: animation,
        curve: Curves.easeOutCubic,
      );
      return FadeTransition(opacity: curved, child: child);
    },
  ),
);

Bu eğri yalnızca FadeTransition'ı, yani sayfanın kendisinin nasıl belirdiğini etkiler. Hero uçuşu transitionsBuilder'dan geçmez: Flutter uçan widget'ı sayfaların üzerindeki overlay katmanında çizer ve route animasyonunu kendi eğrisiyle sarar. Bu eğri varsayılan olarak Curves.fastOutSlowIn'dir. Yani yukarıdaki örnekte sayfa easeOutCubic ile belirirken Hero 500 ms boyunca yine fastOutSlowIn ile uçar; iki animasyonun ortak noktası yalnızca süredir.

Flutter 3.44 ve sonrasında Hero'nun kendi curve ve reverseCurve parametreleri var:

Hero(
  tag: 'product-$productId',
  curve: Curves.easeOutCubic, // Flutter 3.44+
  child: Image.network(imageUrl, fit: BoxFit.cover),
)

Flutter uçuş için üstteki sayfanın Hero'sunu esas alır: açılışta yeni açılan sayfadaki, kapanışta kapanan sayfadaki Hero. Liste ve detay akışında bu iki durumda da detay sayfasıdır, bu yüzden bu parametreleri detay sayfasındaki Hero'ya verin. reverseCurve verilmezse dönüşte curve.flipped kullanılır, yani dönüş gidişin zamandaki ayna görüntüsü gibi görünür. Daha eski sürümlerde uçuş eğrisi sabit olarak fastOutSlowIn'dir; değiştirebildiğiniz şeyler süre, uçuş yolu ve uçan widget'tır.

3. Uçan Widget: flightShuttleBuilder

Varsayılan olarak uçuş sırasında hedef Hero'nun child'ı gösterilir. İki taraftaki child'lar birbirinden farklı görünüyorsa (listede köşeleri yuvarlatılmış küçük resim, detayda köşesiz büyük resim gibi) uçuş boyunca ikisi arasında yumuşak bir geçiş yapabilirsiniz:

Widget crossFadeShuttle(
  BuildContext flightContext,
  Animation<double> animation,
  HeroFlightDirection direction,
  BuildContext fromContext,
  BuildContext toContext,
) {
  // animation değeri alttaki sayfada 0, üstteki sayfada 1'dir.
  // Açılışta üstteki sayfa "to", kapanışta "from" tarafıdır.
  final isPush = direction == HeroFlightDirection.push;
  final topHero = (isPush ? toContext : fromContext).widget as Hero;
  final bottomHero = (isPush ? fromContext : toContext).widget as Hero;

  return Stack(
    fit: StackFit.expand,
    children: [
      FadeTransition(opacity: ReverseAnimation(animation), child: bottomHero.child),
      FadeTransition(opacity: animation, child: topHero.child),
    ],
  );
}

// İki sayfadaki Hero'ya da aynı builder'ı verin:
Hero(
  tag: 'product-$productId',
  flightShuttleBuilder: crossFadeShuttle,
  child: ClipRRect(
    borderRadius: BorderRadius.circular(12),
    child: Image.network(imageUrl, fit: BoxFit.cover),
  ),
)

Builder'a gelen animation, Hero'nun eğrisi uygulanmış route animasyonudur. Açılışta üstteki sayfa toContext, kapanışta fromContext tarafında kaldığı için direction parametresine bakmak gerekir; bakmazsanız dönüşte geçiş ters çalışır. Builder iki Hero'da da tanımlıysa uçuşun hedefindeki Hero'nunki kullanılır, bu yüzden aynı fonksiyonu iki tarafa da vermek en güvenlisi.

4. Uçuş Yolu: createRectTween

Hero'nun başlangıç ve bitiş dikdörtgeni arasındaki yolu createRectTween belirler. MaterialApp kullanıyorsanız varsayılan yol zaten kavislidir: MaterialApp'in oluşturduğu HeroController, MaterialRectArcTween kullanır. Düz bir çizgi için RectTween, dikdörtgenin merkezinin bir yay üzerinde ilerlemesi için MaterialRectCenterArcTween verin:

Hero(
  tag: 'avatar-${user.id}',
  createRectTween: (begin, end) => MaterialRectCenterArcTween(begin: begin, end: end),
  child: CircleAvatar(backgroundImage: NetworkImage(user.photoUrl)),
)

Flutter uçuşun hedefindeki Hero'nun createRectTween değerini kullanır. Gidiş ve dönüşün aynı yolu izlemesi için aynı fonksiyonu iki taraftaki Hero'ya da verin.

5. iOS'ta Geri Kaydırma Hareketi

Kullanıcı iOS'ta ekranın kenarından kaydırarak geri döndüğünde Hero varsayılan olarak uçmaz. Bunu açmak için iki taraftaki Hero'ya da transitionOnUserGestures: true verin:

Hero(
  tag: 'product-$productId',
  transitionOnUserGestures: true,
  child: Image.network(imageUrl, fit: BoxFit.cover),
)

Değer yalnızca bir tarafta true ise hareketle yapılan geri dönüşte uçuş olmaz.

6. Hero'yu Bir Alanda Kapatmak: HeroMode

HeroMode(enabled: false) ile sarılan alt ağaçtaki Hero'lar route değişiminde uçuşa katılmaz; widget'lar normal şekilde görünmeye devam eder. Kullanışlı bir örnek, sistem ayarlarından animasyonları kapatan kullanıcılara saygı göstermek:

HeroMode(
  enabled: !MediaQuery.disableAnimationsOf(context),
  child: ProductGrid(products: products),
)

Sayfa Geçiş Animasyonları

Hero tek başına yeterli olabilir, ama sayfanın kendisinin nasıl açılacağını da PageRouteBuilder ile belirleyebilirsiniz. Buradaki efektler sayfayı hareket ettirir; Hero ise yukarıda anlattığım gibi bunların üzerinde kendi eğrisiyle uçmaya devam eder. Temel Navigator.push kullanımını tazelemek isterseniz Navigator ile sayfa geçişi yazısına göz atın.

SlideTransition: Kaydırarak Geçiş

Sayfa sağdan sola, soldan sağa, yukarıdan aşağıya kayabilir:

Navigator.push(
  context,
  PageRouteBuilder(
    pageBuilder: (context, animation, secondaryAnimation) => NextPage(),
    transitionsBuilder: (context, animation, secondaryAnimation, child) {
      const begin = Offset(1.0, 0.0); // Sağdan başla
      const end = Offset.zero; // Merkezde bitir
      const curve = Curves.easeInOut;

      final tween = Tween(begin: begin, end: end).chain(
        CurveTween(curve: curve),
      );

      final offsetAnimation = animation.drive(tween);

      return SlideTransition(
        position: offsetAnimation,
        child: child,
      );
    },
  ),
);

Yön Örnekleri:

// Sağdan sola
const begin = Offset(1.0, 0.0);

// Soldan sağa
const begin = Offset(-1.0, 0.0);

// Aşağıdan yukarı
const begin = Offset(0.0, 1.0);

// Yukarıdan aşağı
const begin = Offset(0.0, -1.0);

// Çapraz (sağ alt köşeden)
const begin = Offset(1.0, 1.0);

FadeTransition: Solma Geçişi

Sayfa yerinde, saydamdan görünür hale gelir:

Navigator.push(
  context,
  PageRouteBuilder(
    pageBuilder: (context, animation, secondaryAnimation) => NextPage(),
    transitionsBuilder: (context, animation, secondaryAnimation, child) {
      return FadeTransition(
        opacity: animation,
        child: child,
      );
    },
  ),
);

ScaleTransition: Ölçekleme Geçişi

Sayfa küçükten büyüğe açılır:

Navigator.push(
  context,
  PageRouteBuilder(
    pageBuilder: (context, animation, secondaryAnimation) => NextPage(),
    transitionsBuilder: (context, animation, secondaryAnimation, child) {
      final scaleTween = Tween<double>(begin: 0.0, end: 1.0).chain(
        CurveTween(curve: Curves.elasticOut),
      );

      return ScaleTransition(
        scale: animation.drive(scaleTween),
        child: child,
      );
    },
  ),
);

RotationTransition: Döndürme Geçişi

Sayfa dönerek gelir:

Navigator.push(
  context,
  PageRouteBuilder(
    pageBuilder: (context, animation, secondaryAnimation) => NextPage(),
    transitionsBuilder: (context, animation, secondaryAnimation, child) {
      return RotationTransition(
        turns: animation,
        child: child,
      );
    },
  ),
);

Birden Fazla Animasyonu Birleştirmek

Geçişleri iç içe sararak birleştirebilirsiniz:

Navigator.push(
  context,
  PageRouteBuilder(
    pageBuilder: (context, animation, secondaryAnimation) => NextPage(),
    transitionsBuilder: (context, animation, secondaryAnimation, child) {
      // Hem fade hem slide
      return FadeTransition(
        opacity: animation,
        child: SlideTransition(
          position: Tween<Offset>(
            begin: const Offset(0.0, 0.3),
            end: Offset.zero,
          ).animate(animation),
          child: child,
        ),
      );
    },
  ),
);

Özel Geçiş: "Zoom Fade Slide"

Yakınlaşma, solma ve kaymayı birleştiren, tekrar kullanılabilir bir geçiş widget'ı:

class ZoomFadeSlideTransition extends StatelessWidget {
  final Animation<double> animation;
  final Widget child;

  const ZoomFadeSlideTransition({
    super.key,
    required this.animation,
    required this.child,
  });

  @override
  Widget build(BuildContext context) {
    final curved = CurvedAnimation(parent: animation, curve: Curves.easeOut);
    return FadeTransition(
      opacity: animation,
      child: SlideTransition(
        position: Tween<Offset>(
          begin: const Offset(0, 0.1),
          end: Offset.zero,
        ).animate(curved),
        child: ScaleTransition(
          scale: Tween<double>(begin: 0.9, end: 1.0).animate(curved),
          child: child,
        ),
      ),
    );
  }
}

// Kullanımı:
Navigator.push(
  context,
  PageRouteBuilder(
    pageBuilder: (context, animation, secondaryAnimation) => NextPage(),
    transitionsBuilder: (context, animation, secondaryAnimation, child) {
      return ZoomFadeSlideTransition(
        animation: animation,
        child: child,
      );
    },
  ),
);

Curve'ler ile Animasyon Karakteri

Curve, animasyonun hızlanma ve yavaşlama davranışını belirler. Bu eğrileri CurveTween ya da CurvedAnimation ile sayfa geçişine, Flutter 3.44 ve sonrasında Hero.curve ile uçuşa verebilirsiniz:

// Doğrusal: mekanik bir his verir
Curves.linear

// Yavaş başlar, hızlanır, yavaş biter
Curves.easeInOut

// Yavaş başlar
Curves.easeIn

// Yavaş biter
Curves.easeOut

// Hızlı başlar, hedefe yaklaşırken yavaşlar; Hero uçuşunun varsayılanı
Curves.fastOutSlowIn

// Hızlı başlar, yavaşlayarak durur
Curves.decelerate

// Hedefi biraz aşıp geri döner
Curves.easeOutBack

// Elastik: oyuncu bir his
Curves.elasticOut

// Sekerek durur: dikkat çekici
Curves.bounceOut

İpucu: Arayüz geçişlerinde Curves.easeInOut, Curves.easeOut ya da Curves.fastOutSlowIn gibi sakin eğriler iyi çalışır. Curves.bounceOut gibi abartılı eğriler dikkat çeker ama her geçişte tekrarlandığında yorucu olabilir.

Performans İpuçları

1. Animasyonlu Opaklıkta Opacity Yerine FadeTransition

// ❌ Kötü: controller her karede setState tetikler, alt ağaç yeniden build edilir
Opacity(
  opacity: _controller.value,
  child: HeavyWidget(),
)

// ✅ İyi: FadeTransition yalnızca opaklığı günceller, child yeniden build edilmez
FadeTransition(
  opacity: _controller,
  child: const HeavyWidget(),
)

Bir boolean'a göre açılıp kapanan durumlarda aynı işi AnimatedOpacity görür.

2. transitionsBuilder'da Gelen child'ı Kullanın

pageBuilder sayfayı bir kez üretir; transitionsBuilder ise route'un durumu değiştikçe yeniden çağrılabilir. Ağır içeriği builder içinde yeniden kurmak yerine her zaman gelen child'ı sarın:

Navigator.push(
  context,
  PageRouteBuilder(
    pageBuilder: (context, animation, secondaryAnimation) => NextPage(),
    transitionsBuilder: (context, animation, secondaryAnimation, child) {
      return FadeTransition(
        opacity: animation,
        child: child, // pageBuilder'ın ürettiği sayfa
      );
    },
  ),
);

Gerçek Dünya Kullanım Senaryoları

1. Fotoğraf Galerisi

GridView.builder(
  itemCount: photos.length,
  gridDelegate: const 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. Profil Fotoğrafı Genişletme

// Liste öğesi
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),
)

// Profil sayfası
Scaffold(
  body: Column(
    children: [
      Hero(
        tag: 'avatar-${user.id}',
        child: CircleAvatar(
          backgroundImage: NetworkImage(user.photoUrl),
          radius: 80,
        ),
      ),
      // Diğer bilgiler...
    ],
  ),
)

Yaygın Hatalar ve Çözümleri

Hata 1: Aynı Sayfada Aynı Tag

Belirti: Geçiş sırasında There are multiple heroes that share the same tag within a subtree. hatası. Tag bir route içinde benzersiz olmalı; aynı tag'in iki farklı sayfada bulunması ise zaten eşleşmenin şartıdır. En sık liste içinde görülür:

// ❌ Yanlış
ListView.builder(
  itemBuilder: (context, index) {
    return Hero(
      tag: 'item', // Hepsi aynı tag!
      child: ListTile(title: Text('Item $index')),
    );
  },
)

Çözüm: Her öğeye benzersiz bir tag verin.

// ✅ Doğru
ListView.builder(
  itemBuilder: (context, index) {
    return Hero(
      tag: 'item-$index', // Her öğe benzersiz
      child: ListTile(title: Text('Item $index')),
    );
  },
)

Aynı ürün sayfada iki kez görünüyorsa (hem "Öne çıkanlar" hem "Tüm ürünler" bölümünde gibi) id tek başına yetmez. Tag'e bölümü de ekleyin ve detay sayfasına hangi tag'le açıldığını parametre olarak geçin:

// Liste
Hero(tag: 'featured-${product.id}', child: ProductImage(product))

// Detay sayfasını açarken
ProductDetailPage(product: product, heroTag: 'featured-${product.id}')

Hata 2: Aynı Sayfada Birden Fazla FloatingActionButton

FloatingActionButton kendini zaten bir Hero ile sarar ve heroTag verilmezse bütün FAB'ler aynı varsayılan tag'i paylaşır. Aynı sayfada iki FAB varsa bir önceki hatayı alırsınız. Her birine farklı bir heroTag verin, uçmasına gerek yoksa heroTag: null yapın:

FloatingActionButton(
  heroTag: 'add-note', // Uçmasın istiyorsanız: heroTag: null
  onPressed: () {},
  child: const Icon(Icons.add),
)

Hata 3: Hero İçinde Hero

Belirti: A Hero widget cannot be the descendant of another Hero widget. Bir Hero'nun child'ı içinde başka bir Hero olamaz. FAB kendisi bir Hero olduğu için, FAB'in ikonunu ayrıca Hero ile sarmak da bu hataya yol açar. İki öğeyi ayrı ayrı uçurmak istiyorsanız onları iç içe değil, kardeş olarak yerleştirin.

Hata 4: Uçuşta Metnin Altında Sarı Çift Çizgi

Hero'nun child'ında metin varsa uçuş sırasında kırmızı, iri ve altı sarı çift çizgili bir yazı görebilirsiniz. Uçan widget sayfanın Scaffold'u ve Material'ı içinde değil, Navigator'ın overlay katmanında çizilir; metin orada tema stilini bulamaz ve MaterialApp'in "Material eksik" uyarı stiline düşer. Çözüm, child'ı şeffaf bir Material ile sarmak ve metne stilini açıkça vermek:

Hero(
  tag: 'title-$productId',
  child: Material(
    type: MaterialType.transparency,
    child: Text(
      name,
      style: Theme.of(context).textTheme.titleLarge,
    ),
  ),
)

Aynı sarmalamayı iki taraftaki Hero'da da yapın.

Hata 5: Uçuş Sırasında Taşma

Uçan widget, başlangıç ve bitiş boyutu arasındaki her ara boyutta yeniden yerleştirilir. Child bir Column ya da Row gibi sabit bir düzense ara boyutlardan birine sığmayıp RenderFlex overflowed uyarısı verebilir. Hero'yu kartın tamamına değil, ölçeklenebilen parçasına (genellikle görsele) koyun; kartın geri kalanını detay sayfasında normal şekilde gösterin. Uçuşta gerçekten farklı bir görünüm gerekiyorsa flightShuttleBuilder ile daha basit bir widget verin.

Hata 6: Uçuşta Boş Kutu

Uçuş sırasında varsayılan olarak hedef sayfadaki child gösterilir. Detay sayfası görselin daha büyük bir sürümünü farklı bir URL'den çekiyorsa, o görsel henüz yüklenmediği için uçuşta boş bir kutu görürsünüz. İki sayfada aynı URL'yi kullanırsanız Image.network Flutter'ın görsel önbelleğinden okur ve liste sayfasında yüklenmiş görsel uçuşta hazır olur. Büyük sürüm şartsa uçuşu küçük görselle yapın (örneğin flightShuttleBuilder ile kaynak Hero'nun child'ını göstererek) ve büyük görseli detay sayfasında sonradan yükleyin.

Bonus: Tekrar Kullanılabilir Geçiş Yardımcısı

Projenizin her yerinde kullanabileceğiniz bir yardımcı sınıf:

enum PageTransitionType { fade, slide, scale, rotation }

class PageTransitionHelper {
  static Route<T> createRoute<T>(
    Widget page, {
    PageTransitionType type = PageTransitionType.fade,
    Duration duration = const Duration(milliseconds: 300),
  }) {
    return PageRouteBuilder<T>(
      transitionDuration: duration,
      reverseTransitionDuration: duration,
      pageBuilder: (context, animation, secondaryAnimation) => page,
      transitionsBuilder: (context, animation, secondaryAnimation, child) {
        return switch (type) {
          PageTransitionType.fade => FadeTransition(opacity: animation, child: child),
          PageTransitionType.slide => SlideTransition(
              position: Tween<Offset>(
                begin: const Offset(1.0, 0.0),
                end: Offset.zero,
              ).animate(animation),
              child: child,
            ),
          PageTransitionType.scale => ScaleTransition(
              scale: CurvedAnimation(parent: animation, curve: Curves.easeOut),
              child: child,
            ),
          PageTransitionType.rotation => RotationTransition(turns: animation, child: child),
        };
      },
    );
  }
}

// Kullanımı:
Navigator.push(
  context,
  PageTransitionHelper.createRoute(
    NextPage(),
    type: PageTransitionType.slide,
  ),
);

Enum üzerindeki switch ifadesi tüm durumları kapsadığı için default dalı gerekmez; yeni bir geçiş türü eklediğinizde derleyici eksik dalı size gösterir.

Özet

  • Hero: Aynı tag'e sahip widget'lar iki PageRoute arasında "uçar"; tag bir sayfa içinde benzersiz olmalı.
  • Süre: Route'tan gelir (transitionDuration, reverseTransitionDuration).
  • Eğri: transitionsBuilder'daki curve yalnızca sayfayı etkiler; uçuşun varsayılan eğrisi fastOutSlowIn, Flutter 3.44+ sürümlerde Hero.curve ile değişir.
  • flightShuttleBuilder uçan widget'ı, createRectTween uçuş yolunu değiştirir; ikisini de iki taraftaki Hero'ya verin.
  • transitionOnUserGestures iOS geri kaydırmasında, HeroMode bir alanı devre dışı bırakmada işe yarar.
  • SlideTransition, FadeTransition, ScaleTransition, RotationTransition ve PageRouteBuilder ile sayfa geçişini ayrıca özelleştirebilirsiniz.
  • Performans: Animasyonlu opaklık için FadeTransition, transitionsBuilder'da gelen child.

Sık Sorulan Sorular

Hero animasyonum neden hiç çalışmıyor?

Üç yaygın nedeni var: iki sayfadaki tag değerleri == ile eşit değildir, Hero'lardan biri HeroMode(enabled: false) altında kalmıştır ya da yeni sayfa bir PageRoute değildir. showDialog ve showModalBottomSheet PageRoute kullanmadığı için Hero uçmaz. Diyalog görünümlü bir sayfada Hero istiyorsanız PageRouteBuilder(opaque: false, barrierColor: Colors.black54, ...) ile kendi route'unuzu açın.

Hero geçişinin süresini nasıl değiştiririm?

Süre Hero'nun kendisinden değil, route'tan gelir. PageRouteBuilder kullanıp açılış için transitionDuration, kapanış için reverseTransitionDuration değerini ayarlayın.

transitionsBuilder'a verdiğim curve Hero'yu neden değiştirmiyor?

O eğri yalnızca sayfa geçişini çizen widget'lara uygulanır. Hero uçuşu route animasyonunu kendi eğrisiyle kullanır (varsayılan Curves.fastOutSlowIn). Flutter 3.44 ve sonrasında bunu detay sayfasındaki Hero'ya curve: vererek değiştirebilirsiniz.

ListView içindeki her öğeye Hero koyabilir miyim?

Evet, yeter ki her öğe benzersiz bir tag alsın; örneğin ürün id'sini tag olarak kullanabilirsiniz. Aynı tag aynı sayfada birden fazla Hero'da bulunursa geçiş hata verir.

Hero'yu ne zaman kullanmamalıyım?

Hero, iki sayfadaki öğe aynı nesneyi temsil ettiğinde anlamlıdır. Detay sayfasında o öğe yoksa ya da başka bir anlam taşıyorsa (listedeki bir ikon detayda bir butona dönüşüyorsa gibi) uçuş kullanıcıyı yanıltır; normal bir sayfa geçişi yeterlidir.

Yorumlar