0

I am attempting to implement a proposed solution to my other question here. The goal is to use databinding in place of handling ComboBox Loaded events.

I have two nested ViewModels I am trying to databind (similar to the simplified question here), where a ListView displays a list of the outter ViewModel (TaskViewModel) while the ComboBox inside of the ListView displays a list of the inner ViewModel (StatusViewModel), and the SelectedItem inside of the ComboBox is TwoWay databound to the Status property on the TaskViewModel.

I keep getting an unexpected uncaught exception, which is being cause by the Set on TaskViewModel.Status setting a null value. When using the Visual Studio StackTrace, all I can find is that this setter is being called from "External Code".

If I uncomment the commented out code in TaskViewModel.cs, the code runs but the ComboBox binding does nothing. I implemented the solution to the question here for nested view models with INotifyPropertyChanged on my TaskViewModel.Status, but that did not seem to fix my issue.

Where is this null value coming from? I have verified that the list of MyTask going into SetProjectTasks() never contains a task with Status value null.

What is the proper way to implement this (list of outer view models bound to ListView with nested view model property on that view model being bound to ComboBox)? Is my approach wrong?

Page.xaml

<ListView x:Name="TasksListView"
          Grid.Row="1"
          Grid.ColumnSpan="2"
          ItemsSource="{x:Bind MyTasks}"
          SelectionMode="None"
          IsItemClickEnabled="True"
          ItemClick="TasksListView_ItemClick">
    <ListView.ItemTemplate>
        <DataTemplate x:DataType="viewmodels:TaskViewModel">
            <Grid>
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="auto"></ColumnDefinition>
                    <ColumnDefinition Width="*"></ColumnDefinition>
                </Grid.ColumnDefinitions>

                <ComboBox x:Name="StatusComboBox"
                          Tag="{x:Bind ID, Mode=OneWay}"
                          Grid.Column="0"
                          Margin="0,0,10,0"
                          VerticalAlignment="Center"
                          ItemsSource="{Binding Path=ProjectTaskStatuses, ElementName=RootPage}"
                          SelectedValue="{x:Bind Status, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}">
                    <ComboBox.ItemTemplate>
                        <DataTemplate x:DataType="viewmodels:StatusViewModel">
                            <Grid>
                                <Grid.ColumnDefinitions>
                                    <ColumnDefinition Width="auto"></ColumnDefinition>
                                    <ColumnDefinition Width="*"></ColumnDefinition>
                                </Grid.ColumnDefinitions>

                                <Rectangle Grid.Column="0"
                                           Margin="0,0,10,0"
                                           Height="10"
                                           Width="10"
                                           StrokeThickness="1">
                                    <Rectangle.Fill>
                                        <SolidColorBrush Color="{x:Bind Color}"></SolidColorBrush>
                                    </Rectangle.Fill>
                                    <Rectangle.Stroke>
                                        <SolidColorBrush Color="{x:Bind Color}"></SolidColorBrush>
                                    </Rectangle.Stroke>
                                </Rectangle>

                                <TextBlock Grid.Column="1"
                                           Text="{x:Bind Name}"></TextBlock>
                            </Grid>
                        </DataTemplate>
                    </ComboBox.ItemTemplate>
                </ComboBox>

                <TextBlock Grid.Column="1"
                           Text="{x:Bind Name}"></TextBlock>
            </Grid>
        </DataTemplate>
    </ListView.ItemTemplate>
</ListView>

Page.xaml.cs

public ObservableCollection<StatusViewModel> ProjectTaskStatuses { get; set; }

private ObservableCollection<TaskViewModel> MyTasks { get; set; }

public void SetProjectStatuses(List<Status> statuses)
{
    this.ProjectTaskStatuses.Clear();
    statuses.ForEach(status => this.ProjectTaskStatuses.Add(new StatusViewModel(status)));
}

public void SetProjectTasks(List<MyTask> tasks)
{
    this.MyTasks.Clear();
    tasks.ForEach(task => this.MyTasks.Add(new TaskViewModel(task)));
}

TaskViewModel.cs

public class TaskViewModel : INotifyPropertyChanged
{
    private MyTask _model;
    public MyTask Model
    {
        get => new MyTask(this._model) { Status = this._status.Model };
    }

    public string ID
    {
        get => this._model?.ID;
        set
        {
            this._model.ID = value;
            this.RaisePropertyChanged(nameof(ID));
        }
    }

    public string Name
    {
        get => this._model?.Name;
        set
        {
            this._model.Name = value;
            this.RaisePropertyChanged(nameof(Name));
        }
    }

    private StatusViewModel _status;
    public Status Status
    {
        get => this._status?.Model;
        set
        {
            // COMMENTED OUT CODE FOR TESTING - THIS IS WHERE THE UNEXPECTED NULL HAPPENS
            //if (value == null)
            //{
            //    System.Diagnostics.Debug.WriteLine("NULL STATUS BEING SET TO - " + this._model.ID + " " + this._model.Name + " " + this._model.Status.Name);
            //    return;
            //}

            if (this._status != null)
                this._status.PropertyChanged -= StatusChanged;

            this._status = new StatusViewModel(value);

            if (this._status != null)
                this._status.PropertyChanged += StatusChanged;

            this.RaisePropertyChanged(nameof(Status));

            void StatusChanged(object sender, PropertyChangedEventArgs e) => this.RaisePropertyChanged(nameof(Status));
        }
    }

    /// <summary>
    /// Raised when a bindable property of the viewmodel has changed.
    /// </summary>
    public event PropertyChangedEventHandler PropertyChanged;
    private void RaisePropertyChanged(string name)
    {
        if (PropertyChanged != null)
        {
            PropertyChanged(this, new PropertyChangedEventArgs(name));
        }
    }

    public TaskViewModel(MyTask task)
    {
        this._model = task;
        this._status = new StatusViewModel(task.Status);
    }
}

StatusViewModel.cs

public class StatusViewModel : INotifyPropertyChanged
{
    private Status _model;
    public Status Model
    {
        get => new Status(this._model);
    }

    public string ID
    {
        get => this._model?.ID;
        set
        {
            this._model.ID = value;
            this.RaisePropertyChanged(nameof(ID));
        }
    }

    public string Name
    {
        get => this._model?.Name;
        set
        {
            this._model.Name = value;
            this.RaisePropertyChanged(nameof(Name));
        }
    }

    public Color Color
    {
        get => this._model.Color;
        set
        {
            this._model.Color = value;
            this.RaisePropertyChanged(nameof(Color));
        }
    }

    /// <summary>
    /// Raised when a bindable property of the viewmodel has changed.
    /// </summary>
    public event PropertyChangedEventHandler PropertyChanged;
    private void RaisePropertyChanged(string name)
    {
        if (PropertyChanged != null)
        {
            PropertyChanged(this, new PropertyChangedEventArgs(name));
        }
    }

    public StatusViewModel(Status status)
    {
        this._model = status;
    }
}

UPDATE

I added the CommunityToolkit.Mvvm NuGet package to my project, and replaced the MyTask model as suggested. I also eliminated the StatusViewModel entirely and replaced it with just Status. This simplified the code, but same problem. I subscribed to the PropertyChanged event with a print statement in the handler, and confirm that it is never being fired:

Code behind changes:

public partial class TaskViewModel : ObservableObject
{
    [ObservableProperty]
    private MyTask _model;

    public TaskViewModel(MyTask task)
    {
        this._model = task;
    }
}

public void SetProjectTasks(List<MyTask> tasks)
{
    MyTasks.Clear();
    TaskViewModel taskViewModel = new TaskViewModel(task)
    taskViewModel.PropertyChanged += this.ViewModel_PropertyChanged;
    tasks.ForEach(task => this.MyTasks.Add(taskViewModel ));
}

private void TaskViewModel_PropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        // Confirmed with debugger and print statement this event handler never runs... even when changing the status ComboBox.
        TaskViewModel model = sender as TaskViewModel;
        System.Diagnostics.Debug.WriteLine("Model for task: " + model.Model.Name +
            " property " + e.PropertyName +
            " changed to " + model.GetType().GetProperty(e.PropertyName).GetValue(model).ToString());
    }

XAML changes:

<ComboBox x:Name="StatusComboBox"
                  Tag="{x:Bind Model.ID, Mode=OneWay}"
                  Grid.Column="0"
                  Margin="0,0,10,0"
                  VerticalAlignment="Center"
                  ItemsSource="{Binding Path=ProjectTaskStatuses, ElementName=RootPage}"
                  SelectedValue="{x:Bind Model.Status, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}">
    <ComboBox.ItemTemplate>
        <DataTemplate x:DataType="models:Status">
            <Grid>
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="auto"></ColumnDefinition>
                        <ColumnDefinition Width="*"></ColumnDefinition>
                </Grid.ColumnDefinitions>

                <Rectangle Grid.Column="0"
                           Margin="0,0,10,0"
                           Height="10"
                           Width="10"
                           StrokeThickness="1">
                    <Rectangle.Fill>
                        <SolidColorBrush Color="{x:Bind Color}"></SolidColorBrush>
                    </Rectangle.Fill>
                    <Rectangle.Stroke>
                        <SolidColorBrush Color="{x:Bind Color}"></SolidColorBrush>
                    </Rectangle.Stroke>
                </Rectangle>

                <TextBlock Grid.Column="1"
                           Text="{x:Bind Name}"></TextBlock>
            </Grid>
        </DataTemplate>
    </ComboBox.ItemTemplate>
</ComboBox>

Issues:

  1. The ComboBox is not initialized - there is no selection.
  2. Changing the ComboBox (by clicking on it and selecting a different value) does not trigger any code to run.
  3. The ComboBox data binding appears to be over-writing the Status value to null at some point. I have confirmed the MyTask.Status values being set in SetProjectTasks() still never has a null value.

1 Answers1

1

It's hard to tell with all of this code. These are the 2 issues that I could spot.

  • The ComboBox's ItemsSource is a collection of StatusViewModels but the SelectedValue is bound to a Status.
  • The Model property in the StatusViewModel always return a new instance.

This might not be an answer but let me show you something close but with less code by using the CommunityToolkit.Mvvm NuGet package.

public class Status
{
    public string? ID { get; internal set; }
    public string? Name { get; internal set; }
    public Color Color { get; internal set; }
}

public class MyTask
{
    public string? ID { get; internal set; }
    public string? Name { get; internal set; }
    public Status? Status { get; set; }
}

public partial class TaskViewModel : ObservableObject
{
    [ObservableProperty]
    private MyTask _model;

    public TaskViewModel(MyTask task)
    {
        this._model = task;
    }
}

public sealed partial class MainPage : Page
{
    public MainPage()
    {
        InitializeComponent();
    }

    public ObservableCollection<Status> ProjectTaskStatuses { get; set; } = new();

    private ObservableCollection<TaskViewModel> MyTasks { get; set; } = new();

    public void SetProjectStatuses(List<Status> statuses)
    {
        ProjectTaskStatuses.Clear();
        statuses.ForEach(status => this.ProjectTaskStatuses.Add(status));
    }

    public void SetProjectTasks(List<MyTask> tasks)
    {
        MyTasks.Clear();
        tasks.ForEach(task => this.MyTasks.Add(new TaskViewModel(task)));
    }

    private void SetTasksButton_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
    {
        List<MyTask> tasks = new();
        Status? defaultStatus = ProjectTaskStatuses.FirstOrDefault();
        tasks.Add(new MyTask { ID = "1", Name = "Task 1", Status = defaultStatus });
        tasks.Add(new MyTask { ID = "2", Name = "Task 2", Status = defaultStatus });
        tasks.Add(new MyTask { ID = "3", Name = "Task 3", Status = defaultStatus });
        SetProjectTasks(tasks);
    }

    private void SetStatusOptionsButton_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
    {
        List<Status> statuses = new();
        statuses.Add(new Status { ID = "1", Name = "Status 1", Color = Colors.Red });
        statuses.Add(new Status { ID = "2", Name = "Status 2", Color = Colors.Green });
        statuses.Add(new Status { ID = "3", Name = "Status 3", Color = Colors.Blue });
        SetProjectStatuses(statuses);
    }
}
<Grid RowDefinitions="Auto,*">
    <StackPanel
        Grid.Row="0"
        Orientation="Horizontal">
        <Button
            Click="SetStatusOptionsButton_Click"
            Content="Set status options" />
        <Button
            Click="SetTasksButton_Click"
            Content="Set taks" />
    </StackPanel>
    <ListView
        x:Name="TasksListView"
        Grid.Row="1"
        IsItemClickEnabled="True"
        ItemsSource="{x:Bind MyTasks}"
        SelectionMode="None">

        <ListView.ItemTemplate>
            <DataTemplate x:DataType="local:TaskViewModel">
                <Grid>
                    <Grid.ColumnDefinitions>
                        <ColumnDefinition Width="auto" />
                        <ColumnDefinition Width="*" />
                    </Grid.ColumnDefinitions>
                    <ComboBox
                        Grid.Column="0"
                        ItemsSource="{Binding ElementName=RootPage, Path=ProjectTaskStatuses}"
                        SelectedItem="{x:Bind Model.Status, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}">
                        <ComboBox.ItemTemplate>
                            <DataTemplate x:DataType="local:Status">
                                <TextBlock Text="{x:Bind Name, Mode=OneWay}" />
                            </DataTemplate>
                        </ComboBox.ItemTemplate>
                    </ComboBox>
                    <TextBlock
                        Grid.Column="1"
                        Text="{x:Bind Model.Name, Mode=OneWay}" />
                </Grid>
            </DataTemplate>
        </ListView.ItemTemplate>
    </ListView>
</Grid>
Andrew KeepCoding
  • 7,040
  • 2
  • 14
  • 21
  • I adjusted my project as suggested - definitely simplified my code! Thank you! Did not seem to make a difference with the problem though. I updated the question to reflect this. – Michael Kintscher they-them Jul 28 '23 at 22:01
  • I actually implemented a sample app for this answer and it works. Can you make sure the your page is named "RootPage" and there's no misspelling in the binding code? – Andrew KeepCoding Jul 29 '23 at 07:25
  • As a sanity check, I copy and pasted the code from this solution into a new project - and it did indeed work. I then did a side-by-side comparison with my original code, and caught two differences: 1) I bound to `SelectedValue` instead of `SelectedItem` on the `ComboBox`, and 2) I did not have `Mode=OneWay` on any bindings in the `ComboBox`. I changed those to match this solution - neither fixed it. This solution from scratch works while my original code does not. – Michael Kintscher they-them Jul 30 '23 at 00:16
  • I am wondering if this is an issue with references. This solution assigns a `Status` to each `Task` using the same references that are placed into the `ProjectTaskStatuses`. My original code does not, as I am sourcing the data from different API calls. Next I will try updating my original code to see if I can similarly assign the `Status` property using the same references that I place into `MyProjectStatuses`. – Michael Kintscher they-them Jul 30 '23 at 00:18
  • I have confirmed, the references were the issue. I updated my original code so that I explicitly overwrote the references to the `Status` objects in the `MyTask.Status` member so that they were from the set of references (`Status` objects) placed into `ProjectTaskStatuses`. That fixed the data binding, and now the `ComboBox` are initialized properly. Now the only remaining issue, is that changing the value in the `ComboBox` does not raise the `TaskViewModel.PropertyChanged` event of the bound viewmodel. – Michael Kintscher they-them Jul 30 '23 at 00:42
  • I accepted this answer because it has been incredibly helpful! The issue in the comments above still remains to be solved - but I plan to update this with a link to an answer once I figure it out (unless someone else beats me to it!) – Michael Kintscher they-them Aug 27 '23 at 18:16