I am using .net core 3.1 in VS2019. I have a user control containing a ListView listing a set of items of a price list, each of them has a Cost, Markup (percent), a discount (percent) given by a related entity called Promotion and the SalePrice, FinalPrice and PromoDescription fields computed by converters. For example, FinalPrice equals SalePrice if the item does not have a promotion, or it is (SalePrice - SalePrice*item.Promotion.Discount/100.0) otherwise.
BTW, all of these entities belong to a EF Core model with lazy loading with proxies, and all the collections are ObervableCollection, which are loaded like the following:
var dbset = Context.Promos;
dbset.Load();
return dbset.Local.ToObservableCollection();
Everything works fine except when I change the Markup or the Promotion properties, which lead to recalculating the values provided by the converters and involving none or one related entity of type Promo. Debugging the windows I can see all the changes applied correctly in the underlying view model before and after they are confirmed to the database, however I have to force it to refresh the values as in the following picture.
This is the final composition of the window:
Here it is the model:
Here is the wpf of the user control containing the listview:
<UserControl x:Class="MyApp.View.UserControls.PriceListUserControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:MyApp.View.UserControls"
xmlns:localModel="clr-namespace:MyApp.Model"
xmlns:localVm="clr-namespace:MyApp.ViewModel"
mc:Ignorable="d"
d:DesignHeight="450" d:DesignWidth="800"
x:Name="UserControlPriceList">
<UserControl.Resources>
<localVm:PriceListItemConverter x:Key="priceListItemConverter"/>
<localVm:DateConverter x:Key="dateConverter"/>
<localVm:PromoConverter x:Key="promoConverter" />
<Border Style="{StaticResource BorderStyle}" Name="bottomBorder">
<Grid>
<Grid.RowDefinitions>
<RowDefinition />
<RowDefinition />
<RowDefinition />
<RowDefinition />
<RowDefinition />
<RowDefinition />
<RowDefinition />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="106" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Style="{StaticResource SmallTitleStyle}" Margin="5"> Producto:</TextBlock>
<TextBlock Grid.Row="1" Grid.Column="0" Style="{StaticResource SmallTitleStyle}" Margin="5"> Costo:</TextBlock>
<TextBlock Grid.Row="2" Grid.Column="0" Style="{StaticResource SmallTitleStyle}" Margin="5"> Markup (%):</TextBlock>
<TextBlock Grid.Row="3" Grid.Column="0" Style="{StaticResource SmallTitleStyle}" Margin="5"> Precio de Lista:</TextBlock>
<TextBlock Grid.Row="4" Grid.Column="0" Style="{StaticResource SmallTitleStyle}" Margin="5"> Promoción:</TextBlock>
<TextBlock Grid.Row="5" Grid.Column="0" Style="{StaticResource SmallTitleStyle}" Margin="5"> Precio Final:</TextBlock>
<TextBlock Name="ProductDescription" Grid.Row="0" Grid.Column="1" HorizontalAlignment="Left"
Text="{Binding Path=Product.Description}"
Style="{StaticResource TextStyleTextBlockTextBoxLike}" Margin="8,5,0,5" />
<TextBlock Name="CostPrice" Grid.Row="1" Grid.Column="1" HorizontalAlignment="Left"
Text="{Binding Path=Product.CostPrice, StringFormat=C}"
Style="{StaticResource TextStyleTextBlockTextBoxLike}" Margin="8,5,0,5" />
<TextBox Name="MarkupPriceEntryForm" AutomationProperties.Name="Markup" Grid.Row="2" Grid.Column="1" HorizontalAlignment="Left"
Validation.ErrorTemplate="{StaticResource ValidationTemplate}"
Style="{StaticResource TextStyleTextBox}" Margin="8,5,0,5" IsReadOnly="{Binding ElementName=UserControlPriceList, Path=DataContext.IsReadOnly}" >
<TextBox.Text>
<Binding Path="Markup" StringFormat="N2" UpdateSourceTrigger="PropertyChanged">
<Binding.ValidationRules>
<ExceptionValidationRule />
</Binding.ValidationRules>
</Binding>
</TextBox.Text>
</TextBox>
<TextBlock Name="SalePrice" Grid.Row="3" Grid.Column="1" HorizontalAlignment="Left"
Text="{Binding Converter={StaticResource priceListItemConverter}, ConverterParameter=SalePrice, StringFormat=C}"
Style="{StaticResource TextStyleTextBlockTextBoxLike}" Margin="8,5,0,5" />
<TextBlock Name="Promotion" Grid.Row="4" Grid.Column="1" HorizontalAlignment="Left"
Text="{Binding Converter={StaticResource priceListItemConverter}, ConverterParameter=PromoFriendlyDescription}"
Style="{StaticResource TextStyleTextBlockTextBoxLike}" Margin="8,5,0,5"
Visibility="{Binding ElementName=UserControlPriceList, Path=DataContext.HideIfEditing}"/>
<ComboBox Name="PromosComboBox" AutomationProperties.Name="PromoDiscount" Grid.Row="4" Grid.Column="1" HorizontalAlignment="Left"
Text="(Seleccione promo)"
ItemsSource="{Binding ElementName=UserControlPriceList, Path=DataContext.PromoCollection}"
SelectedValue="{Binding ElementName=UserControlPriceList, Path=DataContext.Current.PromoId, Mode=TwoWay}"
SelectedValuePath="Id"
Style="{StaticResource ComboBoxStyle}"
ItemContainerStyle="{StaticResource ComboBoxItemStyle}" Margin="8,5,0,5"
IsTextSearchEnabled="True"
IsTextSearchCaseSensitive="False"
IsDropDownOpen="False"
StaysOpenOnEdit="True"
IsEditable="True" IsReadOnly="False"
Visibility="{Binding ElementName=UserControlPriceList, Path=DataContext.ShowIfEditing}"
Width="{Binding ElementName=Promotion, Path=Width}">
<ComboBox.Resources>
<DataTemplate DataType="{x:Type localModel:Promo}">
<TextBlock Text="{Binding Converter={StaticResource promoConverter}, ConverterParameter=PromoFriendlyDescription}"/>
</DataTemplate>
</ComboBox.Resources>
</ComboBox>
<TextBlock Name="FinalPrice" Grid.Row="5" Grid.Column="1" HorizontalAlignment="Left"
Text="{Binding Converter={StaticResource priceListItemConverter}, ConverterParameter=FinalPrice, StringFormat=C}"
Style="{StaticResource TextStyleTextBlockTextBoxLike}" Margin="8,5,0,5" />
</Grid>
</Border>
</DataTemplate>
</UserControl.Resources>
<Border Padding="20">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition/>
<ColumnDefinition/>
<ColumnDefinition/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="2" Style="{StaticResource MediumTitleStyle}" Margin="5"
Text="{Binding Path=List.Name, StringFormat='Lista: {0}'}" />
<TextBlock Grid.Row="0" Grid.Column="2" Grid.ColumnSpan="2" Style="{StaticResource MediumTitleStyle}" Margin="5" HorizontalAlignment="Right"
Text="{Binding Path=List.PrintDate, StringFormat='Última impresión: {0:dd/MM/yyyy}'}" />
<Border Grid.Row="1" Style="{StaticResource BorderStyle}" Grid.ColumnSpan="4">
<ListView Name="ListViewPriceListItems" Grid.Row="1" Grid.ColumnSpan="4"
ItemsSource="{Binding Path=Items}"
IsSynchronizedWithCurrentItem="True"
SelectionChanged="OnPriceListItemSelectionChanged"
MouseDoubleClick="EnterPriceListItemEditMode"
IsEnabled="{Binding Path=IsReadOnly}">
<ListView.View>
<GridView AllowsColumnReorder="true" ColumnHeaderToolTip="Detalle de productos, precios finales y promociones">
<GridViewColumn DisplayMemberBinding="{Binding Path=Product.Description}" Header="Producto" Width="300"/>
<GridViewColumn DisplayMemberBinding="{Binding Path=Product.CostPrice, StringFormat=C}" Header="Costo"/>
<GridViewColumn DisplayMemberBinding="{Binding Path=Markup, StringFormat=N2}" Header="Markup (%)" />
<GridViewColumn DisplayMemberBinding="{Binding Converter={StaticResource priceListItemConverter}, ConverterParameter=SalePrice, StringFormat=C}" Header="Precio Lista"/>
<GridViewColumn DisplayMemberBinding="{Binding Converter={StaticResource priceListItemConverter}, ConverterParameter=PromoFriendlyDescription}" Header="Promoción" Width="200" />
<GridViewColumn DisplayMemberBinding="{Binding Converter={StaticResource priceListItemConverter}, ConverterParameter=FinalPrice, StringFormat=C}" Header="Precio Final" />
</GridView>
</ListView.View>
</ListView>
</Border>
<ContentControl Name="PriceListDetail" Grid.Row="2" Grid.ColumnSpan="4"
Content="{Binding Path=Current}"
ContentTemplate="{StaticResource PriceListItemDetailTemplate}"
Margin="9,0,0,0" />
<StackPanel Grid.Row="3" Grid.ColumnSpan="4" Orientation="Horizontal" HorizontalAlignment="Right">
<StackPanel.Resources>
<Style TargetType="{x:Type Button}">
<Setter Property="Margin" Value="0,0,0,0"/>
</Style>
</StackPanel.Resources>
<Button Name="EditButton" HorizontalAlignment="Right" Content="_Modificar" Style="{StaticResource AcctionButtonStyle}"
ToolTip="Editar precio y promoción del producto seleccionado en esta lista."
Click="EditSelectedPriceListItem" Visibility="{Binding Path=HideIfEditing}"/>
<Button Name="SaveButton" HorizontalAlignment="Right" Content="_Aceptar" Style="{StaticResource ActionButtonOKStyle}"
ToolTip="Guardar los cambios realizados."
Click="SavePriceListItem" Visibility="{Binding Path=ShowIfEditing}"/>
<Button Name="CancelButton" HorizontalAlignment="Right" Content="_Cancelar" Style="{StaticResource ActionButtonCancelStyle}"
ToolTip="Deshacer los cambios realizados."
Click="UndoPriceListItem" Visibility="{Binding Path=ShowIfEditing}"/>
</StackPanel>
</Grid>
</Border>
This is its view model:
public partial class PriceListItemsViewModel: BaseViewModel<PriceListItem>
{
private PriceList _list;
public PriceListItemsViewModel(PriceList list, ObservableCollection<Promo> promoCollection)
{
this.List = list ?? throw new ArgumentNullException(nameof(list));
this.PromoCollection = promoCollection;
this.Items = list.Products;
this.IsEditMode = false;
this.IsNew = false;
this.Current = this.Items.FirstOrDefault();
}
public PriceList List { get => this._list; set => this._list = value; }
public ObservableCollection<Promo> PromoCollection { get; }
}
Note: BaseViewModel has an ObservableCollection, implements Generic INotifyPropertyChange, and common properties to indicate the current selected item in that collection and other useful states
And here the wpf of the view containing the previous user control:
<Window.Resources>
<uc:PriceListUserControl x:Key="userControlPList"/>
</Window.Resources>
<Border Padding="20">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition/>
<ColumnDefinition/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TabControl x:Name="TabControlPriceLists" Grid.Row="1" Grid.ColumnSpan="3" TabStripPlacement="Top"
ItemsSource="{Binding Items}"
SelectedItem="{Binding Current}"
IsEnabled="{Binding IsReadOnly}" >
<TabControl.Resources>
<DataTemplate DataType="{x:Type localVm:PriceListItemsViewModel}">
<uc:PriceListUserControl x:Name="UserControlPriceList"/>
</DataTemplate>
</TabControl.Resources>
<TabControl.ItemContainerStyle>
<Style TargetType="TabItem">
<Setter Property="Header" Value="{Binding List.Name}" />
<Setter Property="MaxWidth" Value="100" />
<Setter Property="ToolTip" Value="{Binding List.Name}" />
<Setter Property="Background" Value="Bisque" />
<Setter Property="FontWeight" Value="DemiBold" />
<EventSetter Event="MouseDoubleClick" Handler="OnTabItemDoubleClick"/>
</Style>
</TabControl.ItemContainerStyle>
</TabControl>
</Grid>
</Border>
And its view model:
public class PriceListsViewModel : BaseViewModel<PriceListItemsViewModel>
{
private ObservableCollection<Promo> promoCollection;
public PriceListsViewModel()
{
var promoDefaultItems = new List<Promo>
{
new Promo { FriendlyName = PromoConverter.PromoFriendlyNameNoPromo, DiscountPct = 0.0F } //Workaround: Adds "(No promo) to allow the user to left an item without promotion
//new Promo { FriendlyName = PromoConverter.PromoFriendlyNameNewPromo, DiscountPct = 0F }
};
this.promoCollection = new ObservableCollection<Promo>(promoDefaultItems);
var promos = PromoRepository.PromoGetAll();
foreach (var promo in promos)
{
this.promoCollection.Add(promo);
}
var lists = PriceListRepository.PriceListGetAll();
this.Items = new ObservableCollection<PriceListItemsViewModel>();
foreach (var l in lists)
{
this.Items.Add(new PriceListItemsViewModel(l, promoCollection));
}
this.IsEditMode = false;
this.IsNew = false;
this.Current = this.Items.FirstOrDefault();
}
}
I resolved the problem by creating a new view model wrapping the former PriceListItem and providing real properties and notifying their changes. This is the new view model (only relevant code to show the new properties)
public class PriceListItemViewModel : SupervisedEntity
{
private PriceListItem _item;
public PriceListItemViewModel(PriceListItem item)
{
this._item = item ?? throw new ArgumentNullException(nameof(item));
//Bind the parent properties to its dependants (actually they are computed properties without setter) so they notify property changes when their parent property changes.
this.AddDependentProperty(nameof(this.PromoId), nameof(this.SalePrice));
this.AddDependentProperty(nameof(this.PromoId), nameof(this.FinalPrice));
this.AddDependentProperty(nameof(this.PromoId), nameof(this.PromoFriendlyDescription));
this.AddDependentProperty(nameof(this.Markup), nameof(this.SalePrice));
this.AddDependentProperty(nameof(this.Markup), nameof(this.FinalPrice));
}
// The properties and code not related to the solution were removed for clarity
public int? PromoId
{
get => this._item.PromoId;
set
{
this._item.PromoId = (value is null || value == 0) ? null : value;
this.NotifyPropertyChanged(); //This notifies not only the change in PromoId but also in all its dependent properties
}
}
public string ProductDescription => this._item.Product.Description;
public float CostPrice => this._item.Product.CostPrice;
[Required]
public float Markup
{
get => this._item.Markup;
set
{
this._item.Markup = value;
this.NotifyPropertyChanged();
}
}
//Using properties instead of extension methods allows me to create notification for them as dependent
public float SalePrice => this._item.SalePrice();
public float FinalPrice => this._item.FinalPrice();
public int Id => this.Item.Id;
public string PromoFriendlyDescription => this._item.PromoId is null ? string.Empty : this._item.Promotion.ToString();
}
And in the xaml of the user control I replaced all the converters, which didn't get notified when one of the properties they depended on changed, by the actual properties in the new view model. First, replacing the class of the model by the new view model, like this:
<DataTemplate x:Key="PriceListItemDetailTemplate" DataType="{x:Type localVm:PriceListItemViewModel}">
And then replacing all the references to converters in the properties that change:
<TextBlock Name="SalePrice" Grid.Row="3" Grid.Column="1" HorizontalAlignment="Left"
Text="{Binding Converter={StaticResource priceListItemConverter}, ConverterParameter=SalePrice, StringFormat=C}"
Style="{StaticResource TextStyleTextBlockTextBoxLike}" Margin="8,5,0,5" />
<TextBlock Name="FinalPrice" Grid.Row="5" Grid.Column="1" HorizontalAlignment="Left"
Text="{Binding Converter={StaticResource priceListItemConverter}, ConverterParameter=FinalPrice, StringFormat=C}"
Style="{StaticResource TextStyleTextBlockTextBoxLike}" Margin="8,5,0,5" />
By the real properties in the new view model:
<TextBlock Name="SalePrice" Grid.Row="3" Grid.Column="1" HorizontalAlignment="Left"
Text="{Binding Path=SalePrice, StringFormat=C}"
Style="{StaticResource TextStyleTextBlockTextBoxLike}" Margin="8,5,0,5" />
<TextBlock Name="FinalPrice" Grid.Row="5" Grid.Column="1" HorizontalAlignment="Left"
Text="{Binding Path=FinalPrice, StringFormat=C}"
Style="{StaticResource TextStyleTextBlockTextBoxLike}" Margin="8,5,0,5" />
And now it works.