.NET MAUI binding ItemSelected event of ListView to ViewModel
Asked Answered
M

2

5

I am trying to bind the ItemSelected of a ListView to a View Model, but am experiencing some issues (due to my own misunderstands around how it all works).

I have view:

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:Local="clr-namespace:FireLearn.ViewModels"
             x:Class="FireLearn.MainPage"
             Title="Categories">

    <ContentPage.BindingContext>
        <Local:CategoryViewModel/>
    </ContentPage.BindingContext>
    <NavigationPage.TitleView>
        <Label Text="Home"/>
    </NavigationPage.TitleView>
    <ListView
        ItemsSource="{Binding Categories}"
        HasUnevenRows="True"
        IsPullToRefreshEnabled="True"
        IsRefreshing="{Binding ListRefreshing, Mode=OneWay}"
        RefreshCommand="{Binding RefreshCommand}"
        ItemSelected="{Binding OnItemTappedChanged}"
        SelectionMode="Single"
        SelectedItem="{Binding SelectedCategory}">

        <ListView.ItemTemplate>
            <DataTemplate>
                <ViewCell>
                    <HorizontalStackLayout
                        Padding="8"
                        VerticalOptions="Fill"
                        HorizontalOptions="Fill">

                        <Image Source="cafs_bubbles.png"    
                               HeightRequest="64"
                               MaximumWidthRequest="64"
                               HorizontalOptions="CenterAndExpand"
                               VerticalOptions="CenterAndExpand"/>

                        <VerticalStackLayout
                            Padding="8"
                            VerticalOptions="FillAndExpand"
                            HorizontalOptions="FillAndExpand">
                            <Label Text="{Binding FormattedName}" 
                                       SemanticProperties.HeadingLevel="Level1"
                                       FontSize="Title"
                                       HorizontalOptions="Start"/>
                            <Label Text="{Binding ItemCount}" 
                                   FontSize="Subtitle"/>
                            <Label Text="{Binding Description}" 
                                   HorizontalOptions="Center"
                                   LineBreakMode="WordWrap"
                                   FontSize="Caption"
                                   VerticalOptions="CenterAndExpand"
                                   MaxLines="0"/>
                        </VerticalStackLayout>
                    </HorizontalStackLayout>
                </ViewCell>
            </DataTemplate>
        </ListView.ItemTemplate>
    </ListView>
</ContentPage>

This is linked to a view model:

using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Diagnostics;
using System.Reflection.Emit;
using System.Runtime.CompilerServices;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using FireLearn.Models;

namespace FireLearn.ViewModels
{
    public partial class CategoryViewModel : ObservableObject
    {
        public ObservableCollection<CategoryModel> categories = new ObservableCollection<CategoryModel>();

        public ObservableCollection<CategoryModel> Categories
        {
            get => categories;
            set => SetProperty(ref categories, value);
        }

        public bool listRefreshing = false;
        public bool ListRefreshing
        {
            get => listRefreshing;
            set => SetProperty(ref listRefreshing, value);
        }

        public CategoryModel selectedCategory = new CategoryModel();
        public CategoryModel SelectedCategory
        {
            get => selectedCategory;
            set
            {
                SetProperty(ref selectedCategory, value);
               // Tap(value);
            }
        }

        public RelayCommand RefreshCommand { get; set; }
        //public RelayCommand TapCellCommand { get; set; }

        public CategoryViewModel()
        {
            loadFromSource();
            RefreshCommand = new RelayCommand(async () =>
            {
                Debug.WriteLine($"STARTED::{ListRefreshing}");
                if (!ListRefreshing)
                {
                    ListRefreshing = true;
                    try
                    {
                        await loadFromSource();
                    }
                    finally
                    {
                        ListRefreshing = false;
                        Debug.WriteLine($"DONE::{ListRefreshing}");
                    }
                }
            });
        }

        public async Task loadFromSource()
        {
            HttpClient httpClient = new()
            {
                Timeout = new TimeSpan(0, 0, 10)
            };

            Uri uri = new Uri("https://somewebsite.co.uk/wp-json/wp/v2/categories");

            HttpResponseMessage msg = await httpClient.GetAsync(uri);

            if (msg.IsSuccessStatusCode)
            {
                var result = CategoryModel.FromJson(await msg.Content.ReadAsStringAsync());
                Categories = new ObservableCollection<CategoryModel>(result);
            }

            Debug.WriteLine("List Refreshed");
        }

        public void OnItemTappedChanged(System.Object sender, Microsoft.Maui.Controls.SelectedItemChangedEventArgs e)
        {
            var x = new ShellNavigationState();
            
            Shell.Current.GoToAsync(nameof(NewPage1),
                new Dictionary<string, object>
                {
                    {
                        nameof(NewPage1),
                        SelectedCategory
                    }
                });
        }
    }
}

I get compiler error "No property, BindableProperty, or event found for "ItemSelected", or mismatching type between value and property" and am really unsure of how to resolve. If I let XAML create a new event for me, it adds it in MainPage.Xaml.Cs rather than the VM

Margheritamargi answered 31/12, 2022 at 13:50 Comment(3)
ItemSelected expects an event handler defined in the code behind of your View (the .xaml.cs file). Where is OnItemTappedChanged defined? You're trying to use a binding, which won't work for that, event handlers usually exist in the code behind only. You should use Commands in the ViewModel. Please show your code behind, add it to the question. By the way, you should make the backing fields of your properties private.Peephole
Sorry, minor error in my code which I’ve amended above. OnItemTappedChanged Is in the view model, I’ve tried also creating it as a RelayCommand, but that gives the same errorMargheritamargi
ItemSelected is an event. You can't bind a command to an event. You can't bind anything to an event. You can use EventToCommandBehavior as a workaround for this, or just have the event handler in your code behind call the VM command.Forseti
P
10

ItemSelected expects an event handler which usually only exists in the View's code behind. Since the ViewModel shouldn't know anything about the View, it's better not to mix concepts. You have a couple of options to get around this without breaking the MVVM pattern.

Option 1: Use Event Handler and invoke method of ViewModel

First, set up the code behind with the ViewModel by passing it in via the constructor and also add the event handler, e.g.:

public partial class MainPage : ContentPage
{
    private CategoryViewModel _viewModel;

    public MainPage(CategoryViewModel viewModel)
    {
        _viewModel = viewModel;
    }

    public void OnItemSelectedChanged(object sender, SelectedItemChangedEventArgs e)
    {
        //call a method from the ViewModel, e.g.
        _viewModel.DoSomething(e.SelectedItem);
    }

    //...
}

Then use the event handler from within the XAML:

<ListView
    ItemsSource="{Binding Categories}"
    HasUnevenRows="True"
    IsPullToRefreshEnabled="True"
    IsRefreshing="{Binding ListRefreshing, Mode=OneWay}"
    RefreshCommand="{Binding RefreshCommand}"
    ItemSelected="OnItemSelectedChanged"
    SelectionMode="Single"
    SelectedItem="{Binding SelectedCategory}">

    <!-- skipping irrelevant stuff -->

</ListView>

Mind that this does not use bindings.

In your CategoryViewModel you could then define a method that takes in the selected item as an argument:

public partial class CategoryViewModel : ObservableObject
{
    //...

    public void DoSomething(object item)
    {
        //do something with the item, e.g. cast it to Category
    }
}

Option 2: Use EventToCommandBehavior

Instead of handling the invocation of a ViewModel method from your code behind, you could also use the EventToCommandBehavior from the MAUI Community Toolkit:

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:Local="clr-namespace:FireLearn.ViewModels"
             xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
             x:Class="FireLearn.MainPage"
             Title="Categories">

    <ContentPage.Resources>
         <ResourceDictionary>
             <toolkit:SelectedItemEventArgsConverter x:Key="SelectedItemEventArgsConverter" />
         </ResourceDictionary>
    </ContentPage.Resources>

    <ListView
        ItemsSource="{Binding Categories}"
        HasUnevenRows="True"
        IsPullToRefreshEnabled="True"
        IsRefreshing="{Binding ListRefreshing, Mode=OneWay}"
        RefreshCommand="{Binding RefreshCommand}"
        SelectionMode="Single"
        SelectedItem="{Binding SelectedCategory}">
        <ListView.Behaviors>
            <toolkit:EventToCommandBehavior
                EventName="ItemSelected"
                Command="{Binding ItemSelectedCommand}"
                EventArgsConverter="{StaticResource SelectedItemEventArgsConverter}" />
        </ListView.Behaviors>

        <!-- skipping irrelevant stuff -->

    </ListView>

</ContentPage>

Then, in your ViewModel, you can define the ItemSelectedCommand:

public partial class CategoryViewModel : ObservableObject
{
    [RelayCommand]
    private void ItemSelected(object item)
    {
        //do something with the item, e.g. cast it to Category
    }

    // ...
}

This is the preferred way to do it. Option 1 is just another possiblity, but the EventToCommandBehavior is the better choice.

Note that this is an example using MVVM Source Generators (since you're already using the MVVM Community Toolkit). The full Command would normally be implemented like this:

public partial class CategoryViewModel : ObservableObject
{
    private IRelayCommand<object> _itemSelectedCommand;
    public IRelayCommand<object> ItemSelectedCommand => _itemSelectedCommand ?? (_itemSelectedCommand = new RelayCommand<object>(ItemSelected));

    private void ItemSelected(object item)
    {
        //do something with the item, e.g. cast it to Category
    }

    // ...
}
Peephole answered 31/12, 2022 at 14:51 Comment(3)
It doesn't work...Item is null....I think you have to use CommandParameter?Carlsbad
@Carlsbad Not sure what you mean. Where would you expect the CommandParameter property? For a ListView , the SelectedItemEventArgsConverter needs to be used instead of CommandParameter as you can see in the official documentationPeephole
Nice approach, but I want to use the same approach for "DropGestureRecognizer" where I want to use DropEventHandler from my ViewModel, unfortunately this recognizer doesn't have Behaviors property :/Anisomerous
D
1

Perfect answer from Julian, it definitely works. Make sure to add:

<ContentPage.Resources>
         <ResourceDictionary>
             <toolkit:SelectedItemEventArgsConverter x:Key="SelectedItemEventArgsConverter" />
         </ResourceDictionary>
</ContentPage.Resources>

at the beginning of the page, otherwise Item will be null as highlighted by mfranc28.

Dyspepsia answered 8/11, 2023 at 8:11 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.