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:
ComboBox
is not initialized - there is no selection.ComboBox
(by clicking on it and selecting a different value) does not trigger any code to run.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.It's hard to tell with all of this code. These are the 2 issues that I could spot.
ComboBox
's ItemsSource
is a collection of StatusViewModel
s but the SelectedValue
is bound to a Status
.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>