Flutter: Hero Animasyonları ve Sayfa Geçiş Efektleri ile Kullanıcı Deneyimini Zirveye Taşımak
Hiç bir e-ticaret uygulamasında ürün görseline dokunduğunuzda, o küçük resmin detay sayfasında büyüyerek göründüğünü fark ettiniz mi? Ya da Instagram'da bir fotoğrafa tıkladığınızda nasıl akıcı bir şekilde tam ekran açıldığını? İşte bu sihir, Hero animasyonları ile mümkün.
Flutter'da animasyonlar, kullanıcı deneyimini sıradanlıktan sıra dışılığa taşıyan en güçlü araçlardan biri. Bu yazıda, uygulamalarınıza sinema kalitesinde geçişler kazandıracak teknikleri keşfedeceğiz.
Hero Animasyonu Nedir?
Hero animasyonu, iki sayfa arasında paylaşılan bir widget'ın akıcı bir şekilde geçiş yapmasını sağlar. Widget, ilk sayfadaki konumundan ikinci sayfadaki konumuna sorunsuzca "uçar".
Adını mitolojideki kahramanlardan alır - tıpkı kahramanlar gibi, widget de iki dünya (sayfa) arasında yolculuk yapar.
Neden Hero Kullanmalısınız?
Kullanıcılar sayfalar arası geçişlerde bağlam kaybı yaşar. Hero animasyonları bu sorunu çözer:
- Görsel Süreklilik: Kullanıcı nerede olduğunu her zaman bilir
- Profesyonel Görünüm: Uygulamanız sektör lideri gibi görünür
- Düşük Efor, Yüksek Etki: Birkaç satır kod, muazzam sonuç
Hero'nun Anatomisi
Hero animasyonu için iki şey gerekir:
- Aynı tag: İki sayfadaki Hero widget'ları aynı
tagdeğerine sahip olmalı - Hero widget: Her iki sayfada da widget'ınızı Hero ile sarmalayın
İş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 otomatik animasyon yapar
),
)Tag, animasyonun DNA'sı gibidir. Flutter, aynı tag'e sahip widget'ları bulur ve aralarında animasyon oluşturur.
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({
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', // Aynı 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(
'Ürün açıklaması buraya gelecek...',
style: TextStyle(fontSize: 16),
),
],
),
),
],
),
);
}
}Hero Animasyonunu Özelleştirmek
1. Animasyon Süresini Değiştirmek
Varsayılan süre 300ms. Bunu değiştirmek için transitionDuration kullanın:
Navigator.push(
context,
PageRouteBuilder(
transitionDuration: Duration(milliseconds: 600), // Daha yavaş
pageBuilder: (context, animation, secondaryAnimation) => DetailPage(),
),
);2. Özel FlightShuttleBuilder
"Uçuş" sırasında widget'ın nasıl görüneceğini kontrol edin:
Hero(
tag: 'custom-hero',
flightShuttleBuilder: (flightContext, animation, direction, fromContext, toContext) {
// Animasyon sırasında farklı bir widget göster
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. CreateRectTween ile Yolu Özelleştir
Widget'ın hangi yolu izleyeceğini belirleyin:
Hero(
tag: 'curved-hero',
createRectTween: (begin, end) {
return MaterialRectArcTween(begin: begin, end: end); // Kavisli yol
},
child: Container(width: 100, height: 100, color: Colors.blue),
)Sayfa Geçiş Animasyonları
Hero tek başına harika, ama sayfa geçişini de özelleştirirseniz? İşte burada PageRouteBuilder devreye giriyor.
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;
var tween = Tween(begin: begin, end: end).chain(
CurveTween(curve: curve),
);
var 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
iOS tarzı yumuşak geçiş:
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, 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 - 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
Gerçek sihir, animasyonları kombine ettiğinizde ortaya çıkar:
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: Offset(0.0, 0.3),
end: Offset.zero,
).animate(animation),
child: child,
),
);
},
),
);Custom Transition: "Zoom Fade Slide"
Profesyonel uygulamalarda görülen "zoom + fade + slide" kombinasyonu:
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,
),
),
);
}
}
// 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/yavaşlama davranışını belirler:
// Doğrusal - Mekanik hissi verir
Curves.linear
// Yavaş başla, hızlan, yavaş bitir - En doğal
Curves.easeInOut
// Yumuşak başla
Curves.easeIn
// Yumuşak bitir
Curves.easeOut
// Elastik - Oyunculuk hissi
Curves.elasticOut
// Geri sekme - Dikkat çekici
Curves.bounceOut
// Hız kazanma
Curves.decelerate
// Aşırı gitme
Curves.anticipatePro İpucu: Kullanıcı arayüzleri için Curves.easeInOut veya Curves.easeOut kullanın. Curves.bounceOut gibi aşırı curve'ler dikkat çekici ama yorucu olabilir.
Performans İpuçları
1. Opacity Yerine AnimatedOpacity
// ❌ Kötü - Her frame'de rebuild
Opacity(
opacity: _controller.value,
child: HeavyWidget(),
)
// ✅ İyi - Optimize rebuild
AnimatedOpacity(
opacity: isVisible ? 1.0 : 0.0,
duration: Duration(milliseconds: 300),
child: HeavyWidget(),
)2. Ağır Widget'ları Cache'leyin
Navigator.push(
context,
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) => NextPage(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(
opacity: animation,
child: child, // child zaten cache'lenmiş
);
},
),
);3. RepaintBoundary Kullanın
Animasyon sırasında sadece gerekli kısımlar yeniden çizilsin:
RepaintBoundary(
child: Hero(
tag: 'image',
child: Image.network('...'),
),
)Gerçek Dünya Kullanım Senaryoları
1. Fotoğraf Galerisi
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. 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...
],
),
)3. FAB'den Detay Sayfasına
FloatingActionButton(
heroTag: 'fab', // FAB için özel tag
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => CreatePostPage()),
);
},
child: Hero(
tag: 'create-icon',
child: Icon(Icons.add),
),
)Yaygın Hatalar ve Çözümleri
Hata 1: Duplicate Hero Tag
Problem: Aynı tag'i birden fazla Hero için kullanmak.
// ❌ Yanlış
ListView.builder(
itemBuilder: (context, index) {
return Hero(
tag: 'item', // Hepsi aynı tag!
child: ListTile(title: Text('Item $index')),
);
},
)Çözüm: Unique tag kullanın.
// ✅ Doğru
ListView.builder(
itemBuilder: (context, index) {
return Hero(
tag: 'item-$index', // Her öğe unique
child: ListTile(title: Text('Item $index')),
);
},
)Hata 2: Hero ve Material Widget Uyumsuzluğu
Problem: Hero içinde Material widget kullanırken tuhaf animasyonlar.
Çözüm: Her iki sayfada da Material kullanın veya hiç kullanmayın.
// İlk sayfa
Hero(
tag: 'card',
child: Material(
child: Card(child: ...),
),
)
// İkinci sayfa
Hero(
tag: 'card',
child: Material( // Aynı Material sarmalama
child: Card(child: ...),
),
)Hata 3: Animasyon Çok Hızlı veya Yavaş
Çözüm: Optimal süre 200-400ms arasıdır.
// Çok hızlı: 100ms
// Optimal: 300ms
// Çok yavaş: 800ms+
PageRouteBuilder(
transitionDuration: Duration(milliseconds: 300), // Ideal
...
)Bonus: Reusable Transition Widget'ı
Tüm projenizde kullanabileceğiniz bir yardımcı sınıf:
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 }
// Kullanımı:
Navigator.push(
context,
PageTransitionHelper.createRoute(
NextPage(),
type: PageTransitionType.slide,
),
);Özet
- Hero Animasyonu: Widget'lar sayfalar arası "uçar" (aynı
taggerekli) - SlideTransition: Kaydırma efekti (Offset ile yön kontrolü)
- FadeTransition: Solma efekti (opacity animasyonu)
- ScaleTransition: Ölçekleme efekti (büyüme/küçülme)
- RotationTransition: Dönme efekti
- PageRouteBuilder: Özel geçişler oluşturmak için temel
- Curves: Animasyon karakterini belirler (easeInOut, elasticOut, vb.)
- Kombinasyon: Birden fazla geçiş birleştirilebilir
- Performans: AnimatedOpacity, RepaintBoundary, cache kullanın
- Unique Tags: Her Hero için farklı tag kullanın
Animasyonlar, kullanıcı deneyimini sihirleyen detaylardır. Abartmadan, yerinde kullanıldığında uygulamanızı profesyonel kılar. Şimdi sıra sizde - uygulamalarınıza hayat verin!