Search code examples
c#wpfdata-bindingdatagrid

CollectionViewGroup.Items not raising PropertyChanged after having an item added?


I am trying to add grouping with subtotal sum into DataGrid. Read several articles: the solution is to have an ObservableCollection with the data, wrap it into CollectionViewSource which in turn will be ItemsSource for the DataGrid. Subtotal is calculated with a converter, which receives Items of CollectionViewGroup as input and calculates the sum.

All works fine only at the initial population of the ObservableCollection, or when adding an item creates the new group. But if an item is added into any existing group, converter is simply not called for recalculation - apparently CollectionViewGroup.Items is not raising PropertyChanged event? I browsed a bit in CollectionViewGroup source - Items are ReadOnlyObservableCollection<object>, which should trigger PropertyChanged after an item added, shouldn't it?

Then I noticed, that CollectionViewGroup.ItemCount is displayed properly after adding new items, so I tried a trick with a MultiBinding - added a IMultiValueConverter converter which takes both Items and ItemCount as parameters, expecting ItemCount to trigger the recalculation. It worked, but again without full success - somehow the converter gets the correct input only once, when the new group is created. If an item was added to an existing group, ItemCount is correct, but Items are not! Items collection is missing the newly added item! E.g. when ItemCount=2, Items have only 1 "old" item (Items.Count=1). When ItemCount=3, Items have only 2 "old" items (Items.Count=2), etc. So again the converter cannot calculate the correct subtotal, because the input is incomplete...

It looks like the only working solution would be to call Refresh() for the whole CollectionViewSource, but that expands all the groups, cause flickering, breaks MVVM concept, so it is ugly...

So my questions are:

  • Is there still any change to make CollectionViewGroup.Items raise PropertyChanged properly?

  • Isn't it a bug in CollectionViewGroup, that Multi Converter receives Items.Count = ItemCount - 1?

Any advice would be highly appreciated!

The full sample code is on GitHub

Some code excerpts are below - XAML:

            <DataGrid.GroupStyle>
            <GroupStyle>
                <GroupStyle.ContainerStyle>
                    <Style TargetType="{x:Type GroupItem}">
                        <Setter Property="Margin" Value="0,0,0,5"/>
                        <Setter Property="Template">
                            <Setter.Value>
                                <ControlTemplate TargetType="{x:Type GroupItem}">                                       
                                    <Expander IsExpanded="True" BorderThickness="1,1,1,5">
                                        <Expander.Header>
                                            <DockPanel>
                                                <TextBlock FontWeight="Bold" Text="{Binding Path=Name}" Margin="5,0,0,0" Width="100"/>
                                                <TextBlock FontWeight="Bold" Text="{Binding Path=ItemCount}"/>
                                                <TextBlock FontWeight="Bold" Text="Sum 1: " Margin="5,0,0,0"/>
                                                <TextBlock FontWeight="Bold"  >
                                                    <TextBlock.Text>
                                                        <Binding Path="Items" Converter="{StaticResource sumConverter}" ConverterParameter="AmountValue" StringFormat="{}{0:N2}"/>
                                                    </TextBlock.Text>
                                                </TextBlock>
                                                <TextBlock FontWeight="Bold" Text="Sum 2: " Margin="5,0,0,0"/>
                                                <TextBlock FontWeight="Bold"  >
                                                    <TextBlock.Text>
                                                        <MultiBinding Converter="{StaticResource sumMulConverter}" ConverterParameter="AmountValue" StringFormat="{}{0:N2}">
                                                            <Binding Path="Items"/>
                                                            <Binding Path="ItemCount"/>
                                                        </MultiBinding>
                                                    </TextBlock.Text>
                                                </TextBlock>
                                            </DockPanel>
                                        </Expander.Header>
                                        <Expander.Content>
                                            <ItemsPresenter />
                                        </Expander.Content>
                                    </Expander>
                                </ControlTemplate>
                            </Setter.Value>
                        </Setter>
                    </Style>
                </GroupStyle.ContainerStyle>
            </GroupStyle>
        </DataGrid.GroupStyle>

Converters:

    public class SumConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
    {
        if (value == DependencyProperty.UnsetValue) return DependencyProperty.UnsetValue;
        if (null == parameter) return null;
        string propertyName = (string)parameter;
        if (!(value is ReadOnlyObservableCollection<object>)) return null;
        ReadOnlyObservableCollection<object> collection = (ReadOnlyObservableCollection<object>)value;
        decimal sum = 0;
        foreach (object o in collection)
        {
            sum += (decimal)o.GetType().GetProperty(propertyName).GetValue(o);
        }
        return sum;
    }

    public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
    {
        throw new NotImplementedException();
    }
}

public class SumMulConverter : IMultiValueConverter
{
    public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
    {
        if (null == parameter) return null;
        if (!(parameter is string)) return null;
        string propertyName = (string)parameter;

        if (values == DependencyProperty.UnsetValue) return DependencyProperty.UnsetValue;
        if (values == null) return null;
        if (values.Length < 2) return null;
        if (!(values[0] is ReadOnlyObservableCollection<object>)) return null;
        ReadOnlyObservableCollection<object> collection = (ReadOnlyObservableCollection<object>)values[0];
        if (!(values[1] is int)) return null;
        Debug.Print($"ItemCount={(int)values[1]}; Collection Count = {collection.Count}");
        decimal sum = 0;
        foreach (object o in collection)
        {
            sum += (decimal)o.GetType().GetProperty(propertyName).GetValue(o);
        }
        return sum; //.ToString("N2", CultureInfo.CurrentCulture);
    }

    public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
    {
        throw new NotImplementedException();
    }
}

Solution

  • If you want to sum up the values in the view, a simple solution would be to create an Attached Behavior.

    Also make use of LINQ: instead of using reflection you can cast the collection from object to the explicit type using Enumerable.Cast<T> or Enumerable.OfType<T>. To compute a sum of a collection based on an item's property, use Enumerable.Sum:

    GroupItemSumBehavior.cs

    public class GroupItemSumBehavior : DependencyObject
    {
      #region IsEnabled attached property
    
      public static readonly DependencyProperty IsEnabledProperty = DependencyProperty.RegisterAttached(
        "IsEnabled", typeof(bool), typeof(GroupItemSumBehavior), new PropertyMetadata(default(bool), OnIsEnabledChanged));
    
      public static void SetIsEnabled(DependencyObject attachingElement, bool value) => attachingElement.SetValue(GroupItemSumBehavior.IsEnabledProperty, value);
    
      public static bool GetIsEnabled(DependencyObject attachingElement) => (bool) attachingElement.GetValue(GroupItemSumBehavior.IsEnabledProperty);
    
      #endregion
    
      #region Sum attached property
    
      public static readonly DependencyProperty SumProperty = DependencyProperty.RegisterAttached(
        "Sum", typeof(decimal), typeof(GroupItemSumBehavior), new PropertyMetadata(default(decimal)));
    
      public static void SetSum(DependencyObject attachingElement, decimal value) => attachingElement.SetValue(GroupItemSumBehavior.SumProperty, value);
    
      public static decimal GetSum(DependencyObject attachingElement) => (decimal) attachingElement.GetValue(GroupItemSumBehavior.SumProperty);
    
      #endregion
    
      private static Dictionary<IEnumerable, GroupItem> CollectionToGroupItemMap { get; set; }
    
      static GroupItemSumBehavior() => GroupItemSumBehavior.CollectionToGroupItemMap =
        new Dictionary<IEnumerable, GroupItem>();
    
      private static void OnIsEnabledChanged(DependencyObject attachingElement, DependencyPropertyChangedEventArgs e)
      {
        if (!(attachingElement is GroupItem groupItem))
        {
          return;
        }
    
        var collectionViewGroup = groupItem.DataContext as CollectionViewGroup;
        bool isEnabled = (bool) e.NewValue;
    
        if (isEnabled)
        {
          CollectionToGroupItemMap.Add(collectionViewGroup.Items, groupItem);
          (collectionViewGroup.Items as INotifyCollectionChanged).CollectionChanged += CalculateSumOnCollectionChanged;
          CalculateSum(collectionViewGroup.Items);
        }
        else
        {
          CollectionToGroupItemMap.Remove(collectionViewGroup.Items);
          (collectionViewGroup.Items as INotifyCollectionChanged).CollectionChanged -= CalculateSumOnCollectionChanged;
        }
      }
    
      private static void CalculateSum(IEnumerable collection)
      {
        if (GroupItemSumBehavior.CollectionToGroupItemMap.TryGetValue(collection, out GroupItem groupItem))
        {
          decimal sum = collection
            .OfType<LineItem>()
            .Sum(lineItem => lineItem.AmountValue);
    
          SetSum(groupItem, sum);
        }
      }
    
      private static void CalculateSumOnCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) 
        => CalculateSum(sender as IEnumerable);
    }
    

    DataGrid GroupStyle

    <DataGrid.GroupStyle>
      <GroupStyle>
        <GroupStyle.ContainerStyle>
          <Style TargetType="{x:Type GroupItem}">
    
            <Setter Property="GroupItemSumBehavior.IsEnabled" Value="True" />
    
            <Setter Property="Template">
              <Setter.Value>
                <ControlTemplate TargetType="{x:Type GroupItem}">
                  <Expander IsExpanded="True" BorderThickness="1,1,1,5">
                    <Expander.Header>
                      <DockPanel>
                        <TextBlock FontWeight="Bold" Text="{Binding Path=Name}" Margin="5,0,0,0" Width="100" />
                        <TextBlock FontWeight="Bold" Text="{Binding Path=ItemCount}" />
                        <TextBlock FontWeight="Bold" Text="Sum: " Margin="5,0,0,0" />
    
                        <TextBlock FontWeight="Bold"
                                   Text="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=(GroupItemSumBehavior.Sum)}" />
                      </DockPanel>
                    </Expander.Header>
                    <Expander.Content>
                      <ItemsPresenter />
                    </Expander.Content>
                  </Expander>
                </ControlTemplate>
              </Setter.Value>
            </Setter>
          </Style>
        </GroupStyle.ContainerStyle>
      </GroupStyle>
    </DataGrid.GroupStyle>