DCSIMG
Aggregate CheckBox for DataGridCheckBoxColumn (Part 3) - David Sackstein's Blog

Aggregate CheckBox for DataGridCheckBoxColumn (Part 3)

I described the problem at hand in the first post in this series.

In the previous post I described the high level design of the source code that demonstrates my solution.

In this post, I will describe the EmployeeUserControl and summarize.

You can download the complete source code for the article here.

DataBinding Design

First, let’s decide what should be bound to what.

From the very start we made the fairly obvious decision to use an ObservableCollection<Employee> as out ItemsSource. Combined with the implementation of INotifyPropertyChanged by Employee and this simple XAML we get two-way data binding between the Employees and their properties and rows in the DataGrid.

        <StackPanel>

            <tk:DataGrid x:Name="gridEmployeeList"

                        AutoGenerateColumns="False"

                        >

                <tk:DataGrid.Columns>

                    <tk:DataGridCheckBoxColumn

                           Header="Manager"

                           HeaderStyle="{StaticResource CheckBoxHeaderStyle}"

                           Binding="{Binding Path=IsManager, Mode=TwoWay}"

                   />

                    <tk:DataGridTextColumn

                           Header="Name"

                           Width="*"

                           Binding="{Binding Path=Name, Mode=TwoWay}"

                   />

                </tk:DataGrid.Columns>

            </tk:DataGrid>

        </StackPanel>

Now we need to bind a new CheckBox (the aggregate) to all the CheckBoxes in the first column. As we shall see, this can be done using a MultiBinding object and an appropriate IMultivalueConverter that converts the values of the source to a single aggregate value for the target and vice versa.

Though binding properties of controls to properties of other controls is certainly feasible, it requires that we find each of the check boxes on each row and add it to the binding. Finding controls on the grid, is tricky and slow, and in my opinion, should be avoided if possible.

In the case at hand we can use the IsManager property of each Employee instead, because, due to two way data binding, these are always synchronized with their bound CheckBox in the DataGrid. So, this is what I did.

The result is a clean design in which our business objects, represented by a ObserverableCollection<Employee>, are at the center and all bound elements of the UI bind (two-way) directly to these business objects (and not to each other).

So far, we have been talking about an aggregate CheckBox, but have not created it in XAML or in code. Let’s do that next.

Adding the CheckBox to the Column Header

I added a CheckBox using a ControlTemplate.

The ControlTemplate is contained in a style called “CheckBoxHeaderStyle” designed for controls of type “DataGridColumnHeader”. It is assigned to the HeaderStyle property of the first column.

This the XAML for the EmployeeListControl with the CheckBox.

<UserControl

   xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"

   xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"

   xmlns:tk="clr-namespace:Microsoft.Windows.Controls;assembly=WPFToolkit"

   xmlns:tkp="clr-namespace:Microsoft.Windows.Controls.Primitives;assembly=WPFToolkit"

   xmlns:local="clr-namespace:UserControls"

       x:Class="UserControls.EmployeeListControl"

       Loaded ="UserControl_Loaded"

       >

    <UserControl.Resources>

        <Style x:Key="CheckBoxHeaderStyle" TargetType="{x:Type tkp:DataGridColumnHeader}">

            <Setter Property="Template">

                <Setter.Value>

                    <ControlTemplate TargetType="{x:Type tkp:DataGridColumnHeader}">

                        <StackPanel Orientation="Horizontal" VerticalAlignment="Center">

                            <CheckBox x:Name="cbCheckAll"

                                     IsThreeState="True" VerticalAlignment="Center"/>

                            <Label Content="{Binding}"></Label>

                        </StackPanel>

                    </ControlTemplate>

                </Setter.Value>

            </Setter>

        </Style>

    </UserControl.Resources>

 

    <StackPanel x:Name="panel">

        <tk:DataGrid x:Name="gridEmployeeList"

                    AutoGenerateColumns="False"

                    >

            <tk:DataGrid.Columns>

                <tk:DataGridCheckBoxColumn

                       Header="Manager"

                       HeaderStyle="{StaticResource CheckBoxHeaderStyle}"

                       Binding="{Binding Path=IsManager, Mode=TwoWay}"

               />

                <tk:DataGridTextColumn

                       Header="Name"

                       Width="*"

                       Binding="{Binding Path=Name, Mode=TwoWay}"

               />

            </tk:DataGrid.Columns>

        </tk:DataGrid>

    </StackPanel>

</UserControl>

Note that the new CheckBox, hereinafter also known as “cbCheckAll” has IsThreeState set to True.

The required behavior is as follows.

  1. When IsManager is true for all Employees, cbCheckAll.IsChecked will be True.
  2. When IsManager is false for all Employees, cbCheckAll.IsChecked will be False.
  3. Otherwise, cbCheckAll.IsChecked should be null (it’s third state).

On the other hand, when the user clicks on cbCheckAll the result should be either checked or unchecked. The third state has no meaning in this scenario..

OK. So now we have a CheckBox and we know what we need to data bind. Now lets get a reference to the CheckBox and set it all up.

Getting a Reference to a Templated Control

Hmmm. Wouldn’t it be wonderful if we could just have a member called cbCheckAll?

Well, unfortunately, that is not possible for controls that are defined in a template.

OK, so maybe we can use the “FindName” function of the Template class to get hold of the CheckBox dynamically?

Now that “could” work in principle, but there is a problem of timing here. If you try to do this in the constructor or even in the Loaded handler of the user control, you will find that the call returns null.

The reason for this is that cbCheckAll is situated in a DataGridColumnHeadersPresenter which derives from ItemsControl. As you may know, ItemsControls generate their item containers dynamically, and asynchronously. The recommended way to retrieve items or containers from a ItemsControls is to subscribe to the events of the ItemContainerGenerator as shown in the code below.

    public partial class EmployeeListControl : UserControl

    {

        public EmployeeListControl()

        {

            InitializeComponent();

        }

 

        #region The aggregate CheckBox

 

        protected CheckBox cbCheckAll;

 

        private void UserControl_Loaded(object sender, RoutedEventArgs args)

        {

            // The aggregate checkBox is in the DataGridColumnHeadersPresenter

            // which is an ItemsControl.

 

            DataGridColumnHeadersPresenter presenter =

               GetDataGridColumnHeadersPresenter();

 

            // Items in an ItemsControls are generated "asynchronously"

            // by an ItemContainerGenerator.

            // So, to find an item, we subscribe to the StatusChanged event of the

            // ItemContainerGenerator.

 

            presenter.ItemContainerGenerator.StatusChanged +=

               ColumnHeadersGenerator_StatusChanged;

        }

 

        private DataGridColumnHeadersPresenter GetDataGridColumnHeadersPresenter()

        {

            // The DataGrid has a ScrollViewer which contains the

            // PART_ColumnHeadersPresenter

 

            ScrollViewer scrollViewer =

                gridEmployeeList.Template.FindName(

                    "DG_ScrollViewer", gridEmployeeList) as ScrollViewer;

 

            Debug.Assert(scrollViewer != null);

 

            // DG_ScrollViewer contains a DataGridColumnHeadersPresenter

            // called PART_ColumnHeadersPresenter

 

            DataGridColumnHeadersPresenter presenter =

                scrollViewer.Template.FindName(

                     "PART_ColumnHeadersPresenter", scrollViewer)

                          as DataGridColumnHeadersPresenter;

 

            Debug.Assert(presenter != null);

 

            return presenter;

        }

 

        private void ColumnHeadersGenerator_StatusChanged(object sender, EventArgs args)

        {

            ItemContainerGenerator itemGenerator = sender as ItemContainerGenerator;

 

            // Handle the case when the containers have been generated.

 

            if (itemGenerator.Status == GeneratorStatus.ContainersGenerated)

            {

                // The containers in the DataGridColumnHeadersPresenter

                // are the DataGridColumnHeader objects for each row.

                // Find the check box in the header of the first column.

 

                DataGridColumnHeader header =

                   (DataGridColumnHeader)itemGenerator.ContainerFromIndex(0);

 

                cbCheckAll = header.Template.FindName("cbCheckAll", header) as CheckBox;

                Debug.Assert(cbCheckAll != null);

 

                ConfigureCheckBox();

 

                // GeneratorStatus.ContainersGenerated is triggered a number of times,

                // we only use the first - so we uninstall the delegate in the first.

 

                itemGenerator.StatusChanged -= ColumnHeadersGenerator_StatusChanged;

            }

        }

In the Loaded event handler, we find the DataGridColumnHeadersPresenter which is responsible for the layout of the column headers across the top of the DataGrid. We then subscribe to the StatusChanged event of the ItemContainerGenerator of the “presenter”.

The ColumnHeadersGenerator_StatusChanged event handler does nothing until the status of the generator is ContainersGenerated. A status of ContainersGenerated indicates that all the item containers controls have been created. The item container controls for this presenter are of type DataGridColumnHeader.

So, at this point we can ask the ItemContainerGenerator the first DataGridColumnHeader, which corresponds to the DataGridCheckBoxColumn, and then, finally, we can use the FindName method on the Template property of the ColumnHeader to dynamically get the required reference to the cbCheckAll CheckBox.

Oh, and before we return from this function, we unsubscribe the event handler from the StatusChanged event of the ItemContainerGenerator because our task here is done.

Now don’t tell me that was easy!

Not only was it not easy, but you may be quite depressed at this point, asking yourself, “How does he know all of that?”, and, “What, I need to learn and memorize the intricate structure of each control in order to perform such simple customizations in code?”

Well, I can comfort you on one count. I didn’t memorize these details and you also don’t need to. There is a great tool out there to visually guide you through the structure, if you need it. Its called Mole and you can download it here.

And yet, your alarm is not entirely unjustified. The way I worked with Mole to write this code was not efficient at all.

I had to write some skeleton code, run the debugger with a breakpoint set somewhere suitable and to use Mole to view the structure and jot it down somewhere. Then I wrote some code to navigate through the elements and ran it again. Each time, I needed to check with Mole to see whether I was getting nearer the control I was looking for, or not.

This observation is one example of what I meant when I said that sometimes in WPF you have to work very hard to get some very basic features.

OK, that’s enough ranting on! We now have our cbCheckBox. Let’s see what need’s to be done with it.

Configuring the Aggregate CheckBox

There are three configurations we need to implement:

  1. Add an event handler to the IsChecked event of cbCheckAll to disable the indeterminate state when being clicked by the user.
  2. Assume that the MultiBinding (checkBinding ) object has been prepared elsewhere in the code we need to use the CheckBox’s SetBinding method to bind it to the output of the MultiBinding object.
  3. We also set the IsEnabled property of cbCheckAll so that the aggregate CheckBox is disabled when there are no items in the list.

Here is the code:

        MultiBinding checkBinding;

 

        protected virtual void ConfigureCheckBox()

        {

            // Configure the check box:

            // Skip the tri state when clicked by the user, and bind to multiBinding

 

            cbCheckAll.Click +=

                (s, e) =>

                {

                    if (cbCheckAll.IsChecked == null)

                    {

                        cbCheckAll.IsChecked = false;

                    }

                };

            if (checkBinding != null)

            {

                cbCheckAll.SetBinding(CheckBox.IsCheckedProperty, checkBinding);

                cbCheckAll.IsEnabled = Employees.Count > 0;

            }

        }

Now let’s see how the MultiBinding object is prepared.

Setting Up The Binding

The most important part of the data binding plumbing in this project is the MultiBinding object. The MultiBinding object is basically a collection of individual bindings and a converter (a IMultiValueConverter, to be precise).

In order to convert the IsMAnager property of all items in the collection to one aggregate value, and vice versa, we must add exactly one binding object for each Employee object in the collection. The job of the converter is to Convert, upon request, the group of values of the IsManager property for all Employees in the collection to the required aggregate value and vice versa.

Here is the code for the CheckConverter class:

    class CheckConverter : IMultiValueConverter

    {

        #region IMultiValueConverter Members

 

        public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)

        {

            int checkedCount = values.Count((o) => (bool)o);

            if (checkedCount == 0)

                return false;

            if (checkedCount == values.Length)

                return true;

            return null;

        }

 

        public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)

        {

            object[] values = new object[targetTypes.Length];

            for (int i = 0; i < values.Length; i++)

            {

                values[i] = value;

            }

            return values;

        }

 

        #endregion

    }

OK. Now how are we going to make sure that there exactly one binding object in the Bindings collection of the MultBinding object for every Employee?

Easy! Whenever the Employees collection is set we will create a new MultiBinding adding a Binding for each Employee. In addition, we will subscribe to the CollectionChanged event of the collection and in our handler, update the MultiBinding object whenever Employees are added or removed from the collection.

Here you go:

        MultiBinding checkBinding;

        CheckConverter converter = new CheckConverter();

        #region Bind Employee collection to MultiBinding

 

        public ObservableCollection<Employee> Employees

        {

            get { return gridEmployeeList.ItemsSource as ObservableCollection<Employee>; }

            set

            {

                BindCollection(value);

 

                if (Employees != null)

                {

                    Employees.CollectionChanged -= CollectionChanged;

                }

 

                gridEmployeeList.ItemsSource = value;

 

                if (Employees != null)

                {

                    Employees.CollectionChanged += CollectionChanged;

                }

            }

        }

 

        private void BindCollection(ObservableCollection<Employee> value)

        {

            checkBinding = new MultiBinding();

            checkBinding.Converter = converter;

 

            foreach (Employee employee in value)

            {

                Binding binding = new Binding("IsManager");

                binding.Source = employee;

                checkBinding.Bindings.Add(binding);

            }

 

            if (cbCheckAll != null)

            {

                cbCheckAll.SetBinding(CheckBox.IsCheckedProperty, checkBinding);

                cbCheckAll.IsEnabled = value.Count > 0;

            }

        }

 

        void CollectionChanged(object sender, NotifyCollectionChangedEventArgs e)

        {

            BindCollection(Employees);

        }     

 

Now looking at this code, you might be thinking: “Why do I have to rebuild the MultiBinding object each time the collection changes? The CollectionChanged event handler takes by argument an NotifyCollectionChangedEventArgs object that provides a NewItems and an OldItems property. Why cant I just use this and add and remove Bindings from the Bindings collection of the MultiBinding object?

Well, it turns out that once a binding has been used, (e.g. participated in SetBinding) it can no longer be modified. If you try you will get an exception with this message: “Binding cannot be changed after being used”.

Note that while binding the IsChecked property of the cbCheckAll CheckBox we also determine the IsEnabled property. It makes no sense to be able to check and uncheck the cbCheckAll CheckBox  when the collection is empty.

One final improvement that I added was Click Once Editing.

Click Once Editing

By default, you will find that in order to select a CheckBox in a DataGridCheckBoxColumn you need to click once to select the row and a second time to check or uncheck.

In order to enable one click editing I copied the solution I found here..

Summary

Though the problem at hand was a simple one, the code is not simple.

These were the main challenges:

 

Problem 1:
Getting hold of a reference to the aggregate CheckBox when it is in a templated Column Header.

 

Solution:
Find the ItemsControl ancestor of the control, and subscribe to the StatusChanged of the ItemContainerGenerator.

Problem 2:
Setting up a MultBinding object and keeping it synchronized with the source collection.

 

Solution:
Create a new MultiBinding object every time the source collection is set and every time the collection is modified.

 

Problem 3: Enabling Click Once Editing

Implement the solution found here.

Published Saturday, April 18, 2009 4:05 AM by David Sackstein
תגים:, ,

Comments

No Comments

Leave a Comment

(required) 
(required) 
(optional)
(required) 

Enter the numbers above:
Powered by Community Server (Commercial Edition), by Telligent Systems