VodiSoft
Renkleri kontrollerin içine dağıtmak yerine bir tema sistemi kuruyoruz: tasarım token'ları, Style<T> ile devralınabilir stiller, visual state'lerle etkileşim durumları, OnLight/OnDark ile otomatik koyu tema ve DynamicResource ile kullanıcının anında değiştirebildiği vurgu rengi. Hepsi C# markup ile, tek satır XAML olmadan.

Her uygulama aynı yerden başlar: bir butona BackgroundColor(Colors.Blue) yazarsınız. Sonra ikinci butona. Sonra koyu tema isteği gelir ve o mavinin geçtiği 40 yeri tek tek bulmanız gerekir. Ardından “vurgu rengini kullanıcı seçsin” denir ve iş iyice çıkmaza girer.

Çözüm, rengi kontrolün içine yazmayı bırakmak. Bu yazıda üç katmanlı bir tema sistemi kuruyoruz:

  1. Tasarım token'ları — renklerin tek kaynağı
  2. Style<T> — devralınabilir, tipli stiller
  3. Tema stratejileriOnLight/OnDark (işletim sistemi), DynamicResource (kullanıcı)

Sonunda kullanıcının temayı ve vurgu rengini anında değiştirebildiği, seçimini bir sonraki açılışta hatırlayan bir ekran çıkacak. Kod tamamen C# markup; kullanılan kütüphane FmgLib.MauiMarkup 10.2.1.

Katman 1 — Tasarım token'ları

En sıkıcı ama en çok işe yarayan adım: renklere isim verin ve tek bir yerde tutun.

public static class AppColors
{
    public static readonly Color Primary = "#512BD4".ToColor();
    public static readonly Color Teal    = "#0F766E".ToColor();
    public static readonly Color Amber   = "#B45309".ToColor();
    public static readonly Color Danger  = "#DC2626".ToColor();

    public static readonly Color Text     = "#111827".ToColor();
    public static readonly Color TextDark = "#F3F4F6".ToColor();
    public static readonly Color Muted    = "#6B7280".ToColor();

    public static readonly Color Surface     = "#FFFFFF".ToColor();
    public static readonly Color SurfaceDark = "#1C1C1E".ToColor();
    public static readonly Color Border      = "#E5E7EB".ToColor();
    public static readonly Color BorderDark  = "#374151".ToColor();
    public static readonly Color Gray200     = "#E5E7EB".ToColor();
    public static readonly Color Gray600     = "#4B5563".ToColor();

    public static readonly Color PageBackground     = "#F9FAFB".ToColor();
    public static readonly Color PageBackgroundDark = "#0B0B0C".ToColor();
    public static readonly Color FieldFocus         = "#EEF2FF".ToColor();
    public static readonly Color FieldFocusDark     = "#312E81".ToColor();
}

"#512BD4".ToColor(), FmgLib.MauiMarkup'ın string uzantısı. Arkasında Color.Parse var; hem hex hem de "Rebeccapurple" gibi adlandırılmış renkleri çözer. Biçimi açıkça belirtmek isterseniz ToColorFromArgb() ve ToColorFromRgba() de mevcut. Uzantı FmgLib.MauiMarkup.Core namespace'inde; şablonla oluşturulan projelerde Imports.cs bunu zaten global olarak import ediyor.

XAML'deki Colors.xaml ile aynı iş, ama IntelliSense çalışıyor, yazım hatası derleme hatası oluyor ve “bu rengi kim kullanıyor?” sorusunun cevabı bir “Find usages” uzağınızda.

Dikkat: token adları rolü anlatmalı (Surface, Danger, Muted), rengi değil (Beyaz, Kırmızı1). Danger yarın turuncuya dönebilir; Kırmızı1 dönemez. Aynı sebeple sayfa arka planı ve odak rengi gibi “tek yerde kullanılıyor” diye düşündüğünüz değerleri de token yapın — bir süre sonra tek yer olmaktan çıkıyorlar.

Katman 2 — Style<T>

Style<T>, XAML'deki <Style> etiketinin tipli karşılığı. En güzel yanı: setter'ları, kontrollerde kullandığınız aynı fluent metotlarla yazıyorsunuz.

public static class AppStyles
{
    public const string AccentKey = "AccentColor";

    public static Style<Label> BodyText { get; } = new(e => e
        .FontSize(14)
        .TextColor(t => t.OnLight(AppColors.Text).OnDark(AppColors.TextDark)));

    public static Style<Label> Heading { get; } = new(BodyText, e => e
        .FontSize(24)
        .FontAttributes(FontAttributes.Bold));

    public static Style<Label> Caption { get; } = new(BodyText, e => e
        .FontSize(12)
        .TextColor(AppColors.Muted));

    public static Style<Entry> DefaultEntry { get; } = new(e => e
        .FontSize(15)
        .TextColor(t => t.OnLight(AppColors.Text).OnDark(AppColors.TextDark)));
}

new(BodyText, e => ...) XAML'deki BasedOn ile aynı şey: Heading, BodyText'in her şeyini devralır ve yalnızca farkını yazar. Yazı tipi ailesini değiştirmek istediğinizde tek bir yere dokunursunuz.

Örtük mü, adlandırılmış mı?

Bu ayrım kafa karıştırır, oysa kural basit:

public static ResourceDictionary Default { get; } = new()
{
    { AccentKey, AppColors.Primary },   // anahtarlı değer
    BodyText,                            // anahtarsız stil → tüm Label'lar
    DefaultEntry                         // anahtarsız stil → tüm Entry'ler
};

Sözlüğe anahtarsız eklenen stil, o tipteki her kontrole otomatik uygulanır. Bu yüzden Heading ve Caption'ı sözlüğe koymuyoruz — koysaydık BodyText ile çakışır, hangisinin kazandığı belirsizleşirdi. Onlar static property olarak durur ve istendiği yerde verilir:

new Label().Text("Görünüm").Style(AppStyles.Heading),
new Label().Text("Alt açıklama"),                       // örtük BodyText stilini alır

Sözlüğü uygulamaya bağlamak tek satır — App.cs içinde uygulamanın tamamı, sayfa içinde yalnızca o sayfa için:

this.Resources(new ResourceDictionary().MergedDictionaries(AppStyles.Default));

Sözlük bir kez kurulur ve orada durur. Tema değiştiğinde onu yeniden kurmanız gerekmez — nedeni birazdan.

Katman 3 — Etkileşim durumları

Statik stil yetmez: buton basılıyken, üzerine gelindiğinde, devre dışıyken farklı görünmeli. Bunun için VisualState<T>, doğrudan stilin içine yazılabilir:

public static Style<Button> PrimaryButton { get; } = new(e => e
    .FontSize(15)
    .CornerRadius(10)
    .Padding(new Thickness(18, 12))
    .TextColor(Colors.White)
    .BackgroundColor(t => t.DynamicResource(AccentKey)))
{
    new VisualState<Button>(VisualStates.Button.Normal, e => e
        .Opacity(1)
        .Scale(1)),

    new VisualState<Button>(VisualStates.Button.PointerOver, e => e
        .Opacity(0.9)),

    new VisualState<Button>(VisualStates.Button.Pressed, e => e
        .Scale(0.97)),

    new VisualState<Button>(VisualStates.Button.Disabled, e => e
        .BackgroundColor(t => t.OnLight(AppColors.Gray200).OnDark(AppColors.Gray600))
        .TextColor(AppColors.Muted)),
};

public static Style<Button> DangerButton { get; } = new(PrimaryButton, e => e
    .BackgroundColor(AppColors.Danger));

Durum adları için sihirli string yazmayın; VisualStates.Button.Pressed gibi sabitler var — yanlış yazarsanız derlenmez.

Normal durumunu her zaman tanımlayın. Visual State Manager yalnızca bir durumun set ettiği özellikleri geri alır. Pressed içinde Scale(0.97) yazıp Normal içinde Scale(1) yazmazsanız, buton bir kez basıldıktan sonra küçük kalır. Bu, VSM ile ilgili en sık karşılaşılan hatadır.

DangerButton'ın PrimaryButton'dan türediğine dikkat edin: tüm visual state'leri devralır, yalnızca rengi değişir.

Kontrol üzerinde tek seferlik durumlar

Stil yazmaya değmeyecek durumlar için doğrudan kontrole de yazabilirsiniz:

new Entry()
    .Placeholder("E-posta")
    .VisualStateGroups(
        new VisualStateGroupList
        {
            new VisualState<Entry>(VisualStates.VisualElement.Normal, e => e
                .BackgroundColor(Colors.Transparent)),

            new VisualState<Entry>(VisualStates.VisualElement.Focused, e => e
                .BackgroundColor(t => t
                    .OnLight(AppColors.FieldFocus)
                    .OnDark(AppColors.FieldFocusDark))),
        })

VisualStateGroupList içine doğrudan durum yazdığınızda hepsi CommonStates grubuna girer — çoğu senaryoda istediğiniz de budur.

Koyu tema: OnLight / OnDark

Setter içinde tek bir değer yerine tema çifti verebilirsiniz:

.TextColor(t => t.OnLight(AppColors.Text).OnDark(AppColors.TextDark))

Burada olan biteni bilmek önemli, çünkü tema sisteminin çalışıp çalışmaması buna bağlı: bu ifade değeri o an hesaplayıp yazmaz, MAUI'nin AppThemeBinding'ini kurar — XAML'de {AppThemeBinding Light=… Dark=…} yazınca oluşan nesnenin aynısı. MAUI tema değişimini Application'dan başlayıp eleman ağacı boyunca aşağı yayar; binding de bu bildirimi dinler ve kendini yeniden değerlendirir.

Pratik sonucu şu: renk hem kullanıcı telefonun temasını değiştirdiğinde hem de uygulama UserAppTheme'i kendisi değiştirdiğinde günceller. Ekranı yeniden kurmanız, Build()'i tekrar çağırmanız, kaynak sözlüğünü boşaltıp doldurmanız gerekmez.

Sürüm notu: bu davranış 10.2.1 ile geldi. Daha eski sürümlerde OnLight/OnDark değeri kurulum anında bir kez çözüyordu, dolayısıyla tema sonradan değiştiğinde ekrandaki kontroller değişmiyordu. Eski sürümde kaldıysanız tek çare uygulamayı yeniden başlatmak ya da görünümü baştan kurmaktı.

Aynı yapı canlı kontrollerin üzerinde de çalışır:

this.BackgroundColor(e => e
    .OnLight(AppColors.PageBackground)
    .OnDark(AppColors.PageBackgroundDark))

Kural: tema çifti olan her şey token'dan gelmeli. OnLight(Colors.White) yazdığınız an, o beyaz artık tema sisteminin dışında kalır.

Bir de kardeşleri var ama onlar farklı çalışır: OnPlatform ve OnIdiom çalışma anında değişmeyen değerlerdir (cihaz telefonken tablete dönmez), o yüzden bir kez çözülürler. Zamanla değişen tek boyut tema olduğu için binding gereken tek yer de orası.

Kullanıcının seçtiği renk: DynamicResource

OnLight/OnDark işletim sistemine bağlıdır. Peki kullanıcı vurgu rengini kendisi seçsin isterseniz? İşte bu yüzden PrimaryButton içinde şunu yazdık:

.BackgroundColor(t => t.DynamicResource(AccentKey))

StaticResource değeri bir kez okur; DynamicResource ise anahtarı dinler. Sözlükteki değeri değiştirdiğiniz anda, o anahtarı kullanan her kontrol kendini günceller:

Application.Current!.Resources[AppStyles.AccentKey] = AppColors.Teal;

Tek satır — ve ekrandaki bütün birincil butonlar yeşile döner.

Temayı kod tarafından değiştirmek de bir satır:

Application.Current!.UserAppTheme = AppTheme.Dark;   // AppTheme.Unspecified = sistemi takip et

İki mekanizma da aynı mantıkla çalışır: değeri değiştirirsiniz, dinleyenler kendini günceller. İkisinde de sözlüğü ya da stilleri yeniden kurmak gerekmez.

Seçimi hatırlayan view model

İki ayarı ve kalıcılığı tek yerde toplayalım. Bu sınıfın yaptığı üç iş var: seçimi tutmak, Preferences ile saklamak ve açılışta uygulamak.

public class ThemeViewModel : INotifyPropertyChanged
{
    const string ThemePreferenceKey = "app_theme";
    const string AccentPreferenceKey = "app_accent";

    AppTheme _theme;
    Color _accent;
    bool _isBusy;

    public ThemeViewModel()
    {
        // Kullanıcının son seçimi: yoksa sistemi takip et ve markanın rengini kullan.
        _theme = (AppTheme)Preferences.Default.Get(ThemePreferenceKey, (int)AppTheme.Unspecified);
        _accent = Preferences.Default.Get(AccentPreferenceKey, AppColors.Primary.ToHex()).ToColor();

        ApplyTheme();
        ApplyAccent();
    }

    public AppTheme Theme
    {
        get => _theme;
        set
        {
            if (!Set(ref _theme, value))
                return;

            Preferences.Default.Set(ThemePreferenceKey, (int)value);
            ApplyTheme();
        }
    }

    public Color Accent
    {
        get => _accent;
        set
        {
            if (!Set(ref _accent, value))
                return;

            Preferences.Default.Set(AccentPreferenceKey, value.ToHex());
            ApplyAccent();
        }
    }

    public bool IsBusy { get => _isBusy; set => Set(ref _isBusy, value); }

    void ApplyTheme()
    {
        if (Application.Current is { } app)
            app.UserAppTheme = _theme;
    }

    void ApplyAccent()
    {
        if (Application.Current is { } app)
            app.Resources[AppStyles.AccentKey] = _accent;
    }

    public event PropertyChangedEventHandler? PropertyChanged;

    bool Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
    {
        if (EqualityComparer<T>.Default.Equals(field, value))
            return false;

        field = value;
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
        return true;
    }
}

Üç ayrıntı burada kritik:

  • Constructor'daki ApplyTheme() / ApplyAccent(). Tema özelliğinin en sık atlanan yarısı budur: seçim kaydedilir ama açılışta uygulanmaz, kullanıcı da her seferinde ayarı yeniden yapar.
  • Set metodu bool döndürüyor. Değer gerçekten değişmediyse Preferences'a yazmıyor, temayı yeniden uygulamıyoruz. Bu aynı zamanda MAUI'nin de işine geliyor: UserAppTheme'e aynı değeri yazmak zaten bildirim üretmez.
  • Renk ToHex() ile saklanıp ToColor() ile geri okunuyor. Preferences yalnızca ilkel tipleri tuttuğu için bu çift doğal bir eşleşme.

Şablon dışı bir projede gerekli using'ler: System.ComponentModel, System.Runtime.CompilerServices, FmgLib.MauiMarkup, FmgLib.MauiMarkup.Core, Microsoft.Maui.Storage.

Hepsini birleştiren ekran

public class ThemePage : ContentPage, IFmgLibHotReload
{
    readonly ThemeViewModel vm = new();

    public ThemePage() => this.InitializeHotReload();

    public void Build() => this
        .BindingContext(vm)
        .Resources(new ResourceDictionary().MergedDictionaries(AppStyles.Default))
        .BackgroundColor(e => e
            .OnLight(AppColors.PageBackground)
            .OnDark(AppColors.PageBackgroundDark))
        .Content(
            new VerticalStackLayout()
            .Spacing(20)
            .Padding(24)
            .Children(
                new Label().Text("Görünüm").Style(AppStyles.Heading),

                new Label().Text("Uygulamanın rengini ve temasını buradan değiştirin."),

                BuildCard(
                    new Label().Text("Tema").Style(AppStyles.Caption),
                    new HorizontalStackLayout()
                    .Spacing(8)
                    .Children(
                        ThemeButton("Sistem", AppTheme.Unspecified),
                        ThemeButton("Açık", AppTheme.Light),
                        ThemeButton("Koyu", AppTheme.Dark)
                    ),

                    new Label().Text("Vurgu rengi").Style(AppStyles.Caption),
                    new HorizontalStackLayout()
                    .Spacing(8)
                    .Children(
                        AccentSwatch(AppColors.Primary),
                        AccentSwatch(AppColors.Teal),
                        AccentSwatch(AppColors.Amber)
                    )
                ),

                BuildCard(
                    new Label().Text("Önizleme").Style(AppStyles.Caption),

                    new Entry()
                        .Placeholder("E-posta")
                        .VisualStateGroups(
                            new VisualStateGroupList
                            {
                                new VisualState<Entry>(VisualStates.VisualElement.Normal, e => e
                                    .BackgroundColor(Colors.Transparent)),

                                new VisualState<Entry>(VisualStates.VisualElement.Focused, e => e
                                    .BackgroundColor(t => t
                                        .OnLight(AppColors.FieldFocus)
                                        .OnDark(AppColors.FieldFocusDark))),
                            }),

                    new Button()
                        .Text("Kaydet")
                        .Style(AppStyles.PrimaryButton)
                        .IsEnabled(e => e
                            .Getter(static (ThemeViewModel v) => v.IsBusy)
                            .Convert((bool busy) => !busy)),

                    new Button()
                        .Text("Hesabı sil")
                        .Style(AppStyles.DangerButton)
                )
            )
        );

    static View BuildCard(params IView[] children) =>
        new Border()
            .Stroke(e => e.OnLight(AppColors.Border).OnDark(AppColors.BorderDark))
            .StrokeThickness(1)
            .StrokeShape(new RoundRectangle().CornerRadius(14))
            .BackgroundColor(e => e.OnLight(AppColors.Surface).OnDark(AppColors.SurfaceDark))
            .Padding(16)
            .Content(
                new VerticalStackLayout()
                    .Spacing(10)
                    .Children(children));

    View ThemeButton(string text, AppTheme theme) =>
        new Button()
            .Text(text)
            .Style(AppStyles.PrimaryButton)
            .Opacity(e => e
                .Getter(static (ThemeViewModel v) => v.Theme)
                .Convert((AppTheme current) => current == theme ? 1.0 : 0.45))
            .OnClicked(b => vm.Theme = theme);

    View AccentSwatch(Color color) =>
        new Border()
            .WidthRequest(36)
            .HeightRequest(36)
            .BackgroundColor(color)
            .StrokeThickness(0)
            .StrokeShape(new RoundRectangle().CornerRadius(18))
            .GestureRecognizers(
                new TapGestureRecognizer().OnTapped((s, e) => vm.Accent = color));
}

BuildCard ve ThemeButton gibi küçük metotlar C# markup'ın sessiz kazancı: XAML'de bir kartı tekrar kullanmak için ControlTemplate ya da ayrı bir ContentView gerekirken, burada bir metot yetiyor. Seçili tema butonunun soluk görünmesi de binding ile geliyor — Theme değişince üç butonun opaklığı kendiliğinden güncelleniyor.

Hangi aracı ne zaman?

İhtiyaç Araç Çalışma anında değişir mi?
İşletim sistemi açık/koyu teması OnLight / OnDark Evet — binding kurar
Kullanıcının seçtiği renk DynamicResource + Resources[key] = ... Evet — anahtarı dinler
Platforma/cihaz tipine göre değer OnPlatform / OnIdiom Hayır — bir kez çözülür, doğrusu da bu
Bir tipteki tüm kontrollerin görünümü Anahtarsız (örtük) Style<T>
Aynı tipin varyantları (primary/danger) BasedOn ile türetilmiş stil
Basılı / odaklı / devre dışı görünümü VisualState<T>
Yoğunluk, marka gibi tüm set değişimi MergedDictionaries ile sözlük takası

Sık yapılan hatalar

Normal durumu tanımlanmamış. Pressed içinde değiştirdiğiniz her özelliği Normal içinde de yazın; yoksa kontrol o durumdan çıkınca eski hâline dönmez.

Adlandırılmış stili sözlüğe anahtarsız eklemek. Heading stilini anahtarsız eklerseniz bütün Label'lar başlık olur. Anahtarsız = örtük.

Ayar kaydediliyor ama açılışta uygulanmıyor. Preferences.Set(...) tek başına yetmez; view model kurulurken okunan değerin UserAppTheme'e ve kaynak sözlüğüne yazılması gerekir.

Tema değişince stilleri elle yeniden kurmak. Resources.MergedDictionaries.Clear() + tekrar Add(...) gibi çözümler dolaşımda; gerek yok. OnLight/OnDark binding kurduğu, DynamicResource da anahtarı dinlediği için ikisi de kendini günceller. Sözlüğü boşaltıp doldurmak bütün stilleri yeniden kurar, Build()'i yeniden çağırmak ise kaydırma konumunu ve odağı sıfırlar.

Kontrol üzerinde doğrudan yazılan değer stili ezer. .Style(AppStyles.PrimaryButton).BackgroundColor(Colors.Green) yazarsanız yeşil kazanır — üstelik visual state'ler de artık o özelliği geri alamaz. İstisna yapmanız gerekiyorsa yeni bir BasedOn stili türetin.

StaticResource ile DynamicResource karıştırılıyor. Çalışma anında değişecek her şey dinamik olmalı; statik olan bir kez okunur ve bir daha bakmaz.

Renk token'ı yerine doğrudan hex. Bir kere yaparsınız, altı ay sonra o hex'i 12 yerde ararsınız.

Özet

  • Renkleri token'lara, token'ları stillere, stilleri sözlüğe koyun; kontrollerin içinde ham renk kalmasın.
  • BasedOn ile stil hiyerarşisi kurun — varyant yazmak fark yazmak olsun.
  • Etkileşim durumları VisualState<T> ile stile gömülür; Normal'ı asla atlamayın.
  • OnLight/OnDark bir binding kurar, DynamicResource bir anahtarı dinler — ikisi de ekranı kendisi günceller, sizin bir şeyi yeniden kurmanız gerekmez.
  • Seçimi saklamayı ve açılışta uygulamayı unutmayın.

Bu sistemi bir kez kurduğunuzda “koyu tema ekleyelim” isteği bir sabah işi olmaktan çıkıp bir öğleden sonra işine dönüşür — ve marka rengi değiştiğinde tek bir satır güncellersiniz.

İlgili Yazılar

FmgLib.MauiMarkup mu, CommunityToolkit.Maui.Markup mı? Dürüst Bir Karşılaştırma

.NET MAUI arayüzünü XAML yerine C# ile yazmanın iki yolu var ve ikisi de akıcı (fluent) metotlar kullanıyor. Aradaki farkların neredeyse tamamı tek bir mimari tercihten doğuyor: elle seçilmiş uzantılar mı, yoksa kaynak üreteci mi? Bu yazıda farkları, her birinin hangi durumda daha iyi seçim olduğunu ve ikisinin aynı projede yan yana yaşayıp yaşayamayacağını ölçtüğümüz sonuçlarla ele alıyoruz.

Devamını Oku

Uygulamanın Tamamı Aynı Dili Konuşsun: .NET MAUI'de Üçüncü Parti Kontroller ve Kendi Fluent Metotlarınız

C# markup'ın gerçek projelerdeki en büyük sorusu: Syncfusion, UraniumUI, SkiaSharp gibi kütüphanelerin kontrolleri için fluent API kaybolur mu? Kaybolmuyor — aynı kaynak üreteci onlar için de çalışıyor. Tek satırlık opt-in'den otomatik taramaya, attached property'lerden yeniden tanımlanan property kurallarına, oradan kendi tasarım sistemi metotlarınızı yazmaya kadar tüm yolu ele alıyoruz.

Devamını Oku

VodiSoft

Yazılım projenizi gelir üreten bir ürüne dönüştürelim

Web, mobil, .NET, SaaS ve entegrasyon projeleri için teknik keşif yapalım; riskleri, takvimi ve en hızlı ticari kazanımı netleştirelim.

Teklif Al Toplantı