Flutter'da Custom Widget Oluşturma ve Composition

Flutter'ın temel felsefesi composition over inheritance: büyük ve karmaşık UI'ları, küçük ve tek sorumlu widget'lardan bir araya getirerek inşa edersiniz. Kendi widget'larınızı oluşturmak, hem kodun yeniden kullanımını artırır hem de testlerini kolaylaştırır.

Yeniden Kullanılabilir StatelessWidget

Durum barındırmayan, yalnızca parametrelerine göre render eden widget'lar için StatelessWidget kullanın. Özellikle const constructor eklemek performans açısından kritiktir — Flutter yeniden build sırasında bu widget'ları atlayabilir:

class PrimaryButton extends StatelessWidget {
  final String label;
  final VoidCallback? onPressed;
  final bool isLoading;

  const PrimaryButton({
    super.key,
    required this.label,
    this.onPressed,
    this.isLoading = false,
  });

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: double.infinity,
      height: 52,
      child: ElevatedButton(
        onPressed: isLoading ? null : onPressed,
        style: ElevatedButton.styleFrom(
          backgroundColor: Theme.of(context).colorScheme.primary,
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(12),
          ),
        ),
        child: isLoading
            ? const SizedBox(
                width: 20,
                height: 20,
                child: CircularProgressIndicator(
                  strokeWidth: 2,
                  color: Colors.white,
                ),
              )
            : Text(label,
                style: const TextStyle(
                  fontSize: 16,
                  fontWeight: FontWeight.w600,
                  color: Colors.white,
                )),
      ),
    );
  }
}

// Kullanım
PrimaryButton(
  label: 'Giriş Yap',
  isLoading: state.isLoading,
  onPressed: () => context.read<AuthCubit>().login(),
)

StatefulWidget: Kendi Durumunu Yöneten Widget

Widget ve State iki ayrı sınıftan oluşur. initState, dispose gibi lifecycle metodları State sınıfında yaşar:

class QuantitySelector extends StatefulWidget {
  final int initial;
  final int min;
  final int max;
  final ValueChanged<int> onChanged;

  const QuantitySelector({
    super.key,
    this.initial = 1,
    this.min = 1,
    this.max = 99,
    required this.onChanged,
  });

  @override
  State<QuantitySelector> createState() => _QuantitySelectorState();
}

class _QuantitySelectorState extends State<QuantitySelector> {
  late int _value;

  @override
  void initState() {
    super.initState();
    _value = widget.initial;
  }

  void _change(int delta) {
    final next = (_value + delta).clamp(widget.min, widget.max);
    if (next == _value) return;
    setState(() => _value = next);
    widget.onChanged(next);
  }

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisSize: MainAxisSize.min,
      children: [
        IconButton(
          icon: const Icon(Icons.remove_circle_outline),
          onPressed: _value > widget.min ? () => _change(-1) : null,
        ),
        AnimatedSwitcher(
          duration: const Duration(milliseconds: 200),
          child: Text(
            '$_value',
            key: ValueKey(_value),
            style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
          ),
        ),
        IconButton(
          icon: const Icon(Icons.add_circle_outline),
          onPressed: _value < widget.max ? () => _change(1) : null,
        ),
      ],
    );
  }
}

Composition: Küçük Parçaları Birleştirmek

Büyük widget'ları küçük parçalara bölün. Her parça tek bir iş yapar ve bağımsız test edilebilir:

// Küçük parçalar
class ProductImageHero extends StatelessWidget {
  final String imageUrl;
  final String tag;
  const ProductImageHero({super.key, required this.imageUrl, required this.tag});

  @override
  Widget build(BuildContext context) => Hero(
    tag: tag,
    child: ClipRRect(
      borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
      child: Image.network(imageUrl, height: 180, width: double.infinity,
          fit: BoxFit.cover),
    ),
  );
}

class PriceTag extends StatelessWidget {
  final double price;
  final double? originalPrice;
  const PriceTag({super.key, required this.price, this.originalPrice});

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        Text('₺${price.toStringAsFixed(2)}',
          style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold,
              color: Colors.green)),
        if (originalPrice != null) ...[
          const SizedBox(width: 8),
          Text('₺${originalPrice!.toStringAsFixed(2)}',
            style: const TextStyle(
              fontSize: 14,
              color: Colors.grey,
              decoration: TextDecoration.lineThrough,
            )),
        ]
      ],
    );
  }
}

// Ana kart — parçaları bir araya getirir
class ProductCard extends StatelessWidget {
  final Product product;
  const ProductCard({super.key, required this.product});

  @override
  Widget build(BuildContext context) {
    return Card(
      clipBehavior: Clip.antiAlias,
      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          ProductImageHero(imageUrl: product.image, tag: 'product-${product.id}'),
          Padding(
            padding: const EdgeInsets.all(12),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(product.title,
                  maxLines: 2,
                  overflow: TextOverflow.ellipsis,
                  style: const TextStyle(fontWeight: FontWeight.w600)),
                const SizedBox(height: 8),
                PriceTag(price: product.price, originalPrice: product.originalPrice),
                const SizedBox(height: 12),
                PrimaryButton(label: 'Sepete Ekle',
                  onPressed: () => context.read<CartCubit>().add(product)),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

InheritedWidget ile Veriyi Ağaçta Paylaşmak

Provider ve Riverpod gibi paketlerin altında InheritedWidget yatar. Doğrudan kullanımı düşük seviyeli ama mekanizmayı anlamak önemlidir:

class AppConfig extends InheritedWidget {
  final String apiBaseUrl;
  final bool isDemoMode;

  const AppConfig({
    super.key,
    required this.apiBaseUrl,
    required this.isDemoMode,
    required super.child,
  });

  static AppConfig of(BuildContext context) {
    final result = context.dependOnInheritedWidgetOfExactType<AppConfig>();
    assert(result != null, 'AppConfig bulunamadı!');
    return result!;
  }

  @override
  bool updateShouldNotify(AppConfig old) =>
      apiBaseUrl != old.apiBaseUrl || isDemoMode != old.isDemoMode;
}

// Widget ağacının tepesine yerleştirme
AppConfig(
  apiBaseUrl: 'https://api.example.com',
  isDemoMode: false,
  child: MaterialApp(...),
)

// Herhangi bir alt widget'tan erişim
final config = AppConfig.of(context);
Text(config.isDemoMode ? 'Demo Modu' : config.apiBaseUrl);

CustomPainter ile Özel Grafik

Standart widget'ların yetmediği durumlarda Canvas API'si ile piksel düzeyinde çizim yapabilirsiniz:

class DonutChartPainter extends CustomPainter {
  final List<MapEntry<String, double>> data;
  final List<Color> colors;

  DonutChartPainter({required this.data, required this.colors});

  @override
  void paint(Canvas canvas, Size size) {
    final total = data.fold(0.0, (sum, e) => sum + e.value);
    final center = Offset(size.width / 2, size.height / 2);
    final radius = size.shortestSide / 2;
    const strokeWidth = 32.0;
    var startAngle = -1.5708; // -π/2

    for (var i = 0; i < data.length; i++) {
      final sweep = (data[i].value / total) * 2 * 3.14159;
      canvas.drawArc(
        Rect.fromCircle(center: center, radius: radius - strokeWidth / 2),
        startAngle,
        sweep - 0.05, // küçük boşluk
        false,
        Paint()
          ..color = colors[i % colors.length]
          ..style = PaintingStyle.stroke
          ..strokeWidth = strokeWidth
          ..strokeCap = StrokeCap.butt,
      );
      startAngle += sweep;
    }
  }

  @override
  bool shouldRepaint(DonutChartPainter old) => data != old.data;
}

// Kullanım
CustomPaint(
  size: const Size(200, 200),
  painter: DonutChartPainter(
    data: [
      MapEntry('Gıda', 35),
      MapEntry('Ulaşım', 20),
      MapEntry('Eğlence', 15),
      MapEntry('Diğer', 30),
    ],
    colors: [Colors.blue, Colors.green, Colors.orange, Colors.purple],
  ),
)

Custom widget tasarımı, Flutter geliştirmenin özüdür. Composition prensibiyle küçük ve test edilebilir parçalar oluşturun. const constructor kullanımını alışkanlık haline getirin. InheritedWidget mekanizmasını anlarsanız, Provider/Riverpod gibi araçları çok daha etkili kullanırsınız.