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:
- Tasarım token'ları — renklerin tek kaynağı
Style<T>— devralınabilir, tipli stiller- Tema stratejileri —
OnLight/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/OnDarkdeğ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. Setmetodubooldöndürüyor. Değer gerçekten değişmediysePreferences'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ıpToColor()ile geri okunuyor.Preferencesyalnı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.
BasedOnile 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/OnDarkbir binding kurar,DynamicResourcebir 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.