VodiSoft
The hardest part of CollectionView is binding inside the template: why can't the button in a row find the view model's command? Using a product catalog screen, we build the DataTemplate binding context, ancestor binding with RelativeSource, a template selector, pull-to-refresh and infinite scroll step by step in C# markup.

Almost everyone who builds their first CollectionView hits the same wall: the list renders, the rows show the right data, and then tapping the button inside a row does absolutely nothing. No exception, no log. It just silently doesn't work.

The reason fits in one sentence: inside a DataTemplate, the binding context is not the page's view model — it is the row itself. In this post we build a product catalog screen to cover that difference and everything around it: binding inside templates, commands, template selectors, empty views, pull-to-refresh and infinite scroll.

Everything is C# markup, no XAML. The library used is FmgLib.MauiMarkup 10.2.0.

Model and view model

Data first. The critical part: the item in the list must implement INotifyPropertyChanged too. It isn't enough for the view model to do it — if a field changes inside a row (the favourite flag here), the class holding that field has to announce it.

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

On the view model side the collection has to be an ObservableCollection<T> — with a List<T> the list is drawn once and nothing you add afterwards ever reaches the screen.

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 => { /* add to cart */ });

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

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

        IsLoadingMore = true;
        // ... fetch a page from the service, add to Products ...
        _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));
    }
}

Note that the commands are { get; } properties built once. Writing public ICommand Foo => new Command(...) is a common mistake: it creates a new object on every read, strands CanExecuteChanged subscriptions, and produces a separate command instance for every row in the list.

Binding the list

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 is read from the page's binding context — we are still in view model territory here. Binding SelectedItem as TwoWay pushes the selection straight into the view model, so you never have to subscribe to SelectionChanged and assign it by hand.

You may have noticed Path instead of Getter there. The reason: Selected is a Product? while SelectedItem is an object. A string path is more direct than a compiled getter across that gap — both work, one is quieter.

The heart of it: the binding context inside a DataTemplate

The moment you step into the template, the world changes. Each instance of the template is created for a single Product, and that instance's binding context is that product.

So this works inside the template:

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

And this silently does nothing:

// WRONG — there is no ToggleFavoriteCommand on Product
new Button().Command(e => e.Path("ToggleFavoriteCommand"))

The binding looks for ToggleFavoriteCommand on Product, doesn't find it, and quietly gives up. You tap the button and nothing happens.

The fix: ancestor binding with RelativeSource

The command lives in the view model, the data lives in the row. The way to join them is to tell the binding “don't look here, look at the CatalogViewModel binding context above me”:

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

Two lines, two different jobs:

  • Source(...) walks up the visual tree and finds the first ancestor whose binding context is a CatalogViewModel.
  • Path(".") in CommandParameter still runs in the row's own context and passes the Product itself as the parameter.

So the command comes from the view model and the parameter comes from the row, and Command<Product> receives it typed. This one pattern is the answer to 90% of “my CollectionView buttons don't work”.

FindAncestorBindingContext looks at binding contexts; FindAncestor looks at control types (like typeof(CatalogPage)). Since commands live in view models, you almost always want the first one.

Multiple sources inside a template

The row's subtitle is made of three fields: category, price and stock. Instead of adding a DisplayText property to the model, combine the three sources directly:

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} in stock"
                : $"{category} · {price:C} · sold out"))

Your model doesn't have to carry a display-only property for three fields; formatting stays where it belongs — in the UI.

The same idea applies to reshaping a single source:

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

The star fills in the moment IsFavorite changes — because Product implements INotifyPropertyChanged. Without it the button would be frozen at its initial state; that is the practical consequence of the warning at the top of this post.

A different template per row type

To make campaign products look different, use a DataTemplateSelector:

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

One important limit: OnSelectTemplate runs only when a row is first created. If IsCampaign changes later, the template does not swap itself. For state that changes often, use a single template with IsVisible bindings instead of separate templates.

Empty view, refresh and infinite scroll

Three practical details, three lines:

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("No products match your search.")
                    .HorizontalOptions(LayoutOptions.Center))
            .ItemTemplate(BuildItemTemplate()))
  • IsRefreshing must be TwoWay. Leave it one-way and the spinner keeps turning forever: MAUI sets it to true and the view model can never set it back to false.
  • RemainingItemsThreshold + OnRemainingItemsThresholdReached is the whole of infinite scroll: five rows before the end, the next page loads. The IsLoadingMore guard inside LoadPageAsync is not optional — the event fires repeatedly during a fast scroll and without a guard you will fetch the same page several times.
  • Without ItemsUpdatingScrollMode.KeepScrollOffset, appending data jumps the list back to the top.

The complete page

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("Search products")
                    .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("No products match your search.")
                                    .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} in stock"
                                : $"{category} · {price:C} · sold out"))
            )
            .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("Add to cart")
                    .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) => $"SALE · {name}")),

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

Pulling the templates into their own methods is not only cosmetic: Build() becomes unreadable as it grows, and keeping template logic separate makes it easy to reuse a row in another list later.

Common mistakes

Using List<T>. The list renders once and Add/Remove never reach the screen. Use ObservableCollection<T>. When the whole collection changes, Clear() + Add() is usually smoother than swapping the reference.

The row model doesn't implement INotifyPropertyChanged. The list looks right, but nothing inside a row ever updates. Rows staying frozen while the list itself updates is the classic symptom.

Binding to the view model directly from inside a template. The number one cause of silent failure. Commands need RelativeBindingSource with FindAncestorBindingContext.

Forgetting CommandParameter. The command runs but has no idea which product it was for. Path(".") passes the row itself.

Mutating the collection from a background thread. ObservableCollection is bound to the UI thread; use MainThread.BeginInvokeOnMainThread(...) when adding service results, or make sure you are back on the UI context after your await.

Heavy visual trees inside the template. Every row is built as it is recycled. Fewer nested Grids and lighter effects make a noticeable difference in long lists.

IsRefreshing bound one-way. The spinner never stops. It has to be TwoWay.

Summary

  • The template's binding context is the row; reach the view model with RelativeBindingSource + FindAncestorBindingContext and pass the parameter with Path(".").
  • The collection must be an ObservableCollection<T> and the row model must implement INotifyPropertyChanged — they only work together.
  • Bind SelectedItem and IsRefreshing two-way.
  • Infinite scroll is two lines with RemainingItemsThreshold plus its event; add a flag against repeated firing.
  • For composed row text, use MultiConvert instead of adding display properties to the model.

When someone says CollectionView “doesn't work”, the problem is almost never CollectionView — it is not knowing where the binding context is. Remember that the world changes when you step inside the template, and the rest falls into place.

Related Articles

FmgLib.MauiMarkup or CommunityToolkit.Maui.Markup? An Honest Comparison

There are two ways to build a .NET MAUI UI in C# instead of XAML, and both use fluent methods. Nearly every difference between them follows from a single architectural choice: hand-curated extensions or a source generator? This post walks through the differences, when each library is the better pick, and what actually happens — measured, not guessed — when you put both in one project.

Read More

VodiSoft

Turn your software idea into a revenue-generating product

For web, mobile, .NET, SaaS and integration projects, we can map the risks, timeline and fastest path to commercial value.

Get a Quote Meeting