VodiSoft
Tek bir kargo hesaplama ekranı üzerinden .NET MAUI'de data binding, two-way binding ve multibinding'i sıfırdan kuruyoruz. Ağırlık, mesafe ve ekspres seçeneğini tek bir fiyat etiketinde birleştiriyor, formu converter sınıfı yazmadan doğruluyoruz. Tüm kod C# markup ile, XAML yok.

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ı:

  1. Sıra önemli. MultiConvert parametreleri, 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.
  2. Tipler gerçek tiplerdir. object[] alıp cast etmiyorsunuz; double, double, bool yazıyorsunuz.
  3. 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.
  • Convert kaynağa aittir, MultiConvert zinciri kapatır.
  • MultiAll ve 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.

İ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ı