.NET MAUI - How to bind to a property of an ObservableCollection Item on custom element of type ContentView

1.5k Views Asked by At

I have a model TrackModel, that models my music tracks (now it's just a class of a mock):

public partial class TrackModel : ObservableObject
{
    /// <summary>
    /// Name of the track that will be displayed on the track list
    /// </summary>
    [ObservableProperty]
    private string title;

    /// <summary>
    /// The source file, represented as FileMediaSource 
    /// </summary>
    [ObservableProperty]
    private MediaSource source; //TODO: later rename this ti FileMediaResource

    public TrackModel()
    {
    }

    public TrackModel(string title, MediaSource source)
    {
        this.title = title;
        this.source = source;
    }
}

On the page my ViewModel is defined like this:

public partial class MusicPageViewModel : ObservableObject
{
    [ObservableProperty]
    private MediaSource source;

    [ObservableProperty]
    private ObservableCollection<TrackModel> tracks;

    public int Index { get; private set; } = 0;

    public MusicPageViewModel()
    {
    }

    public void FetchTracks()
    {
        Tracks = new ObservableCollection<TrackModel>
        {
            new TrackModel { Title = "Mostantol", Source = MediaSource.FromResource("Mostantol.mp3") },
            new TrackModel { Title = "In The Dark", Source = MediaSource.FromResource("In The Dark.mp3"), },
            new TrackModel { Title = "Midnight Sky", Source = MediaSource.FromResource("Midnight Sky.mp3") },
            new TrackModel { Title = "Fever", Source = MediaSource.FromResource("Fever.mp3") },
            new TrackModel { Title = "Prisoner", Source = MediaSource.FromResource("Prisoner.mp3") },
            new TrackModel { Title = "Taki Taki", Source = MediaSource.FromResource("Taki Taki.mp3") },
            new TrackModel { Title = "Solo", Source = MediaSource.FromResource("Solo.mp3") },
            new TrackModel { Title = "Dark Night Rider", Source = MediaSource.FromResource("Dark Night Rider.mp3") },
            new TrackModel { Title = "Omen III", Source = MediaSource.FromResource("Omen III.mp3") },
            new TrackModel { Title = "Get-A-Way", Source = MediaSource.FromResource("Get-A-Way.mp3") },
            new TrackModel { Title = "3AM Eternal", Source = MediaSource.FromResource("3AM Eternal.mp3") },
            new TrackModel { Title = "Baby Got Back", Source = MediaSource.FromResource("Baby Got Back.mp3") },
            new TrackModel { Title = "Ice Ice Baby", Source = MediaSource.FromResource("Ice Ice Baby.mp3") },
            new TrackModel { Title = "Holiday rap", Source = MediaSource.FromResource("Holiday rap.mp3") },
            new TrackModel { Title = "Jo reggelt!", Source = MediaSource.FromResource("Jo reggelt!.mp3") },
            new TrackModel { Title = "Aj lav ju", Source = MediaSource.FromResource("Aj lav ju.mp3") },
            new TrackModel { Title = "Zug a Volga", Source = MediaSource.FromResource("Zug a Volga.mp3") },
            new TrackModel { Title = "Mi kene, ha volna", Source = MediaSource.FromResource("Mi kene, ha volna.mp3") },
            new TrackModel { Title = "Megamix (Slager FM Bonus Track)", Source = MediaSource.FromResource("Megamix (Slager FM Bonus Track).mp3") },
        };
    }

    public MediaSource FirstTrack => Source = Tracks[Index].Source;

    public MediaSource NextTrack()
    {
        Index++;
        Index = Index > Tracks.Count - 1 ? 0 : Index;

        return Tracks[Index].Source;
    }

    public MediaSource PreviousTrack()
    {
        Index--;
        Index = Index < 0 ? Tracks.Count - 1 : Index;

        return Tracks[Index].Source;
    }
}

The page code behind:

public partial class MusicPage : ContentPage
{
    private readonly ILogger logger;

    public MusicPageViewModel ViewModel => (MusicPageViewModel)base.BindingContext;

    public MusicPage(MusicPageViewModel viewModel, ILogger<MusicPage> logger)
    {
        BindingContext = viewModel;
        this.logger = logger;

        InitializeComponent();
    }

    protected override void OnAppearing()
    {
        base.OnAppearing();
        ViewModel.FetchTracks();

        audioPlayer.Source = ViewModel.FirstTrack;
    }

    public void OnMediaEnded(object sender, EventArgs e) => audioPlayer.Source = ViewModel.NextTrack();

    public void OnNext(object sender, EventArgs e) => audioPlayer.Source = ViewModel.NextTrack();

    public void OnPrevious(object sender, EventArgs e) => audioPlayer.Source = ViewModel.PreviousTrack();
}

XAML for the main page:

<?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:views="clr-namespace:Solution.MobileApp.Pages.Tabs"
             xmlns:component="clr-namespace:Solution.MobileApp.Components"
             xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
             xmlns:models="clr-namespace:Solution.MobileApp.Models"
             xmlns:viewModel="clr-namespace:Solution.MobileApp.ViewModels"
             x:DataType="viewModel:MusicPageViewModel"
             x:Class="Solution.MobileApp.Pages.Tabs.MusicPage"
             BackgroundColor="#333333"
             Shell.NavBarIsVisible="False">

    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="*" />
            <RowDefinition Height="*" />
        </Grid.RowDefinitions>
        <CollectionView Grid.Row="0" x:Name="collectionView"
                        ItemsSource="{Binding Tracks}"
                        BackgroundColor="#333333"
                        Margin="0,5,0,0">
            <CollectionView.ItemsLayout>
                <LinearItemsLayout Orientation="Vertical" ItemSpacing="5"/>
            </CollectionView.ItemsLayout>
            <CollectionView.ItemTemplate>
                <DataTemplate x:DataType="models:TrackModel">
                    <component:TrackListItemComponent Track="{Binding .}"/>
                </DataTemplate>
            </CollectionView.ItemTemplate>
        </CollectionView>
        <Frame Grid.Row="1">
            <component:AudioPlayerComponent x:Name="audioPlayer"
                                            MediaEnded="OnMediaEnded" 
                                            Next="OnNext"
                                            Previous="OnPrevious"/>
        </Frame>
    </Grid>
</ContentPage>

Here I have definied a template for a collection view with a TrackListItemComponent. The TrackListItemComponent looks like this:

public partial class TrackListItemComponent : ContentView
{
    public static readonly BindableProperty TrackProperty = BindableProperty.Create(nameof(Track), typeof(TrackModel), typeof(TrackListItemComponent), null);

    public TrackModel Track
    {
        get => (TrackModel)GetValue(TrackProperty);
        set => SetValue(TrackProperty, value);
    }

    public TrackListItemComponent()
    {
        BindingContext = this;

        InitializeComponent();
    }

    private void OnDelete(object sender, EventArgs e)
    { }

    private void OnTapp(object sender, EventArgs e)
    { }
}
<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="Solution.MobileApp.Components.TrackListItemComponent"
             xmlns:vm="clr-namespace:Solution.MobileApp.Models"
             x:DataType="vm:TrackModel">

    <SwipeView x:Name="swipe">
        <SwipeView.RightItems>
            <SwipeItems>
                <SwipeItem IconImageSource="delete.png"
                           BackgroundColor="Red"
                           Invoked="OnDelete" />
            </SwipeItems>
        </SwipeView.RightItems>
        <Frame BindingContext="{x:Reference this}"
               Margin="2"
               BackgroundColor="#4D4D4D"
               BorderColor="Black"
               HasShadow="False">

            <Frame.GestureRecognizers>
                <TapGestureRecognizer Tapped="OnTapp"/>
            </Frame.GestureRecognizers>

            <StackLayout Orientation="Horizontal" HorizontalOptions="EndAndExpand" VerticalOptions="Center"
                         FlowDirection="LeftToRight">
                <Label Text="{Binding Title}" TextColor="White" />
                <Label Text="{Binding Source.Id}" TextColor="Wheat" />
            </StackLayout>
        </Frame>
    </SwipeView>

</ContentView>

My problem is that the CollectionView's item of type TrackModel is not binding to the component's Track property (the get/set never hits). I can see the 19 element is the CollectionView, but all are empty.

I can't figure out what I did wrong.

2

There are 2 best solutions below

1
Alexandar May - MSFT On

It looks like you are not setting the BindingContext for the TrackModel in the ContentView. You can either set the BindingContext in Xaml like below or in code-behind.

<ContentView.BindingContext>
     <Model:TrackModel />
</ContentView.BindingContext>
0
Julian On

Problem

You're not correctly binding to the Track in your TrackListItemComponent. You have set the BindingContext to the component itself, therefore, the x:DataType cannot be TrackModel.

Apart from that, you don't need to set the BindingContext of the Frame, that's redundant in this case. I assume this was an attempt to fix the Binding.

Solutions

You have two options to fix this, either by updating the XAML bindings and removing the x:DataType declaration or by removing the BindableProperty and inheriting the BindingContext.

Option 1: Fix XAML binding and DataType

To fix this, you could change the XAML like this:

<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="Solution.MobileApp.Components.TrackListItemComponent"
             xmlns:vm="clr-namespace:Solution.MobileApp.Models">

    <SwipeView x:Name="swipe">
        <SwipeView.RightItems>
            <SwipeItems>
                <SwipeItem IconImageSource="delete.png"
                           BackgroundColor="Red"
                           Invoked="OnDelete" />
            </SwipeItems>
        </SwipeView.RightItems>
        <Frame Margin="2"
               BackgroundColor="#4D4D4D"
               BorderColor="Black"
               HasShadow="False">

            <Frame.GestureRecognizers>
                <TapGestureRecognizer Tapped="OnTapp"/>
            </Frame.GestureRecognizers>

            <StackLayout Orientation="Horizontal" HorizontalOptions="EndAndExpand" VerticalOptions="Center"
                         FlowDirection="LeftToRight">
                <Label Text="{Binding Track.Title}" TextColor="White" />
                <Label Text="{Binding Track.Source.Id}" TextColor="Wheat" />
            </StackLayout>
        </Frame>
    </SwipeView>

</ContentView>

It's also good practice to set the BindingContext only after calling InitializeComponent():

public TrackListItemComponent()
{
    InitializeComponent();
    BindingContext = this;        
}

Option 2: Remove BindableProperty and inherit BindingContext

Remove the Track BindableProperty and do not set the BindingContext.

public partial class TrackListItemComponent : ContentView
{
    public TrackListItemComponent()
    {
        InitializeComponent();

        //Do NOT set BindingContext, it will be inherited
    }

    private void OnDelete(object sender, EventArgs e)
    { }

    private void OnTapp(object sender, EventArgs e)
    { }
}

Keep the XAML as is (just remove the redundant BindingContext setting on the Frame):

<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="Solution.MobileApp.Components.TrackListItemComponent"
             xmlns:vm="clr-namespace:Solution.MobileApp.Models"
             x:DataType="vm:TrackModel">

    <SwipeView x:Name="swipe">
        <SwipeView.RightItems>
            <SwipeItems>
                <SwipeItem IconImageSource="delete.png"
                           BackgroundColor="Red"
                           Invoked="OnDelete" />
            </SwipeItems>
        </SwipeView.RightItems>
        <Frame Margin="2"
               BackgroundColor="#4D4D4D"
               BorderColor="Black"
               HasShadow="False">

            <Frame.GestureRecognizers>
                <TapGestureRecognizer Tapped="OnTapp"/>
            </Frame.GestureRecognizers>

            <StackLayout Orientation="Horizontal" HorizontalOptions="EndAndExpand" VerticalOptions="Center"
                         FlowDirection="LeftToRight">
                <Label Text="{Binding Title}" TextColor="White" />
                <Label Text="{Binding Source.Id}" TextColor="Wheat" />
            </StackLayout>
        </Frame>
    </SwipeView>

</ContentView>

Then remove the Track from the DataTemplate, because the TrackModel instance will be passed down to the TrackListItemComponent as its BindingContext:

<CollectionView Grid.Row="0" x:Name="collectionView"
                ItemsSource="{Binding Tracks}"
                BackgroundColor="#333333"
                Margin="0,5,0,0">
    <CollectionView.ItemsLayout>
        <LinearItemsLayout Orientation="Vertical" ItemSpacing="5"/>
    </CollectionView.ItemsLayout>
    <CollectionView.ItemTemplate>
        <DataTemplate x:DataType="models:TrackModel">
            <component:TrackListItemComponent />
        </DataTemplate>
    </CollectionView.ItemTemplate>
</CollectionView>