Search code examples
c#wpfxamldata-binding

WPF: Bindings break when CollectionChanges - why?


First, let me apologize for the length of this post - I know it's long, but I figured for this one, more detail is better than less.

What I'm trying to achieve now is a totals footer row for a datagrid. Since it needs to show up on the bottom row, the approach I'm taking is to just add some TextBlocks that line up with the columns in the datagrid.

My app has multiple datagrids inside an ItemsControl so I haven't found a nice way of just setting a binding. Using RelativeSource doesn't seem to be an option as there's no way (as far as I can tell) to point it at a descendent element, then search for a particular child. So instead I've written a bit of hackery to do what I want in code behind.

Ok, so now the problem.. Everything looks fine when the app starts, but as soon as any of the items in the grid change, the width binding seems to break completely. I wrote a small test app to show what I mean. Here's some screenshots:

Initial output

Now if I click the button to change an element, the width binding of the footer textblocks breaks:

After Click

I'm completely stumped as to the cause of this behavior. I'm pretty new to WPF and just muddling my way through building my first app. So if this is a dumb way of doing things, please let me know. Here's my code.

MainWindow.xaml:

<Window x:Class="WpfTestApp.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:WpfTestApp"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <StackPanel Orientation="Vertical">
        <ItemsControl x:Name="BarsItemsControl" ItemsSource="{Binding Bars}">
            <ItemsControl.ItemTemplate>
                <DataTemplate>
                    <StackPanel Orientation="Vertical">
                        <TextBlock Text="{Binding Description}" />
                        <DataGrid x:Name="FooGrid" 
                              ItemsSource="{Binding Foos}" 
                              IsSynchronizedWithCurrentItem="False" 
                              AutoGenerateColumns="False" 
                              SelectionUnit="Cell" 
                              SelectionMode="Extended" 
                              CanUserReorderColumns="False"
                              CanUserAddRows="True"
                              HeadersVisibility="Column">
                            
                            <DataGrid.Columns>
                                <DataGridTextColumn Header="Col 1" Width="*" Binding="{Binding Value1}" />
                                <DataGridTextColumn Header="Col 2" Width="*" Binding="{Binding Value2}" />
                                <DataGridTextColumn Header="Col 3" Width="*" Binding="{Binding Value3}" />
                            </DataGrid.Columns>
                        </DataGrid>
                        <StackPanel x:Name="TotalsRow" Orientation="Horizontal">
                            <TextBlock Text="{Binding Totals[0]}" />
                            <TextBlock Text="{Binding Totals[1]}" />
                            <TextBlock Text="{Binding Totals[2]}" />
                        </StackPanel>
                    </StackPanel>
                </DataTemplate>
            </ItemsControl.ItemTemplate>
        </ItemsControl>
        <Button Content="Change something" Click="Button_Click" />
    </StackPanel>
</Window>

MainWindow.xaml.cs

using System.Collections.Generic;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Media;


namespace WpfTestApp
{
    /// <summary>
    /// Interaction logic for MainWindow.xaml
    /// </summary>
    public partial class MainWindow : Window
    {
        public IList<Bar> Bars { get; }

        public MainWindow()
        {
            InitializeComponent();
            this.DataContext = this;
            this.Bars = new ObservableItemsCollection<Bar>();
            var foos = new ObservableItemsCollection<Foo>();
            for (int i = 0; i < 5; i++)
            {
                foos.Add(new Foo()
                {
                    Value1 = 14.23,
                    Value2 = 53.23,
                    Value3 = 35.23
                });
            }

            var foos2 = new ObservableItemsCollection<Foo>();
            for (int i = 0; i < 5; i++)
            {
                foos2.Add(new Foo()
                {
                    Value1 = 14.23,
                    Value2 = 53.23,
                    Value3 = 35.23
                });
            }

            this.Bars.Add(new Bar(foos) 
            { 
                Description = "Bar 1",
            });

            this.Bars.Add(new Bar(foos2)
            {
                Description = "Bar 2",
            });

            this.Loaded += MainWindow_Loaded;
        }

        private void MainWindow_Loaded(object sender, RoutedEventArgs e)
        {
            // Bind widths of the TotalsRow textblocks (footers) to the width of the 
            // datagrid column they're associated with
            var elements = new List<FrameworkElement>();
            this.GetChildElementsByName(this.BarsItemsControl, "FooGrid", ref elements);
            foreach (var element in elements)
            {
                var dataGrid = element as DataGrid;
                if (dataGrid != null)
                {
                    var totalsRowList = new List<FrameworkElement>();
                    this.GetChildElementsByName(VisualTreeHelper.GetParent(dataGrid), "TotalsRow", ref totalsRowList);
                    if (totalsRowList.Count > 0)
                    {
                        for (int i = 0; i < VisualTreeHelper.GetChildrenCount(totalsRowList[0]); i++)
                        {
                            var textBlock = VisualTreeHelper.GetChild(totalsRowList[0], i) as TextBlock;
                            Binding widthBinding = new Binding();
                            widthBinding.Source = dataGrid.Columns[i];
                            widthBinding.Path = new PropertyPath("ActualWidth");
                            widthBinding.UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged;
                            BindingOperations.SetBinding(textBlock, TextBlock.WidthProperty, widthBinding);
                        }
                    }
                }
            }
        }

        /// <summary>
        /// Populate a list of elements in the visual tree with the given name under the given parent
        /// </summary>
        public void GetChildElementsByName(DependencyObject parent, string name, ref List<FrameworkElement> elements)
        {
            for (int i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
            {
                var child = VisualTreeHelper.GetChild(parent, i);
                var element = child as FrameworkElement;
                if (element != null && element.Name == name)
                {
                    elements.Add(element);
                }
                GetChildElementsByName(child, name, ref elements);
            }
        }

        private void Button_Click(object sender, RoutedEventArgs e)
        {
            this.Bars[0].Foos[3].Value1 = 10;
        }
    }
}

Foo.cs

using System.ComponentModel;
using System.Runtime.CompilerServices;

namespace WpfTestApp
{
    public class Foo : INotifyPropertyChanged
    {
        private double value1;
        public double Value1 {
            get { return value1; }
            set { value1 = value; OnPropertyChanged(); }
        }
        private double value2;
        public double Value2
        {
            get { return value2; }
            set { value2 = value; OnPropertyChanged(); }
        }
        private double value3;
        public double Value3
        {
            get { return value3; }
            set { value3 = value; OnPropertyChanged(); }
        }

        public event PropertyChangedEventHandler PropertyChanged;
        protected void OnPropertyChanged([CallerMemberName] string name = null)
        {
            this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
        }
    }
}

Bar.cs

using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;


namespace WpfTestApp
{
    public class Bar : INotifyPropertyChanged
    {
        public Bar(ObservableItemsCollection<Foo> foos)
        {
            this.Foos = foos;
            this.Totals = new double[3] { 14, 14, 14};
            this.Foos.CollectionChanged += Foos_CollectionChanged;
        }

        private void Foos_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
        {
            //var fooList = this.Categories.Cast<CategoryViewModel>();
            this.Totals[0] = this.Foos.Sum(f => f.Value1);
            this.Totals[1] = this.Foos.Sum(f => f.Value2);
            this.Totals[2] = this.Foos.Sum(f => f.Value3);
            OnPropertyChanged(nameof(Totals));
        }

        public string Description { get; set; }
        public ObservableItemsCollection<Foo> Foos { get; }

        public double[] Totals { get; }

        public event PropertyChangedEventHandler PropertyChanged;
        protected void OnPropertyChanged([CallerMemberName] string name = null)
        {
            this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
        }
    }
}

ObservableItemsCollection.cs

using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;


namespace WpfTestApp
{
    public class ObservableItemsCollection<T> : ObservableCollection<T>
        where T : INotifyPropertyChanged
    {
        private void Handle(object sender, PropertyChangedEventArgs args)
        {
            base.OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset, null));
        }

        protected override void OnCollectionChanged(NotifyCollectionChangedEventArgs e)
        {
            if (e.NewItems != null)
            {
                foreach (object t in e.NewItems)
                {
                    ((T)t).PropertyChanged += Handle;
                }
            }
            if (e.OldItems != null)
            {
                foreach (object t in e.OldItems)
                {
                    ((T)t).PropertyChanged -= Handle;
                }
            }
            base.OnCollectionChanged(e);
        }
    }
}

Solution

  • Just bind to the ActualWidth of the columns:

    <Window x:Class="WpfTestApp.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:WpfTestApp"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <StackPanel Orientation="Vertical">
        <ItemsControl x:Name="BarsItemsControl" ItemsSource="{Binding Bars}">
            <ItemsControl.ItemTemplate>
                <DataTemplate>
                    <StackPanel Orientation="Vertical">
                        <TextBlock Text="{Binding Description}" />
                        <DataGrid x:Name="FooGrid" 
                              ItemsSource="{Binding Foos}" 
                              IsSynchronizedWithCurrentItem="False" 
                              AutoGenerateColumns="False" 
                              SelectionUnit="Cell" 
                              SelectionMode="Extended" 
                              CanUserReorderColumns="False"
                              CanUserAddRows="True"
                              HeadersVisibility="Column">
    
                            <DataGrid.Columns>
                                <DataGridTextColumn x:Name="col1" Header="Col 1" Width="*" Binding="{Binding Value1}" />
                                <DataGridTextColumn x:Name="col2" Header="Col 2" Width="*" Binding="{Binding Value2}" />
                                <DataGridTextColumn x:Name="col3" Header="Col 3" Width="*" Binding="{Binding Value3}" />
                            </DataGrid.Columns>
                        </DataGrid>
                        <StackPanel  x:Name="TotalsRow" Orientation="Horizontal">
                            <TextBlock Width="{Binding ElementName=col1, Path=ActualWidth}" Text="{Binding Totals[0]}" />
                            <TextBlock Width="{Binding ElementName=col2, Path=ActualWidth}" Text="{Binding Totals[1]}" />
                            <TextBlock Width="{Binding ElementName=col3, Path=ActualWidth}" Text="{Binding Totals[2]}" />
                        </StackPanel>
                    </StackPanel>
                </DataTemplate>
            </ItemsControl.ItemTemplate>
        </ItemsControl>
        <Button Content="Change something" Click="Button_Click" />
    </StackPanel>