VodiSoft
CollectionView'da en çok takılınan konu şablon içindeki binding'dir: satırdaki buton neden ViewModel'in komutunu bulamaz? Bir ürün kataloğu ekranı üzerinden DataTemplate binding context'ini, RelativeSource ile atadan bağlamayı, template selector'ı, pull-to-refresh ve sonsuz kaydırmayı C# markup ile adım adım kuruyoruz.

CollectionView'ı ilk kez kuran hemen herkes aynı duvara toslar: liste görünür, satırlar doğru veriyi gösterir, ama satırdaki butona basınca hiçbir şey olmaz. Hata da yoktur, log da yoktur. Sessizce çalışmaz.

Sebebi tek bir cümlede özetlenebilir: DataTemplate içindeki binding context, sayfanın view model'i değil, o satırın kendisidir. Bu yazıda bir ürün kataloğu ekranı kurarak bu farkı ve etrafındaki her şeyi ele alıyoruz — şablon içi binding, komutlar, template selector, boş liste görünümü, pull-to-refresh ve sonsuz kaydırma.

Kod tamamen C# markup, XAML yok. Kullanılan kütüphane FmgLib.MauiMarkup 10.2.0.

Model ve view model

Önce veri. Kritik nokta: listedeki nesne de INotifyPropertyChanged uygulamalı. Yalnızca view model'in bunu yapması yetmez — satır içinde değişen bir alan varsa (burada favori durumu) o alanı taşıyan sınıf da haber vermek zorundadır.

public class Product : INotifyPropertyChanged
{
    bool _isFavorite;

    public int Id { get; init; }
    public string Name { get; init; } = "";
    public string Category { get; init; } = "";
    public decimal Price { get; init; }
    public int Stock { get; init; }
    public bool IsCampaign { get; init; }

    public bool IsFavorite { get => _isFavorite; set => Set(ref _isFavorite, value); }

    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));
    }
}

View model tarafında koleksiyon ObservableCollection<T> olmalı — List<T> kullanırsanız liste ilk hâliyle çizilir ve sonradan eklediğiniz hiçbir şey ekrana yansımaz.

public class CatalogViewModel : INotifyPropertyChanged
{
    bool _isRefreshing;
    bool _isLoadingMore;
    string _search = "";
    Product? _selected;
    int _page;

    public ObservableCollection<Product> Products { get; } = new();

    public bool IsRefreshing { get => _isRefreshing; set => Set(ref _isRefreshing, value); }
    public bool IsLoadingMore { get => _isLoadingMore; set => Set(ref _isLoadingMore, value); }
    public string Search { get => _search; set => Set(ref _search, value); }
    public Product? Selected { get => _selected; set => Set(ref _selected, value); }

    public ICommand ToggleFavoriteCommand { get; }
    public ICommand AddToCartCommand { get; }
    public ICommand RefreshCommand { get; }

    public CatalogViewModel()
    {
        ToggleFavoriteCommand = new Command<Product>(product =>
        {
            if (product is not null)
                product.IsFavorite = !product.IsFavorite;
        });

        AddToCartCommand = new Command<Product>(product => { /* sepete ekle */ });

        RefreshCommand = new Command(async () =>
        {
            IsRefreshing = true;
            _page = 0;
            Products.Clear();
            await LoadPageAsync();
            IsRefreshing = false;
        });
    }

    public async Task LoadPageAsync()
    {
        if (IsLoadingMore)
            return;

        IsLoadingMore = true;
        // ... servisten sayfa çek, Products'a ekle ...
        _page++;
        IsLoadingMore = false;
    }

    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));
    }
}

Komutların { get; } olarak bir kez kurulduğuna dikkat edin. public ICommand Foo => new Command(...) yazmak yaygın bir hatadır: her get çağrısında yeni bir nesne üretir, CanExecuteChanged aboneliklerini havada bırakır ve listede her satır için ayrı bir komut nesnesi yaratır.

Listeyi bağlama

new CollectionView()
    .ItemsSource(e => e.Getter(static (CatalogViewModel v) => v.Products))
    .SelectionMode(SelectionMode.Single)
    .SelectedItem(e => e
        .Path(nameof(CatalogViewModel.Selected))
        .BindingMode(BindingMode.TwoWay))

ItemsSource sayfanın binding context'inden okunur — burası hâlâ view model dünyası. SelectedItemTwoWay bağlarsanız seçim doğrudan view model'e düşer; SelectionChanged event'ine abone olup elle atama yapmanıza gerek kalmaz.

Burada Getter yerine Path kullandığımı fark etmiş olabilirsiniz. Sebebi: Selected tipi Product?, SelectedItem ise object. İkisi arasında derlenen bir getter kurmak yerine string path daha doğrudan — ikisi de çalışır, biri daha az gürültülü.

Asıl mesele: DataTemplate içindeki binding context

Şablonun içine girdiğiniz anda dünya değişir. Şablonun her örneği tek bir Product için üretilir ve o örneğin binding context'i o üründür.

Yani şablon içinde bu doğru çalışır:

new Label().Text(e => e.Getter(static (Product p) => p.Name))

Ama bu sessizce hiçbir şey yapmaz:

// YANLIŞ — Product üzerinde ToggleFavoriteCommand diye bir şey yok
new Button().Command(e => e.Path("ToggleFavoriteCommand"))

Binding, Product üzerinde ToggleFavoriteCommand arar, bulamaz, sessizce boş geçer. Butona basarsınız, hiçbir şey olmaz.

Çözüm: atadan bağlama (RelativeSource)

Komut view model'de, veri satırda. İkisini birleştirmenin yolu, binding'e “kaynağı bu satırda değil, yukarıdaki CatalogViewModel binding context'inde ara” demektir:

new Button()
    .Text("Sepete ekle")
    .Command(e => e
        .Path(nameof(CatalogViewModel.AddToCartCommand))
        .Source(new RelativeBindingSource(
            RelativeBindingSourceMode.FindAncestorBindingContext,
            typeof(CatalogViewModel))))
    .CommandParameter(e => e.Path("."))

İki satır, iki ayrı iş yapıyor:

  • Source(...) komutu görsel ağaçta yukarı doğru arar; binding context'i CatalogViewModel olan ilk atayı bulur.
  • CommandParameter içindeki Path(".") ise hâlâ satırın kendi context'inde çalışır ve Product nesnesinin kendisini parametre olarak geçirir.

Yani komut view model'den, parametre satırdan gelir. Command<Product> da bunu tipli olarak karşılar. Bu kalıp, CollectionView'da butonların çalışmamasının %90'ının cevabıdır.

FindAncestorBindingContext binding context'e bakar; FindAncestor ise kontrol tipine bakar (typeof(CatalogPage) gibi). Komutlar view model'de olduğu için neredeyse her zaman ilkini istersiniz.

Şablon içinde çoklu kaynak

Satırın alt metni üç alandan oluşuyor: kategori, fiyat ve stok. Bunun için view model'e DisplayText diye bir property eklemek yerine üç kaynağı doğrudan birleştirebilirsiniz:

new Label()
    .FontSize(13)
    .Opacity(0.7)
    .Text(e => e
        .Getter(static (Product p) => p.Category)
        .Getter(static (Product p) => p.Price)
        .Getter(static (Product p) => p.Stock)
        .MultiConvert((string category, decimal price, int stock) =>
            stock > 0
                ? $"{category} · {price:C} · {stock} adet"
                : $"{category} · {price:C} · tükendi"))

Modelde üç alan için ek bir “görüntüleme” property'si taşımak zorunda kalmazsınız; biçimlendirme, ait olduğu yerde — arayüzde — kalır.

Aynı mantık tek bir kaynağı dönüştürmek için de geçerli:

new Button()
    .Text(e => e
        .Getter(static (Product p) => p.IsFavorite)
        .Convert((bool favorite) => favorite ? "★" : "☆"))

IsFavorite değiştiği anda yıldız dolar — çünkü Product, INotifyPropertyChanged uyguluyor. Uygulamasaydı buton ilk hâlinde donup kalırdı; yazının başındaki uyarının pratik karşılığı budur.

Satır tipine göre farklı şablon

Kampanyalı ürünler farklı görünsün istiyorsanız DataTemplateSelector kullanılır:

public class CampaignTemplateSelector : DataTemplateSelector
{
    public DataTemplate Standard { get; set; } = default!;
    public DataTemplate Campaign { get; set; } = default!;

    protected override DataTemplate OnSelectTemplate(object item, BindableObject container)
        => item is Product { IsCampaign: true } ? Campaign : Standard;
}
.ItemTemplate(new CampaignTemplateSelector
{
    Standard = BuildItemTemplate(),
    Campaign = BuildCampaignTemplate()
})

Önemli bir sınır: OnSelectTemplate yalnızca satır ilk oluşturulurken çağrılır. IsCampaign sonradan değişirse şablon kendiliğinden değişmez. Sık değişen durumlar için ayrı şablon yerine tek şablon içinde IsVisible binding'i kullanın.

Boş liste, yenileme ve sonsuz kaydırma

Üç pratik ayrıntı, üç satır:

new RefreshView()
    .IsRefreshing(e => e
        .Getter(static (CatalogViewModel v) => v.IsRefreshing)
        .Setter(static (CatalogViewModel v, bool x) => v.IsRefreshing = x)
        .BindingMode(BindingMode.TwoWay))
    .Command(e => e.Getter(static (CatalogViewModel v) => v.RefreshCommand))
    .Content(
        new CollectionView()
            .ItemsSource(e => e.Getter(static (CatalogViewModel v) => v.Products))
            .ItemsUpdatingScrollMode(ItemsUpdatingScrollMode.KeepScrollOffset)
            .RemainingItemsThreshold(5)
            .OnRemainingItemsThresholdReached(async c => await vm.LoadPageAsync())
            .EmptyView(
                new Label()
                    .Text("Aradığınız kriterde ürün bulunamadı.")
                    .HorizontalOptions(LayoutOptions.Center))
            .ItemTemplate(BuildItemTemplate()))
  • IsRefreshing mutlaka TwoWay olmalı. Tek yönlü bırakırsanız kullanıcı listeyi aşağı çektiğinde spinner sonsuza kadar döner: MAUI değeri true yapar ama view model bunu asla false'a çeviremez.
  • RemainingItemsThreshold + OnRemainingItemsThresholdReached sonsuz kaydırmanın tamamıdır: sona 5 satır kala bir sonraki sayfa yüklenir. LoadPageAsync içindeki IsLoadingMore kontrolü şart — bu olay hızlı kaydırmada arka arkaya tetiklenir ve koruma koymazsanız aynı sayfayı birkaç kez çekersiniz.
  • ItemsUpdatingScrollMode.KeepScrollOffset olmadan, sayfa sonuna veri eklendiğinde liste başa zıplar.

Tam sayfa

public class CatalogPage : ContentPage, IFmgLibHotReload
{
    readonly CatalogViewModel vm = new();

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

    public void Build() => this
        .BindingContext(vm)
        .Content(
            new Grid()
            .RowDefinitions(e => e.Auto().Star())
            .Children(
                new SearchBar()
                    .Placeholder("Ürün ara")
                    .Text(e => e
                        .Getter(static (CatalogViewModel v) => v.Search)
                        .Setter(static (CatalogViewModel v, string s) => v.Search = s)
                        .BindingMode(BindingMode.TwoWay))
                    .Row(0),

                new RefreshView()
                    .IsRefreshing(e => e
                        .Getter(static (CatalogViewModel v) => v.IsRefreshing)
                        .Setter(static (CatalogViewModel v, bool x) => v.IsRefreshing = x)
                        .BindingMode(BindingMode.TwoWay))
                    .Command(e => e.Getter(static (CatalogViewModel v) => v.RefreshCommand))
                    .Content(
                        new CollectionView()
                            .ItemsSource(e => e.Getter(static (CatalogViewModel v) => v.Products))
                            .SelectionMode(SelectionMode.Single)
                            .SelectedItem(e => e
                                .Path(nameof(CatalogViewModel.Selected))
                                .BindingMode(BindingMode.TwoWay))
                            .ItemsUpdatingScrollMode(ItemsUpdatingScrollMode.KeepScrollOffset)
                            .RemainingItemsThreshold(5)
                            .OnRemainingItemsThresholdReached(async c => await vm.LoadPageAsync())
                            .EmptyView(
                                new Label()
                                    .Text("Aradığınız kriterde ürün bulunamadı.")
                                    .HorizontalOptions(LayoutOptions.Center))
                            .ItemTemplate(new CampaignTemplateSelector
                            {
                                Standard = BuildItemTemplate(),
                                Campaign = BuildCampaignTemplate()
                            }))
                    .Row(1)
            )
        );

    DataTemplate BuildItemTemplate() => new(() =>
        new Grid()
        .ColumnDefinitions(e => e.Star().Auto())
        .Padding(16, 12)
        .ColumnSpacing(12)
        .Children(
            new VerticalStackLayout()
            .Spacing(2)
            .Children(
                new Label()
                    .FontSize(16)
                    .Text(e => e.Getter(static (Product p) => p.Name)),

                new Label()
                    .FontSize(13)
                    .Opacity(0.7)
                    .Text(e => e
                        .Getter(static (Product p) => p.Category)
                        .Getter(static (Product p) => p.Price)
                        .Getter(static (Product p) => p.Stock)
                        .MultiConvert((string category, decimal price, int stock) =>
                            stock > 0
                                ? $"{category} · {price:C} · {stock} adet"
                                : $"{category} · {price:C} · tükendi"))
            )
            .Column(0),

            new HorizontalStackLayout()
            .Spacing(4)
            .Children(
                new Button()
                    .Text(e => e
                        .Getter(static (Product p) => p.IsFavorite)
                        .Convert((bool favorite) => favorite ? "★" : "☆"))
                    .Command(e => e
                        .Path(nameof(CatalogViewModel.ToggleFavoriteCommand))
                        .Source(new RelativeBindingSource(
                            RelativeBindingSourceMode.FindAncestorBindingContext,
                            typeof(CatalogViewModel))))
                    .CommandParameter(e => e.Path(".")),

                new Button()
                    .Text("Sepete ekle")
                    .IsEnabled(e => e
                        .Getter(static (Product p) => p.Stock)
                        .Convert((int stock) => stock > 0))
                    .Command(e => e
                        .Path(nameof(CatalogViewModel.AddToCartCommand))
                        .Source(new RelativeBindingSource(
                            RelativeBindingSourceMode.FindAncestorBindingContext,
                            typeof(CatalogViewModel))))
                    .CommandParameter(e => e.Path("."))
            )
            .Column(1)
        ));

    DataTemplate BuildCampaignTemplate() => new(() =>
        new Border()
        .BackgroundColor(Colors.LightYellow)
        .Padding(16, 12)
        .Content(
            new VerticalStackLayout()
            .Spacing(2)
            .Children(
                new Label()
                    .FontSize(16)
                    .FontAttributes(FontAttributes.Bold)
                    .Text(e => e
                        .Getter(static (Product p) => p.Name)
                        .Convert((string name) => $"KAMPANYA · {name}")),

                new Label()
                    .FontSize(13)
                    .Text(e => e
                        .Getter(static (Product p) => p.Price)
                        .Convert((decimal price) => $"{price * 0.8m:C} (%20 indirim)"))
            )
        ));
}

Şablonları ayrı metotlara çıkarmak sadece estetik değil: Build() uzadıkça okunmaz hâle gelir ve şablon mantığını ayrı tutmak, ileride bir satırı başka listede yeniden kullanmanızı da kolaylaştırır.

Sık yapılan hatalar

List<T> kullanmak. Liste ilk hâliyle çizilir, Add/Remove ekrana yansımaz. ObservableCollection<T> kullanın. Koleksiyonun tamamı değişiyorsa referansı değiştirmek yerine Clear() + Add() yapmak genelde daha sorunsuzdur.

Satırdaki model INotifyPropertyChanged uygulamıyor. Liste doğru görünür ama satır içindeki hiçbir değişiklik ekrana yansımaz. Listenin kendisi güncellenirken satırların donuk kalması bunun klasik belirtisidir.

Şablon içinden view model'e doğrudan bağlanmak. Sessiz başarısızlığın bir numaralı sebebi. Komutlar için RelativeBindingSource + FindAncestorBindingContext şart.

CommandParameter unutmak. Komut çalışır ama hangi ürün için olduğunu bilemez. Path(".") satırın kendisini geçirir.

Koleksiyonu arka plan iş parçacığından değiştirmek. ObservableCollection UI thread'e bağlıdır; servisten dönen veriyi eklerken MainThread.BeginInvokeOnMainThread(...) kullanın ya da await sonrası UI bağlamına döndüğünüzden emin olun.

Şablon içinde ağır görsel ağaçlar. Her satır yeniden kullanılırken kurulur. İç içe Grid'leri azaltmak, gölge/efekt kullanımını kısmak uzun listelerde hissedilir fark yaratır.

IsRefreshing tek yönlü bağlanmış. Spinner sonsuza kadar döner. TwoWay olmalı.

Özet

  • Şablonun binding context'i satırın kendisidir; view model'e ulaşmak için RelativeBindingSource + FindAncestorBindingContext kullanın, parametreyi Path(".") ile geçirin.
  • Koleksiyon ObservableCollection<T>, satır modeli INotifyPropertyChanged olmalı — ikisi birlikte çalışır.
  • SelectedItem ve IsRefreshing iki yönlü bağlanır.
  • Sonsuz kaydırma RemainingItemsThreshold + eşik olayı ile iki satırdır; tekrarlı tetiklenmeye karşı bir bayrak koyun.
  • Satır içi metin birleştirmeleri için modele ekstra property koymak yerine MultiConvert kullanın.

CollectionView “çalışmıyor” dendiğinde sorun neredeyse hiçbir zaman CollectionView değildir; binding context'in nerede olduğunu bilmemektir. Şablonun içine girdiğinizde dünyanın değiştiğini hatırlarsanız geri kalanı yerine oturur.

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