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

Flutter: Theme ve ThemeData ile Açık ve Koyu Tema

Ahmet Balaman

7 dk okuma

FlutterThemeDataMaterial 3ColorSchemeDark ModeTextTheme
Flutter: Theme ve ThemeData ile Açık ve Koyu Tema

Bir uygulamada butonun rengini, başlığın yazı boyutunu ya da kartın köşe yuvarlaklığını her widget'ta ayrı ayrı yazarsan, ilk tasarım değişikliğinde onlarca dosyayı elden geçirmen gerekir. Koyu tema eklemek istediğinde de işin içinden çıkamazsın. ThemeData bu kararları tek bir yerde toplar: renkler, yazı stilleri ve bileşenlerin varsayılan görünümü. Widget'lar değerleri sabit yazmak yerine temadan okuduğunda, koyu tema neredeyse kendiliğinden gelir. Bu yazıda Material 3 ile tema kurmayı, açık ve koyu temayı, temadan değer okumayı, bileşen temalarını ve kendi tasarım değerlerini temaya eklemeyi anlatıyorum.

ColorScheme.fromSeed ile Renk Şeması

Material 3'te (Flutter 3.16'dan beri varsayılan) renklerin kaynağı ColorScheme'dır. Her rengi tek tek seçmek yerine bir "tohum" renk verirsin, ColorScheme.fromSeed ondan uyumlu tonlarla bütün şemayı üretir:

import 'package:flutter/material.dart';

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
      ),
      darkTheme: ThemeData(
        colorScheme: ColorScheme.fromSeed(
          seedColor: Colors.indigo,
          brightness: Brightness.dark,
        ),
      ),
      themeMode: ThemeMode.system,
      home: const HomePage(),
    );
  }
}

Aynı tohum renkten brightness: Brightness.dark ile koyu şema üretildiği için iki tema aynı ailenin üyesi gibi görünür. Şemadaki renklerin her birinin bir rolü vardır ve widget'lar bu rollere göre boyanır:

Rol Nerede kullanılır?
primary / onPrimary Ana eylem: dolu buton, seçili durum; on... onun üstündeki yazı ve ikon
primaryContainer / onPrimaryContainer Vurgulu ama daha yumuşak alanlar, öne çıkan kartlar
secondary, tertiary (+ container'ları) İkincil vurgu ve denge renkleri
surface / onSurface Sayfa ve kart zemini, üstündeki ana metin
onSurfaceVariant İkincil metin, açıklamalar
surfaceContainerLow ... surfaceContainerHighest Katman katman yükselen yüzey tonları
outline, outlineVariant Çerçeve ve ayırıcı çizgiler
error / onError Hata durumları

Kural basit: bir rengi zemin olarak kullandıysan, üstüne gelen yazı için onun on... karşılığını seç. Böylece iki temada da kontrast korunur.

Tohum renk doğrudan primary olarak görünmek zorunda değildir; fromSeed onu Material'ın ton sistemine göre yumuşatabilir. Marka rengin birebir görünmeliyse dynamicSchemeVariant: DynamicSchemeVariant.fidelity parametresi paletleri tohum renge daha yakın tutar. Tek bir rolü kesin olarak sabitlemek istiyorsan fromSeed o rolü doğrudan parametre olarak da kabul eder (örneğin primary: brandColor).

ThemeMode: Sistem, Açık, Koyu

MaterialApp'in üç alanı birlikte çalışır: theme açık tema, darkTheme koyu tema, themeMode ise hangisinin kullanılacağıdır. ThemeMode.system cihazın ayarını izler; kullanıcı telefonu koyu moda aldığında uygulama da kendiliğinden geçer. ThemeMode.light ve ThemeMode.dark ise sistemden bağımsız olarak birini zorlar. darkTheme vermezsen themeMode ne olursa olsun theme kullanılır. Kullanıcıya bir seçim sunacaksan genellikle bu üç seçenek birlikte gösterilir; varsayılanı system bırakmak, kullanıcının cihaz genelinde yaptığı tercihe saygı göstermek demektir.

Theme.of(context) ile Temayı Okumak

Temayı tanımlamak işin yarısıdır; asıl kazanç, widget'ların değerleri temadan okumasıyla gelir:

class InfoCard extends StatelessWidget {
  const InfoCard({super.key});

  @override
  Widget build(BuildContext context) {
    final colors = Theme.of(context).colorScheme;
    final text = Theme.of(context).textTheme;

    return Container(
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: colors.primaryContainer,
        borderRadius: BorderRadius.circular(16),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            'Bugünkü hedef',
            style: text.titleMedium?.copyWith(color: colors.onPrimaryContainer),
          ),
          const SizedBox(height: 4),
          Text(
            '3 ders, 45 dakika',
            style: text.bodyMedium?.copyWith(
              color: colors.onPrimaryContainer.withValues(alpha: 0.8),
            ),
          ),
        ],
      ),
    );
  }
}

Bu kartta tek bir sabit renk yok; tema değişince kart da değişir. Flutter 3.27'den beri ColorScheme.of(context) ve TextTheme.of(context) kısayolları da var, aynı değerleri döndürürler. Rengin şeffaflığını değiştirmek için eski withOpacity yerine withValues(alpha: ...) kullanılır.

Theme.of(context) çağıran widget temaya abone olur: tema değiştiğinde Flutter bu widget'ı kendiliğinden yeniden çizer, senin setState çağırmana gerek kalmaz. Arama, ağaçta yukarı doğru en yakın Theme'e kadar gider. Bu sayede uygulamanın yalnızca bir bölümünde temayı değiştirebilirsin:

Theme(
  data: Theme.of(context).copyWith(
    colorScheme: ColorScheme.fromSeed(
      seedColor: Colors.red,
      brightness: Theme.of(context).brightness,
    ),
  ),
  child: FilledButton(onPressed: () {}, child: const Text('Hesabı sil')),
)

TextTheme: Yazı Stilleri

Material 3'te yazı stilleri beş grup ve her grupta üç boyuttan oluşur:

Grup Boyutlar Tipik kullanım
display Large, Medium, Small Çok büyük, kısa metinler (sayaç, karşılama)
headline Large, Medium, Small Sayfa ve bölüm başlıkları
title Large, Medium, Small Kart başlıkları, AppBar, liste başlıkları
body Large, Medium, Small Paragraflar ve genel metin
label Large, Medium, Small Buton yazıları, küçük etiketler

Stilleri Theme.of(context).textTheme.titleLarge gibi okursun. Temada textTheme verdiğinde yalnızca belirttiğin alanlar değişir, geri kalanlar varsayılanlarla birleştirilir. Bütün uygulamanın yazı tipini değiştirmek için ThemeData(fontFamily: 'Inter') yeterlidir; fontu önce pubspec.yaml'da tanımlaman gerekir. Eski sürümlerden kalan headline6, bodyText1 gibi isimler artık yok; bunların karşılıkları titleLarge, bodyLarge gibi yeni adlardır.

Bileşen Temaları: Tek Yerden Varsayılanlar

ThemeData her bileşen için ayrı bir tema alanı taşır: appBarTheme, filledButtonTheme, inputDecorationTheme, cardTheme, snackBarTheme ve daha fazlası. Açık ve koyu temada aynı kararları tekrar yazmamak için temayı bir fonksiyonda üretmek iyi bir alışkanlıktır:

ThemeData buildTheme(Brightness brightness) {
  final colors = ColorScheme.fromSeed(
    seedColor: const Color(0xFF3F51B5),
    brightness: brightness,
  );

  return ThemeData(
    colorScheme: colors,
    textTheme: const TextTheme(
      headlineSmall: TextStyle(fontWeight: FontWeight.w700),
      titleMedium: TextStyle(fontWeight: FontWeight.w600),
    ),
    appBarTheme: const AppBarTheme(centerTitle: false),
    filledButtonTheme: FilledButtonThemeData(
      style: FilledButton.styleFrom(
        minimumSize: const Size.fromHeight(48),
        shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
      ),
    ),
    inputDecorationTheme: const InputDecorationTheme(
      border: OutlineInputBorder(),
      filled: true,
    ),
    cardTheme: CardThemeData(
      elevation: 0,
      color: colors.surfaceContainerLow,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(16),
        side: BorderSide(color: colors.outlineVariant),
      ),
    ),
    snackBarTheme: const SnackBarThemeData(behavior: SnackBarBehavior.floating),
  );
}

Artık uygulamadaki bütün FilledButton'lar tam genişlikte ve 12 piksel yuvarlak köşeli, bütün metin alanları çerçeveli ve dolgulu gelir. Tek bir yerde farklı görünüm gerekiyorsa widget'ın kendi parametresi temayı ezer. AppBar ve NavigationBar yazılarında bu bileşenlerin tema alanlarını ayrıntılı gösterdim.

Neyi Temaya, Neyi Widget'a Yazmalı?

Her değeri temaya taşımak da ayrı bir karmaşa yaratır. Derslerde işe yarayan pratik bir kural var: aynı görsel karar iki ya da daha fazla yerde tekrar ediyorsa temaya, tek bir ekrana özgüyse o widget'ın parametresine yazılır. Buton köşe yuvarlaklığı, metin alanı çerçevesi ve kart zemini uygulamanın her yerinde aynı olmalıdır, bu yüzden temada durur. Karşılama ekranındaki tek seferlik büyük başlığın rengi ise o ekranda kalabilir, yeter ki yine colorScheme içinden bir rol seçilsin. Boşluk ölçüleri (8, 16, 24 gibi) ThemeData'da hazır bir alana sahip değildir; bunları sabitler olarak ya da aşağıdaki ThemeExtension ile tutmak, sayıların kod içine rastgele dağılmasını önler.

Erişilebilirlik: Yüksek Kontrast

ColorScheme.fromSeed, contrastLevel parametresiyle kontrastı ayarlamana izin verir: 0 varsayılandır, Material yönergelerindeki orta ve yüksek kontrast 0.5 ve 1.0 değerlerine karşılık gelir. MaterialApp'in highContrastTheme ve highContrastDarkTheme alanları da, kullanıcı işletim sisteminde kontrastı artırma ayarını açtığında (örneğin iOS'ta) devreye girer. İkisini birleştirmek kolaydır: aynı buildTheme fonksiyonuna bir kontrast parametresi ekleyip yüksek kontrastlı temaları da oradan üretirsin.

Kendi Tasarım Değerlerin: ThemeExtension

ColorScheme'da "başarı" ya da "uyarı" gibi roller yok. Bu tür değerleri sabit renk olarak dağıtmak yerine ThemeExtension ile temaya ekleyebilirsin; böylece onlar da açık ve koyu temaya göre değişir:

@immutable
class StatusColors extends ThemeExtension<StatusColors> {
  const StatusColors({required this.success, required this.warning});

  final Color success;
  final Color warning;

  static const light = StatusColors(
    success: Color(0xFF2E7D32),
    warning: Color(0xFF8D6E00),
  );
  static const dark = StatusColors(
    success: Color(0xFF81C784),
    warning: Color(0xFFFFD54F),
  );

  @override
  StatusColors copyWith({Color? success, Color? warning}) {
    return StatusColors(
      success: success ?? this.success,
      warning: warning ?? this.warning,
    );
  }

  @override
  StatusColors lerp(StatusColors? other, double t) {
    if (other == null) return this;
    return StatusColors(
      success: Color.lerp(success, other.success, t)!,
      warning: Color.lerp(warning, other.warning, t)!,
    );
  }
}

Temaya extensions: [StatusColors.light] (koyu temada StatusColors.dark) olarak eklenir, widget'ta Theme.of(context).extension<StatusColors>()!.success ile okunur. lerp metodu, tema değişirken renklerin yumuşak geçişle değişmesini sağlar.

Mini Senaryo: Tema Seçimi ve Önizleme

Şimdi parçaları birleştirelim: yukarıdaki buildTheme fonksiyonuna extensions satırını ekliyoruz ve kullanıcının Sistem, Açık ve Koyu arasında seçim yaptığı, sonucu anında gördüğü bir ekran kuruyoruz.

// buildTheme içindeki ThemeData'ya eklenen satır:
// extensions: [
//   brightness == Brightness.light ? StatusColors.light : StatusColors.dark,
// ],

void main() => runApp(const ThemeDemoApp());

class ThemeDemoApp extends StatefulWidget {
  const ThemeDemoApp({super.key});

  @override
  State<ThemeDemoApp> createState() => _ThemeDemoAppState();
}

class _ThemeDemoAppState extends State<ThemeDemoApp> {
  final _themeMode = ValueNotifier(ThemeMode.system);

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

  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder<ThemeMode>(
      valueListenable: _themeMode,
      builder: (context, mode, child) => MaterialApp(
        theme: buildTheme(Brightness.light),
        darkTheme: buildTheme(Brightness.dark),
        themeMode: mode,
        home: ThemePreviewPage(themeMode: _themeMode),
      ),
    );
  }
}

class ThemePreviewPage extends StatelessWidget {
  const ThemePreviewPage({super.key, required this.themeMode});

  final ValueNotifier<ThemeMode> themeMode;

  @override
  Widget build(BuildContext context) {
    final colors = Theme.of(context).colorScheme;
    final text = Theme.of(context).textTheme;
    final status = Theme.of(context).extension<StatusColors>()!;

    return Scaffold(
      appBar: AppBar(title: const Text('Görünüm')),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          Text('Tema', style: text.titleMedium),
          const SizedBox(height: 8),
          SegmentedButton<ThemeMode>(
            segments: const [
              ButtonSegment(value: ThemeMode.system, label: Text('Sistem')),
              ButtonSegment(value: ThemeMode.light, label: Text('Açık')),
              ButtonSegment(value: ThemeMode.dark, label: Text('Koyu')),
            ],
            selected: {themeMode.value},
            onSelectionChanged: (selection) => themeMode.value = selection.first,
          ),
          const SizedBox(height: 24),
          Card(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text('Haftalık özet', style: text.headlineSmall),
                  const SizedBox(height: 8),
                  Text(
                    'Bu hafta 5 gün çalıştın.',
                    style: text.bodyMedium?.copyWith(
                      color: colors.onSurfaceVariant,
                    ),
                  ),
                  const SizedBox(height: 8),
                  Text('Hedef tamamlandı', style: TextStyle(color: status.success)),
                ],
              ),
            ),
          ),
          const SizedBox(height: 16),
          const TextField(decoration: InputDecoration(labelText: 'Not ekle')),
          const SizedBox(height: 16),
          FilledButton(onPressed: () {}, child: const Text('Kaydet')),
        ],
      ),
    );
  }
}

Bu ekrandaki hiçbir widget'ta renk ya da köşe yuvarlaklığı yazılı değil. Kart, metin alanı ve buton görünümünü buildTheme'deki bileşen temalarından, yazılar textTheme'den, "Hedef tamamlandı" yazısı StatusColors'tan alıyor. Kullanıcı Koyu'ya bastığında ValueNotifier değişiyor, MaterialApp yeniden kuruluyor ve her şey koyu temaya geçiyor. MaterialApp iki tema arasında kısa bir animasyonla geçer; StatusColors.lerp bu sırada ara renkleri üretir.

Bu örnekte seçim uygulama kapanınca kaybolur. Kalıcı yapmak için ValueNotifier yerine, değeri diske yazan bir controller kullanırsın; aynı yapının ThemeMode'u saklayan tam hâlini SharedPreferences ile yerel veri saklama yazısında anlattım. Tema tercihini birden fazla ekran değiştirecekse Provider ile bir ChangeNotifier'da tutmak da aynı fikrin devamıdır.

Sık Yapılan Hatalar

1. Sabit renkler kullanmak

Belirti: Açık temada güzel görünen ekran, koyu temada bembeyaz kutular ve okunmayan yazılarla dolu. Sebep Colors.white, Colors.black gibi sabit değerler. Zemin için surface ya da bir surfaceContainer tonu, yazı için onSurface kullandığında iki tema da doğru görünür. Card yazısındaki koyu tema hatası da aynı kökten gelir. İkonlar için de aynısı geçerli: Icon'a color: Colors.black87 yazmak yerine rengi hiç vermezsen ikon, içinde bulunduğu bileşenin temasından uygun rengi alır; AppBar'da bir renk, kartın içinde başka bir renk olur.

2. primarySwatch ile renk vermeye çalışmak

Eski örneklerde sık görülen ThemeData(primarySwatch: Colors.green) Material 3'te renk şemasını değiştirmez; butonlar varsayılan mor tonlarında kalır. Material 3'te renkler colorScheme (ya da kısayolu olan colorSchemeSeed) üzerinden verilir.

3. brightness ile colorScheme'ın uyuşmaması

ThemeData(brightness: Brightness.dark, colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo)) şu assertion hatasını verir:

ThemeData.brightness does not match ColorScheme.brightness.

fromSeed varsayılan olarak açık şema üretir. Koyu tema için brightness: Brightness.dark parametresini fromSeed'e ver; ThemeData'ya ayrıca brightness yazmana gerek yok.

4. Theme.of(context)'i MaterialApp'in üstündeki context ile çağırmak

MaterialApp'i döndüren build metodunda Theme.of(context) çağırırsan, o context henüz MaterialApp'in içinde değildir ve varsayılan temayı alırsın. Temaya ihtiyaç duyan kodu home altındaki bir widget'a taşı ya da bir Builder ile yeni bir context al.

5. Kullanımdan kalkmış isimler

ColorScheme'daki background, onBackground ve surfaceVariant kullanımdan kaldırıldı; yerlerine surface, onSurface ve surfaceContainerHighest gelir. withOpacity yerine withValues(alpha: ...), eski TextTheme adları yerine Material 3 adları kullanılır. Analiz uyarılarını görmezden gelme; çoğu doğrudan yeni adı söyler.

Sık Sorulan Sorular

Uygulama koyu modda açılırken neden bir an beyaz ekran görünüyor?

O ekran Flutter'dan önce çizilen native açılış ekranıdır ve ThemeData onu etkilemez. Android'de launch_background çizimi, iOS'ta LaunchScreen.storyboard belirler; koyu mod için Android'de drawable-night klasörüne ayrı bir çizim ekleyebilir ya da bu işi flutter_native_splash gibi bir pakete bırakabilirsin. Kullanıcının tema seçimini diskte tutuyorsan, değeri runApp'ten önce okumak da ilk karede yanlış temanın görünmesini önler.

Sistem teması değişince uygulama kendiliğinden güncellenir mi?

themeMode: ThemeMode.system ise evet. MaterialApp platformun parlaklık ayarını dinler ve kullanıcı cihazı koyu moda aldığında darkTheme'e geçer; senin ayrıca bir şey dinlemene gerek yoktur.

Aynı uygulamada farklı bölümler farklı renkte olabilir mi?

Evet. İlgili alt ağacı Theme(data: Theme.of(context).copyWith(...), child: ...) ile sarman yeterli. İçerideki widget'lar Theme.of(context) ile bu yeni temayı görür, dışarısı etkilenmez. Tehlikeli işlem butonlarını kırmızı bir şemayla göstermek bunun tipik kullanımıdır.

Yorumlar