Flutter'da Animasyonlar: AnimationController, Tween ve Hero

Flutter'ın animasyon sistemi iki katmana ayrılır: Explicit (açık) animasyonlar — AnimationController ile her kareyi elle yönettiğiniz; ve Implicit (örtük) animasyonlar — bir değer değiştiğinde Flutter'ın geçişi otomatik yönettiği. Her iki yaklaşımı ve Hero geçişlerini bu yazıda inceleyeceğiz.

AnimationController ve Tween

AnimationController, 0.0 ile 1.0 arasında değişen ham bir sayısal değer üretir. Tween bu değeri istediğiniz tipte bir aralığa dönüştürür. Animasyon kodunuzu doğrudan build'e koyarsanız her frame'de tüm widget ağacı yeniden oluşur. Bunu önlemek için AnimatedBuilder kullanın:

class SlideInCard extends StatefulWidget {
  final Widget child;
  const SlideInCard({super.key, required this.child});

  @override
  State<SlideInCard> createState() => _SlideInCardState();
}

class _SlideInCardState extends State<SlideInCard>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<Offset> _slideAnim;
  late Animation<double> _fadeAnim;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 600),
    );

    _slideAnim = Tween<Offset>(
      begin: const Offset(0, 0.3),
      end: Offset.zero,
    ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic));

    _fadeAnim = Tween<double>(begin: 0, end: 1)
        .animate(CurvedAnimation(parent: _controller, curve: Curves.easeIn));

    _controller.forward();
  }

  @override
  void dispose() {
    _controller.dispose(); // bellek sızıntısını önler
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (context, child) => FadeTransition(
        opacity: _fadeAnim,
        child: SlideTransition(position: _slideAnim, child: child),
      ),
      child: widget.child, // sabit alt widget — her frame'de yeniden oluşmaz
    );
  }
}

Stagger Animasyon: Sıralı Gecikmeli Animasyonlar

Birden fazla öğeyi art arda canlandırmak için tek bir controller üzerinde Interval ile farklı aralıklar tanımlayın:

class StaggeredList extends StatefulWidget {
  final List<Widget> items;
  const StaggeredList({super.key, required this.items});

  @override
  State<StaggeredList> createState() => _StaggeredListState();
}

class _StaggeredListState extends State<StaggeredList>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: Duration(milliseconds: 400 + widget.items.length * 80),
    )..forward();
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  Animation<double> _itemAnim(int index) {
    final start = index * 0.1;
    final end = (start + 0.5).clamp(0.0, 1.0);
    return CurvedAnimation(
      parent: _controller,
      curve: Interval(start, end, curve: Curves.easeOutBack),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        for (var i = 0; i < widget.items.length; i++)
          AnimatedBuilder(
            animation: _itemAnim(i),
            builder: (context, child) => Transform.translate(
              offset: Offset(0, 30 * (1 - _itemAnim(i).value)),
              child: Opacity(
                opacity: _itemAnim(i).value.clamp(0.0, 1.0),
                child: child,
              ),
            ),
            child: widget.items[i],
          ),
      ],
    );
  }
}

Hero Animasyonu: Sayfalar Arası Geçiş

İki sayfada aynı tag'e sahip iki Hero widget'ı varsa Flutter otomatik geçiş animasyonu oluşturur:

// Liste sayfası
GestureDetector(
  onTap: () => Navigator.push(
    context,
    MaterialPageRoute(builder: (_) => ProductDetailPage(product: product)),
  ),
  child: Hero(
    tag: 'product-image-${product.id}',
    child: ClipRRect(
      borderRadius: BorderRadius.circular(12),
      child: Image.network(product.imageUrl, width: 100, height: 100,
          fit: BoxFit.cover),
    ),
  ),
)

// Detay sayfası — aynı tag
Hero(
  tag: 'product-image-${product.id}',
  child: Image.network(product.imageUrl, width: double.infinity, height: 280,
      fit: BoxFit.cover),
)

// Geçişin eğrisini özelleştirme
PageRouteBuilder(
  transitionDuration: const Duration(milliseconds: 500),
  pageBuilder: (_, __, ___) => ProductDetailPage(product: product),
  transitionsBuilder: (_, animation, __, child) =>
      FadeTransition(opacity: animation, child: child),
)

Implicit Animasyonlar: AnimatedContainer ve TweenAnimationBuilder

Durum değişikliklerinde otomatik geçiş için implicit animasyon widget'larını tercih edin. Çok daha az kod gerektirir:

// AnimatedContainer: özellikler değişince otomatik geçiş yapar
class ThemeToggleCard extends StatefulWidget {
  const ThemeToggleCard({super.key});

  @override
  State<ThemeToggleCard> createState() => _ThemeToggleCardState();
}

class _ThemeToggleCardState extends State<ThemeToggleCard> {
  bool _isDark = false;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () => setState(() => _isDark = !_isDark),
      child: AnimatedContainer(
        duration: const Duration(milliseconds: 400),
        curve: Curves.easeInOut,
        width: 200,
        height: 100,
        decoration: BoxDecoration(
          color: _isDark ? Colors.grey[900] : Colors.amber[100],
          borderRadius: BorderRadius.circular(_isDark ? 30 : 8),
          boxShadow: [
            BoxShadow(
              color: _isDark ? Colors.black54 : Colors.orange.withOpacity(0.3),
              blurRadius: _isDark ? 20 : 5,
            ),
          ],
        ),
        child: Center(
          child: AnimatedDefaultTextStyle(
            duration: const Duration(milliseconds: 400),
            style: TextStyle(
              fontSize: _isDark ? 28 : 16,
              color: _isDark ? Colors.white : Colors.orange[800],
            ),
            child: Text(_isDark ? '🌙' : '☀️'),
          ),
        ),
      ),
    );
  }
}

// TweenAnimationBuilder: herhangi bir değeri animasyonla değiştirin
TweenAnimationBuilder<double>(
  tween: Tween(begin: 0, end: progress),
  duration: const Duration(milliseconds: 800),
  curve: Curves.easeOutCubic,
  builder: (context, value, _) => LinearProgressIndicator(value: value),
)

AnimationController Durumlarını Yönetmek

// forward: 0 → 1
// reverse: 1 → 0
// repeat: sürekli döngü
// stop: dur

// Animasyon tamamlanınca callback
_controller.addStatusListener((status) {
  if (status == AnimationStatus.completed) {
    _controller.reverse();
  } else if (status == AnimationStatus.dismissed) {
    _controller.forward();
  }
});

// Pulse efekti için tekrarlayan animasyon
_controller.repeat(reverse: true);

// Belirli bir değere atla
_controller.animateTo(0.5, duration: const Duration(milliseconds: 200));

Flutter animasyon sistemi güçlü ama başlangıçta karmaşık görünebilir. Kuralı şu şekilde belirleyin: değer değişince otomatik geçiş yeterliyse implicit widget kullanın; zamanlama ve sırayı kontrol etmeniz gerekiyorsa explicit AnimationController tercih edin.