Search code examples
c#listviewdata-bindingcomboboxwinui-3

How to TwoWay data-bind nested INotifyPropertyChanged ViewModels to ComboBox inside of ListView in WinUI 3?


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.

Solution

  • 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>