Search code examples
c#xamldata-bindingmauireactiveui

How to set up XAML bindings properly in a MAUI app


I wrote a simple dictionary app with a single XAML page, then I decided to put the entries and translations into separate views with their own ViewmModels and the fun began.

Basically I managed to make everything work, but it just shows me these nagging XAML binding errors, I don't know why, AI went stupid on this, would much appreciate help!

EntryModel has properties Header and Subheader

TranslationModel has its own properties

Errors look like this:

Severity    Count   Data Context    Binding Path    Target  Target Type Description File    Line    Project

Error   50  EntryViewModel  Header  Label.Text  String  'Header' property not found on 'dosham.ViewModels.EntryViewModel', target property: 'Microsoft.Maui.Controls.Label.Text'    C:\Users\x.dr\source\repos\movsar\chldr\src\chldr_maui\Views\EntryView.xaml 11  dosham

Error   50  EntryViewModel  Subheader   Label.Text  String  'Subheader' property not found on 'dosham.ViewModels.EntryViewModel', target property: 'Microsoft.Maui.Controls.Label.Text' C:\Users\x.dr\source\repos\movsar\chldr\src\chldr_maui\Views\EntryView.xaml 14  dosham

Error   50  EntryViewModel  Translations    StackLayout.ItemsSource IEnumerable 'Translations' property not found on 'dosham.ViewModels.EntryViewModel', target property: 'Microsoft.Maui.Controls.StackLayout.ItemsSource' C:\Users\x.dr\source\repos\movsar\chldr\src\chldr_maui\Views\EntryView.xaml 18  dosham

Error   50  EntryViewModel  .   EntryView.Entry EntryModel  'dosham.ViewModels.EntryViewModel' cannot be converted to type 'chldr_data.DatabaseObjects.Models.EntryModel'   C:\Users\x.dr\source\repos\movsar\chldr\src\chldr_maui\Pages\MainPage.xaml  24  dosham

I see it's saying that it can't find the Header and Subheader on my ViewModel, but it shouldn't be looking for them on the ViewModel, instead on my Entry and Translation models from ViewModel. How do I tell that without breaking the bindings that are working right now?

Also, if you have better suggestions on code organization - you're welcome to advice.

Here's the code:

MainPage

-- 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:d="http://xamarin.com/schemas/2014/forms/design"
             xmlns:vm="clr-namespace:dosham.ViewModels"
             xmlns:views="clr-namespace:dosham.Views"
             x:Class="dosham.Pages.MainPage">

    <Grid Padding="10">
        <!-- Define the rows -->
        <Grid.RowDefinitions>
            <!-- For CollectionView, takes remaining space -->
            <RowDefinition Height="*"/>
            <!-- For Search Box -->
            <RowDefinition Height="Auto"/>
        </Grid.RowDefinitions>

        <!-- Collection View for Entries -->
        <CollectionView x:Name="EntriesCollectionView" Grid.Row="0"
                        ItemsSource="{Binding FilteredEntries}">
            <CollectionView.ItemTemplate>
                <DataTemplate>

                    <views:EntryView Entry="{Binding .}"></views:EntryView>
                    
                </DataTemplate>
            </CollectionView.ItemTemplate>
        </CollectionView>

        <!-- Search Box -->
        <Entry x:Name="SearchBox" Grid.Row="1"
         Text="{Binding SearchText, Mode=TwoWay}" 
         Placeholder="Начните писать..." 
         Margin="0,0,0,10" />
    </Grid>
</ContentPage> 

-- Codebehind

 public partial class MainPage : ContentPage
    {
        public MainPage()
        {
            InitializeComponent();
            BindingContext = App.Services.GetRequiredService<MainPageViewModel>();
        }
    }

-- ViewModel

public class MainPageViewModel : ReactiveObject
{
    public IEnumerable<EntryModel> FilteredEntries
    {
        get => _filteredEntries;
        set => this.RaiseAndSetIfChanged(ref _filteredEntries, value);
    }
....

EntryView

-- View

<?xml version="1.0" encoding="utf-8" ?>
<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:views="clr-namespace:dosham.Views"
             x:Class="dosham.Views.EntryView">
    
    <StackLayout Orientation="Vertical">
        <Frame CornerRadius="10" Margin="5" Padding="10" BorderColor="#CACCaa" BackgroundColor="#f9F9F9">
            <StackLayout Orientation="Vertical" Spacing="5">
                <!-- Entry Content -->
                <Label Text="{Binding Header}" FontSize="Medium" FontAttributes="Bold" TextColor="#000" />

                <!-- Source Name -->
                <Label Text="{Binding Subheader}" FontSize="Micro" TextColor="#cba" />


                <!-- Translation views -->
                <StackLayout BindableLayout.ItemsSource="{Binding Translations}">
                    <BindableLayout.ItemTemplate>
                        <DataTemplate>
                            <views:TranslationView Translation="{Binding .}" />
                        </DataTemplate>
                    </BindableLayout.ItemTemplate>
                </StackLayout>
            </StackLayout>
        </Frame>
    </StackLayout>

</ContentView>

-- Codebehind

public partial class EntryView : ContentView
{
    private EntryViewModel _viewModel;

    public static readonly BindableProperty EntryProperty =
         BindableProperty.Create(nameof(_viewModel.Entry), typeof(EntryModel), typeof(EntryView));

    public EntryView()
    {
        _viewModel = App.Services.GetRequiredService<EntryViewModel>();
        BindingContext = _viewModel;
        InitializeComponent();
    }
}

-- ViewModel

 public class EntryViewModel : ViewModelBase
 {
     #region Properties, Actions and Constructors
     public EntryModel Entry { get; set; }
     ...
 }

TranslationView

-- View

<?xml version="1.0" encoding="utf-8" ?>
<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:local="clr-namespace:dosham.Views"
             x:Class="dosham.Views.TranslationView">

    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="Auto" />
            <ColumnDefinition Width="*" />
        </Grid.ColumnDefinitions>

        <!-- Language Code Label -->
        <Label Grid.Column="0"
                  Text="{Binding LanguageCode, StringFormat='{0}:'}" 
                  FontSize="Small" 
                  FontAttributes="Italic" 
                  TextColor="#AA6666" />

        <!-- Content Label -->
        <Label Grid.Column="1"
                  Text="{Binding Content, StringFormat=' {0}'}" 
                  FontSize="Small" 
                  TextColor="#666666" />
    </Grid>

</ContentView>

-- Codebehind

public partial class TranslationView : ContentView
{
    private readonly TranslationViewModel _viewModel;

    public static readonly BindableProperty TranslationProperty =
     BindableProperty.Create(nameof(_viewModel.Translation), typeof(TranslationModel), typeof(TranslationView));

    public TranslationView()
    {
        _viewModel = App.Services.GetRequiredService<TranslationViewModel>();
        BindingContext = _viewModel;

        InitializeComponent();
    }
}

-- ViewModel

public class TranslationViewModel : ViewModelBase
{
    public TranslationViewModel(ContentStore contentStore, UserStore userStore) : base(contentStore, userStore)
    { }
    public TranslationModel Translation { get; set; }
....
}

Here's the visual: enter image description here


Solution

  • After two weeks of suffering and frustration, I ended up with a genius solution - got rid of the EntryView and TranslationView converting both of them to DataTemplates for better performance, then created a single view to hold the whole thing - EntriesView with a EntriesViewModel.

    I also changed ContentView to reactive:ReactiveContentView and ContentPage to reactive:ReactiveContentPage.

    The resulting code looks like this:

    MainPage

    public partial class MainPage : ReactiveContentPage<MainPageViewModel>
    {
        public MainPage()
        {
            ViewModel = App.Services.GetRequiredService<MainPageViewModel>();
            InitializeComponent();
    
            // Reactive bindings
            this.WhenActivated(disposable =>
            {
                this.OneWayBind(ViewModel, vm => vm.FilteredEntries, v => v.entriesView.ViewModel!.Entries);
            });
        }
    }
    

    EntriesView

    <?xml version="1.0" encoding="utf-8" ?>
    <reactive:ReactiveContentView   xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
                                    xmlns:viewModels ="clr-namespace:dosham.ViewModels"
                                    xmlns:reactive="clr-namespace:ReactiveUI.Maui;assembly=ReactiveUI.Maui"
                                    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
                                    x:TypeArguments="viewModels:EntriesViewModel"
                                    x:Class="dosham.Views.EntriesView">
    
        <CollectionView x:Name="EntriesCollection"
                        ItemTemplate="{StaticResource EntryTemplate}"
                        ItemsSource="{Binding Entries}">
        </CollectionView>
    
    </reactive:ReactiveContentView>
    

    Codebehind

    public partial class EntriesView : ReactiveContentView<EntriesViewModel>
    {
        public EntriesView()
        {
            ViewModel = App.Services.GetRequiredService<EntriesViewModel>();
            InitializeComponent();
        }
    }
    

    ViewModel (ViewModelBase inherits from ReactiveObject)

    public class EntriesViewModel : ViewModelBase
    {
        private IEnumerable<EntryModel> _entries;
        public IEnumerable<EntryModel> Entries
        {
            get => _entries;
            set => this.RaiseAndSetIfChanged(ref _entries, value);
        }
    }
    

    Works perfectly well :)