Data binding'i çoğumuz biliyoruz: bir kaynak değişir, arayüz kendini günceller. Peki hedef özellik birden çok kaynağa bağlıysa? Klasik cevap “view model'e hesaplanmış bir property ekle” ya da “bir IMultiValueConverter sınıfı yaz” olur. İkisi de her zaman doğru cevap değildir — özellikle kaynaklar view model'de değil, ekrandaki kontrollerde duruyorsa.
Bu yazıda tek bir ekran üzerinden gidiyoruz: bir kargo ücreti hesaplama sayfası. Ağırlık, mesafe ve “ekspres” anahtarı değiştikçe fiyat anında güncellenecek, form eksiksiz doldurulmadan onay butonu açılmayacak. Kod tamamen C# markup ile, XAML yok.
Kullandığımız kütüphane FmgLib.MauiMarkup 10.2.0. Tek paket hem .NET 9 hem .NET 10 hedefliyor.
Ne kuracağız?
Ekranda şunlar olacak:
- İki slider: ağırlık (kg) ve mesafe (km)
- Bir switch: ekspres teslimat
- Anlık güncellenen bir fiyat etiketi — üç kaynağın birleşimi
- Duruma göre değişen bir bilgi metni — yine çoklu kaynak
- Ad Soyad, adres ve sözleşme onayı alanları
- Üçü de geçerli olmadan etkinleşmeyen bir onay butonu
Adım 1 — Projeyi oluştur
En hızlısı hazır şablon:
dotnet new install FmgLib.MauiMarkup.Template
dotnet new fmglib-mauimarkup-app -o ShippingDemo
Mevcut bir MAUI projesine ekleyecekseniz:
dotnet add package FmgLib.MauiMarkup
Kayıt çağrısı, MauiProgram düzenlemesi yok. Paket geldiği anda MAUI'nin tüm kontrolleri için akıcı metotlar hazır.
Adım 2 — View model
Binding'in çalışması için tek şart: kaynak, değiştiğini duyursun. Yani INotifyPropertyChanged.
public class ShippingViewModel : INotifyPropertyChanged
{
double _weight = 2;
double _distance = 150;
bool _isExpress;
string _firstName = "";
string _lastName = "";
string _address = "";
bool _acceptedTerms;
public double Weight { get => _weight; set => Set(ref _weight, value); }
public double Distance { get => _distance; set => Set(ref _distance, value); }
public bool IsExpress { get => _isExpress; set => Set(ref _isExpress, value); }
public string FirstName { get => _firstName; set => Set(ref _firstName, value); }
public string LastName { get => _lastName; set => Set(ref _lastName, value); }
public string Address { get => _address; set => Set(ref _address, value); }
public bool AcceptedTerms { get => _acceptedTerms; set => Set(ref _acceptedTerms, value); }
public static decimal Price(double weightKg, double distanceKm, bool isExpress)
{
var basePrice = 29.90m;
var weightCost = (decimal)Math.Max(0, weightKg - 1) * 8.50m;
var distanceCost = (decimal)distanceKm * 0.35m;
var total = basePrice + weightCost + distanceCost;
return isExpress ? total * 1.4m : total;
}
public event PropertyChangedEventHandler? PropertyChanged;
void Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
{
if (Equals(field, value))
return;
field = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
}
Dikkat: fiyat hesabı view model'de property değil, saf bir metot. Nedeni birazdan anlaşılacak — hesaplamayı binding katmanı tetikleyecek, PropertyChanged zincirini elle yönetmeyeceğiz.
Adım 3 — İlk binding
Sayfanın iskeleti:
public class ShippingPage : ContentPage, IFmgLibHotReload
{
readonly ShippingViewModel vm = new();
public ShippingPage() => this.InitializeHotReload();
public void Build() => this
.BindingContext(vm)
.Content(
new VerticalStackLayout()
.Spacing(16)
.Padding(24)
.Children(
new Label()
.Text(e => e
.Getter(static (ShippingViewModel v) => v.Weight)
.StringFormat("Ağırlık: {0:F1} kg"))
)
);
}
Buradaki e => e... ifadesi property builder. Her fluent property metodu, değer yerine böyle bir lambda kabul ediyor.
Path mi Getter mı?
İki yol var, ikisi de aynı işi yapıyor:
// string path — çalışma anında reflection ile çözülür
.Text(e => e.Path("Weight").StringFormat("Ağırlık: {0:F1} kg"))
// compiled binding — derleyici denetler, reflection yok
.Text(e => e.Getter(static (ShippingViewModel v) => v.Weight).StringFormat("Ağırlık: {0:F1} kg"))
Getter tercih edin: property adını yanlış yazarsanız uygulama sessizce boş bir label göstermek yerine derlenmez, rename yaptığınızda binding de peşinden gelir, üstelik daha hızlıdır. static yazmak da alışkanlık hâline gelsin — istemeden closure yakalamanızı engeller.
Path'in hâlâ yeri var: bağlanacağınız property'nin tipini derleme anında bilmiyorsanız (dinamik sözlükler, Text.Length gibi zincirler) ya da kaynak başka bir kontrolse.
Adım 4 — Two-way binding
Slider'ın değerini okumak yetmez, kullanıcı kaydırdığında view model'e yazılması da gerekir. Compiled binding'de ters yönü Setter verir:
new Slider()
.Minimum(0.5)
.Maximum(30)
.Value(e => e
.Getter(static (ShippingViewModel v) => v.Weight)
.Setter(static (ShippingViewModel v, double x) => v.Weight = x)
.BindingMode(BindingMode.TwoWay)),
Aynı kalıp Entry.Text, Switch.IsToggled, CheckBox.IsChecked için de geçerli:
new Switch()
.IsToggled(e => e
.Getter(static (ShippingViewModel v) => v.IsExpress)
.Setter(static (ShippingViewModel v, bool x) => v.IsExpress = x)
.BindingMode(BindingMode.TwoWay)),
Üç kural: Getter okur, Setter yazar, BindingMode.TwoWay ikisini birden açar. Setter vermeyi unutursanız kullanıcı arayüzü değiştirir ama view model haberdar olmaz — hata da almazsınız, en can sıkıcı bug türü budur.
Adım 5 — MultiBinding: fiyat üç kaynaktan
Şimdi asıl mesele. Fiyat üç şeye bağlı: ağırlık, mesafe, ekspres. Klasik yaklaşımda view model'e TotalPrice diye bir property koyar, Weight, Distance ve IsExpress setter'larının her birinde ayrıca OnPropertyChanged(nameof(TotalPrice)) çağırırsınız. Üç kaynak için üç ek satır; birini unutmak sessiz bir bug.
MultiBinding bunu tersine çevirir: bağımlılığı kaynağın değil, hedefin yanında tarif edersiniz.
new Label()
.FontSize(28)
.Text(e => e
.Getter(static (ShippingViewModel v) => v.Weight)
.Getter(static (ShippingViewModel v) => v.Distance)
.Getter(static (ShippingViewModel v) => v.IsExpress)
.MultiConvert((double w, double d, bool express) =>
ShippingViewModel.Price(w, d, express).ToString("C", new CultureInfo("tr-TR")))),
Okunuşu şu: "Bu etiketin metni şu üç kaynaktan geliyor; üçünden herhangi biri değişince şu fonksiyonu çalıştır."
Üç ayrıntı:
- Sıra önemli.
MultiConvertparametreleri,Getter/Pathçağrılarının sırasıyla eşleşir. Sayı tutmazsa binding kurulurken “3 alt binding var ama delege 2 parametre alıyor” diyen bir hata alırsınız — çalışma anında sessizce yanlış davranmaz. - Tipler gerçek tiplerdir.
object[]alıp cast etmiyorsunuz;double,double,boolyazıyorsunuz. - Kaynaklar farklı yerlerden gelebilir. Hepsi view model'de olmak zorunda değil;
.Source(slider)ile doğrudan bir kontrole de bağlanabilirsiniz:
new Slider().Assign(out var widthSlider).Minimum(1).Maximum(300),
new Slider().Assign(out var heightSlider).Minimum(1).Maximum(300),
new Label()
.Text(e => e
.Path(nameof(Slider.Value)).Source(widthSlider)
.Path(nameof(Slider.Value)).Source(heightSlider)
.MultiConvert((double w, double h) => $"{w:F0} × {h:F0} = {w * h:F0} px²")),
Bu ikinci senaryo, view model'e hesaplanmış property koymanın hiç yardımcı olmadığı yerdir: değerler view model'e hiç uğramıyor.
Sadece biçimlendirme yapıyorsanız
Yapacağınız tek şey iki değeri yan yana yazmaksa lambda bile gereksiz:
new Label()
.Text(e => e
.Path(nameof(ShippingViewModel.FirstName))
.Path(nameof(ShippingViewModel.Address))
.MultiStringFormat("{0} — {1}")),
Adım 6 — Kaynak başına Convert
Bazen bir kaynağı birleştirmeden önce şekillendirmek gerekir. Convert, kendisinden önce gelen kaynağa aittir:
new Label()
.Text(e => e
.Getter(static (ShippingViewModel v) => v.Weight).Convert((double w) => w > 10)
.Getter(static (ShippingViewModel v) => v.IsExpress)
.MultiConvert((bool heavy, bool express) => (heavy, express) switch
{
(true, true) => "Ağır paket + ekspres: ek ücret uygulanır.",
(true, false) => "Ağır paket: teslimat 1 gün uzayabilir.",
(false, true) => "Ekspres: yarın kapında.",
_ => "Standart teslimat: 2-3 iş günü."
})),
Weight bir double, ama kendi Convert'i sayesinde MultiConvert'e bool olarak ulaşıyor. İkinci kaynak dokunulmadan geliyor.
Ezberlenecek tek cümle: Convert bir kaynağa aittir, MultiConvert zinciri kapatır.
Adım 7 — Formu doğrula: MultiAll
“Üç koşul da sağlanınca butonu aç” ihtiyacı o kadar yaygın ki, bunun için lambda bile yazmaya gerek yok:
new Button()
.Text("Siparişi Onayla")
.IsEnabled(e => e
.Getter(static (ShippingViewModel v) => v.FirstName)
.Convert((string s) => !string.IsNullOrWhiteSpace(s))
.Getter(static (ShippingViewModel v) => v.Address)
.Convert((string s) => !string.IsNullOrWhiteSpace(s) && s.Length >= 10)
.Getter(static (ShippingViewModel v) => v.AcceptedTerms)
.MultiAll())
.OnClicked(async b => await DisplayAlert("Tamam", "Siparişiniz alındı.", "Kapat")),
Her kaynak bool üretiyor — ilk ikisi kendi Convert'i üzerinden, üçüncüsü zaten bool. Aynı ailede MultiAny, MultiNone, MultiAtLeast(n), MultiExactly(n) de var.
Bu kadarcık kod için normalde bir AllTrueConverter sınıfı, onun kaydı ve üç ayrı IsValid property'si yazardık.
Adım 8 — Tek Entry, iki property
MultiBinding tek yönlü değil. Kullanıcının tek alana “Ada Lovelace” yazdığını ve bunun view model'de FirstName + LastName olarak ayrıştığını düşünün:
new Entry()
.Placeholder("Ad Soyad")
.Text(e => e
.Path(nameof(ShippingViewModel.FirstName))
.Path(nameof(ShippingViewModel.LastName))
.MultiMode(BindingMode.TwoWay)
.MultiConvert((string first, string last) => $"{first} {last}".Trim())
.MultiConvertBack((string full) =>
{
var parts = (full ?? string.Empty).Trim().Split(' ', 2);
return (parts[0], parts.Length > 1 ? parts[1] : string.Empty);
})),
MultiConvertBack bir tuple döndürür; elemanlar tanımlandıkları sırayla kaynaklara yazılır. MultiMode tüm multi binding'in modunu belirler — tek bir kaynağı salt okunur tutmak isterseniz ona kendi .BindingMode(BindingMode.OneWay)'ini verirsiniz.
Tam sayfa kodu
using System.ComponentModel;
using System.Globalization;
using System.Runtime.CompilerServices;
using FmgLib.MauiMarkup;
namespace ShippingDemo;
public class ShippingPage : ContentPage, IFmgLibHotReload
{
readonly ShippingViewModel vm = new();
public ShippingPage() => this.InitializeHotReload();
public void Build() => this
.BindingContext(vm)
.Content(
new ScrollView()
.Content(
new VerticalStackLayout()
.Spacing(16)
.Padding(24)
.Children(
new Label()
.Text("Kargo Ücreti Hesapla")
.FontSize(24)
.FontAttributes(FontAttributes.Bold),
new Label()
.Text(e => e
.Getter(static (ShippingViewModel v) => v.Weight)
.StringFormat("Ağırlık: {0:F1} kg")),
new Slider()
.Minimum(0.5)
.Maximum(30)
.Value(e => e
.Getter(static (ShippingViewModel v) => v.Weight)
.Setter(static (ShippingViewModel v, double x) => v.Weight = x)
.BindingMode(BindingMode.TwoWay)),
new Label()
.Text(e => e
.Getter(static (ShippingViewModel v) => v.Distance)
.StringFormat("Mesafe: {0:F0} km")),
new Slider()
.Minimum(5)
.Maximum(1500)
.Value(e => e
.Getter(static (ShippingViewModel v) => v.Distance)
.Setter(static (ShippingViewModel v, double x) => v.Distance = x)
.BindingMode(BindingMode.TwoWay)),
new HorizontalStackLayout()
.Spacing(8)
.Children(
new Switch()
.IsToggled(e => e
.Getter(static (ShippingViewModel v) => v.IsExpress)
.Setter(static (ShippingViewModel v, bool x) => v.IsExpress = x)
.BindingMode(BindingMode.TwoWay)),
new Label().Text("Ekspres teslimat")
),
new Label()
.FontSize(28)
.Text(e => e
.Getter(static (ShippingViewModel v) => v.Weight)
.Getter(static (ShippingViewModel v) => v.Distance)
.Getter(static (ShippingViewModel v) => v.IsExpress)
.MultiConvert((double w, double d, bool express) =>
ShippingViewModel.Price(w, d, express)
.ToString("C", new CultureInfo("tr-TR")))),
new Label()
.Text(e => e
.Getter(static (ShippingViewModel v) => v.Weight).Convert((double w) => w > 10)
.Getter(static (ShippingViewModel v) => v.IsExpress)
.MultiConvert((bool heavy, bool express) => (heavy, express) switch
{
(true, true) => "Ağır paket + ekspres: ek ücret uygulanır.",
(true, false) => "Ağır paket: teslimat 1 gün uzayabilir.",
(false, true) => "Ekspres: yarın kapında.",
_ => "Standart teslimat: 2-3 iş günü."
})),
new Entry()
.Placeholder("Ad Soyad")
.Text(e => e
.Path(nameof(ShippingViewModel.FirstName))
.Path(nameof(ShippingViewModel.LastName))
.MultiMode(BindingMode.TwoWay)
.MultiConvert((string first, string last) => $"{first} {last}".Trim())
.MultiConvertBack((string full) =>
{
var parts = (full ?? string.Empty).Trim().Split(' ', 2);
return (parts[0], parts.Length > 1 ? parts[1] : string.Empty);
})),
new Entry()
.Placeholder("Teslimat adresi")
.Text(e => e
.Getter(static (ShippingViewModel v) => v.Address)
.Setter(static (ShippingViewModel v, string x) => v.Address = x)
.BindingMode(BindingMode.TwoWay)),
new HorizontalStackLayout()
.Spacing(8)
.Children(
new CheckBox()
.IsChecked(e => e
.Getter(static (ShippingViewModel v) => v.AcceptedTerms)
.Setter(static (ShippingViewModel v, bool x) => v.AcceptedTerms = x)
.BindingMode(BindingMode.TwoWay)),
new Label().Text("Mesafeli satış sözleşmesini okudum")
),
new Button()
.Text("Siparişi Onayla")
.IsEnabled(e => e
.Getter(static (ShippingViewModel v) => v.FirstName)
.Convert((string s) => !string.IsNullOrWhiteSpace(s))
.Getter(static (ShippingViewModel v) => v.Address)
.Convert((string s) => !string.IsNullOrWhiteSpace(s) && s.Length >= 10)
.Getter(static (ShippingViewModel v) => v.AcceptedTerms)
.MultiAll())
.OnClicked(async b => await DisplayAlertAsync("Tamam", "Siparişiniz alındı.", "Kapat"))
)
)
);
}
IFmgLibHotReload sayesinde Build() her kod değişikliğinde yeniden çalışır; slider'ları oynatıp fiyat formülünü canlı olarak deneyebilirsiniz.
Ne zaman MultiBinding, ne zaman view model property?
MultiBinding her yerin çözümü değil. Basit bir ayrım:
| Durum | Tercih |
|---|---|
| Kaynaklar view model'de ve sonuç iş mantığı (vergi, indirim, yetki) | View model'de hesaplanmış property |
| Kaynaklar ekrandaki kontroller (slider, entry, switch) | MultiBinding |
| Sonuç yalnızca sunum (biçimlendirme, renk, görünürlük) | MultiBinding |
| Sonucun birim testi yazılacak | View model — test edilmesi kolay |
| Aynı birleşim üç ayrı ekranda tekrar ediyor | View model, ya da kendi IMultiValueConverter'ınız |
Pratik kural: hesap iş mantığıysa view model'e, sunumsa binding'e.
Sık karşılaşılan hatalar
Kaynak INotifyPropertyChanged uygulamıyor. Binding ilk değeri okur ve orada donar. Hata mesajı da almazsınız. Bir binding “çalışmıyorsa” önce buraya bakın.
Setter unutulmuş. Arayüz değişir, view model boş kalır. BindingMode.TwoWay tek başına yetmez; compiled binding'de ters yönü siz tarif edersiniz.
Parametre tipi kaynağın tipiyle uyuşmuyor. Weight bir double iken MultiConvert((int w, ...) => ...) yazarsanız binding, hangi property'de hangi sıradaki değerin uyuşmadığını söyleyen bir hata verir. Mesajı okuyun — parametre tipini kaynağın gerçek tipiyle eşleyin.
Değerler henüz gelmemişken panik. MultiBinding, kaynaklardan ilki çözülür çözülmez değerlendirilir. Bu sırada diğerleri boş olabilir; bu durumda hedef, null ile ezilmek yerine mevcut değerinde bırakılır. Yani ilk karede yanıp sönen boş etiketler görmezsiniz.
Ekran açılışında butonun bir an aktif görünmesi. MultiAll gibi toplayıcılar kaynak sayısını doğrulamaz; iki kaynak üzerinde MultiAtLeast(3) her zaman false verir. Beklediğiniz sayıda Getter/Path yazdığınızdan emin olun.
Özet
Getter+Setter+BindingMode.TwoWay→ derlenen, refactor'a dayanıklı iki yönlü binding.- Birden çok
Getter/Path+MultiConvert→ çoklu kaynak, tipli parametrelerle, converter sınıfı olmadan. Convertkaynağa aittir,MultiConvertzinciri kapatır.MultiAllve kardeşleri form doğrulamayı tek satıra indirir.- İş mantığı view model'de kalsın; sunum birleşimleri binding'de.
Bu ekranın tamamı 100 satır civarı ve içinde tek bir converter sınıfı, tek bir OnPropertyChanged(nameof(Total)) zinciri yok. MultiBinding'in asıl kazancı da bu: bağımlılığı, onu gerçekten önemseyen yerde — hedefin yanında — tarif etmek.