My .NET focused coding blog.

How to bind a three-state CheckBox to some other CheckBoxes in a data-bound ItemsControl in WPF using MVVM

This post provides an example on how you can use a three-state Checkbox control to set the IsChecked property of several other related CheckBoxes in a data-bound ItemsControl, or any other control that derives from the ItemsControl such as the DataGrid, ListView or TreeView controls, in WPF using the MVVM (Model-View-ViewModel) pattern.

By setting the IsThreeState property of a CheckBox control to true, the IsChecked property can also be set to NULL as a third state in addition to the two default ones; true and false.

A three-state CheckBox is usually used to reflect an overall state of some other related checkboxes. It should appear checked if all related checkboxes are checked, unchecked if none of the related checkboxes are checked, or in an indeterminate state if only some of the related CheckBox controls are checked:

checkboxstates

<CheckBox IsChecked="{x:Null}" IsThreeState="True">IsChecked = NULL</CheckBox>
<CheckBox IsChecked="True" IsThreeState="True">IsChecked = True</CheckBox>
<CheckBox IsChecked="False" IsThreeState="True">IsChecked = False</CheckBox>

The sample code in this post uses a view model that exposes a single collection of Country objects with each country having a property named IsSelected of type System.Boolean (bool) that will be bound to a CheckBox control in the view, and string properties for the name of the country and the continent to which it belongs to respectively:

public class Country : INotifyPropertyChanged
{
    private string _countryName;
    public string CountryName
    {
        get { return _countryName; }
        set { _countryName = value; OnPropertyChanged(); }
    }

    private string _continentName;
    public string ContinentName
    {
        get { return _continentName; }
        set { _continentName = value; OnPropertyChanged(); }
    }

    private bool _isSelected;
    public bool IsSelected
    {
        get { return _isSelected; }
        set { _isSelected = value; OnPropertyChanged(); }
    }

    #region INotifyPropertyChanged
    public event PropertyChangedEventHandler PropertyChanged;
    protected void OnPropertyChanged([CallerMemberName] string name = "")
    {
        PropertyChangedEventHandler handler = PropertyChanged;
        if (handler != null)
            handler(this, new PropertyChangedEventArgs(name));
    }
    #endregion
}
public class ViewModel
{
    private const string _asia = "Asia";
    private const string _europe = "Europe";
    private const string _northAmerica = "North America";

    public ViewModel()
    {
        this.Countries = new ObservableCollection<Country>();

        /* Asia */
        this.Countries.Add(new Country() { ContinentName = _asia, CountryName = "China" });
        this.Countries.Add(new Country() { ContinentName = _asia, CountryName = "India" });
        this.Countries.Add(new Country() { ContinentName = _asia, CountryName = "Japan" });
        this.Countries.Add(new Country() { ContinentName = _asia, CountryName = "Pakistan" });
        this.Countries.Add(new Country() { ContinentName = _asia, CountryName = "Thailand" });
        this.Countries.Add(new Country() { ContinentName = _asia, CountryName = "Vietnam" });

        /* Europe */
        this.Countries.Add(new Country() { ContinentName = _europe, CountryName = "France" });
        this.Countries.Add(new Country() { ContinentName = _europe, CountryName = "Germany" });
        this.Countries.Add(new Country() { ContinentName = _europe, CountryName = "Italy" });
        this.Countries.Add(new Country() { ContinentName = _europe, CountryName = "Russia" });
        this.Countries.Add(new Country() { ContinentName = _europe, CountryName = "Spain" });
        this.Countries.Add(new Country() { ContinentName = _europe, CountryName = "Sweden" });
        this.Countries.Add(new Country() { ContinentName = _europe, CountryName = "United Kingdom" });

        /* North America */
        this.Countries.Add(new Country() { ContinentName = _northAmerica, CountryName = "Canada" });
        this.Countries.Add(new Country() { ContinentName = _northAmerica, CountryName = "Mexico" });
        this.Countries.Add(new Country() { ContinentName = _northAmerica, CountryName = "USA" });
    }

    public ObservableCollection<Country> Countries { get; set; }
}

Grouping in XAML

The countries will be grouped and listed by continent in the view with each continent being represented by a three-state CheckBox whose IsChecked property value will be dependent of the value of the IsSelected property of each of the related Country objects.

To be able to group a collection of data in the view you can use the CollectionViewSource class. It allows you to specify sorting and grouping conditions directly in XAML without having to do anything in code. In this particular example you simply bind its Source property to the collection of Country objects in the view model and then group the countries by the ContinentName property by adding a PropertyGroupDescription as follows:

<Window.Resources>
    <CollectionViewSource x:Key="countries" Source="{Binding Countries}">
        <CollectionViewSource.GroupDescriptions>
            <PropertyGroupDescription PropertyName="ContinentName" />
        </CollectionViewSource.GroupDescriptions>
    </CollectionViewSource>
</Window.Resources>

Data Templating

The ItemssSource property of the ItemsControl is then bound to the CollectionViewSource instead of the collection property of the view model. You can then use a DataTemplate to define the appearance of a Country object. The sample markup below has a CheckBox bound to the IsChecked property of the Country class and a TextBlock to display the name of the country.

Using a DataTemplate with the DataType property set to the appropriate data type and without specifying an x:Key will automatically apply the template to all objects of the given type. This is arguably one of the most powerful and useful features of WPF.

<ItemsControl x:Name="itemsControl" ItemsSource="{Binding Source={StaticResource countries}}"
              xmlns:local="clr-namespace:Mm.ThreeStateCheckboxes">
    <ItemsControl.Resources>
        <DataTemplate DataType="{x:Type local:Country}">
            <StackPanel Orientation="Horizontal">
                <CheckBox IsChecked="{Binding IsSelected}"/>
                <TextBlock Text="{Binding CountryName}" Margin="2 0 0 0"/>
            </StackPanel>
        </DataTemplate>
    </ItemsControl.Resources>
    ...
</ItemsControl>

Defining a GroupStyle

To define the appearance of each group of countries, i.e. a continent, you specify a System.Windows.Controls.GroupStyle and add it to the GroupStyle property of the ItemsControl. This property contains a collection of GroupStyle objects that determine the group style for each level of groups (to support cases where you may have multiple levels of grouping) with the entry at index 0 describing the top-level group, the entry at index 1 describing the next level and so on.

The GroupStyle class has a ContainerStyle property of type System.Windows.Style that is used to determine the style for each group item in the same level. You can completely redefine the look and feel of a group item by specifying a style that sets the Template property to a custom ControlTemplate. In the sample markup below, an Expander control with a three-state CheckBox control and a TextBlock for displaying the name of the group in its header are used. The ItemsPresenter element is used within a template to specify where the ItemsPanel with the bound objects, i.e. the Country objects in this case, defined by the ItemsControl is to be added in the control’s visual tree.

<ItemsControl ItemsSource="{Binding Source={StaticResource countries}}" ... >
    ...
    <ItemsControl.GroupStyle>
        <GroupStyle>
            <GroupStyle.ContainerStyle>
                <Style TargetType="{x:Type GroupItem}">
                    <Setter Property="Template">
                        <Setter.Value>
                            <ControlTemplate TargetType="{x:Type GroupItem}">
                                <Expander>
                                    <Expander.Header>
                                        <StackPanel Orientation="Horizontal">
                                            <CheckBox IsThreeState="True" />
                                            <TextBlock Text="{Binding Name}" 
                                                       FontWeight="Bold"
                                                       Margin="2 0 0 0" />
                                        </StackPanel>
                                    </Expander.Header>
                                    <Expander.Content>
                                        <ItemsPresenter />
                                    </Expander.Content>
                                </Expander>
                            </ControlTemplate>
                        </Setter.Value>
                    </Setter>
                </Style>
            </GroupStyle.ContainerStyle>
        </GroupStyle>
    </ItemsControl.GroupStyle>
</ItemsControl>

Note that the style will be applied to elements of type System.Windows.Controls.GroupItem and the DataContext of each of these group elements is a MS.Internal.Data.CollectionViewGroupInternal object which you can see if you use the WPF Visualizer – a tool that lets you search and drill down the visual tree produced by WPF – when debugging the application in Visual Studio:

wpfvisualizer

The CollectionViewGroupInternal class is derived from the public System.Windows.Data.CollectionViewGroup class and this one has a Name property that returns the value of the property that is used to divide items into groups, i.e. the value of the ContinentName property in this example. The TextBlock in the above markup binds to this one. There is also an Items property that returns a collection of the items, i.e. the Country objects, or subgroups in the group.

The binding

You might be tempted to bind the IsChecked property of the three-state CheckBox control in the Expander control to the CollectionViewGroup.Items property and perhaps use a converter to iterate through all Country objects in the collection and return true, false or NULL depending on the number of objects that have the IsSelected property set to true. However, if you want the IsChecked property of the three-state CheckBox control to get updated whenever the user checks or unchecks a country you somehow need to refresh the binding associated with the IsChecked property when this happens. In other words, you want the binding to the collection of countries to be refreshed as soon as the value of the IsSelected property for any Country objects changes in order for the code in the converter class to be executed again.

This can be accomplished by adding some code to the view model. The ObservableCollection<T> class has a CollectionChanged event that occurs whenever an item gets added, removed, replaced, moved or when the entire collection is refreshed. Note that it doesn’t occur when a property value of an item inside the collection changes which is exactly what you want in this particular case. The trick to make this happen is to add a PropertyChangedEventHandler to all individual items that simply tells WPF to query the get accessor of the Countries property of the view model again as soon as any property of any Country object in the collection changes. For this to work, both the Country class and the view model must implement the System.ComponentModel.INotifyPropertyChanged interface. Also note that you must hook up the ObservableCollection<T>.CollectionEvent before adding any items to the collection:

public class ViewModel : INotifyPropertyChanged
{
    ...
    public ViewModel()
    {
        this.Countries = new ObservableCollection<Country>();
        this.Countries.CollectionChanged += Countries_CollectionChanged;

        /* Add countries here */
        ...
    }

    private void Countries_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
    {
        if (e.NewItems != null)
        {
            foreach (object country in e.NewItems)
            {
                (country as INotifyPropertyChanged).PropertyChanged
                    += new PropertyChangedEventHandler(item_PropertyChanged);
            }
        }

        if (e.OldItems != null)
        {
            foreach (object country in e.OldItems)
            {
                (country as INotifyPropertyChanged).PropertyChanged
                    -= new PropertyChangedEventHandler(item_PropertyChanged);
            }
        }
    }

    private void item_PropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        OnPropertyChanged("Countries");
    }

    public ObservableCollection<Country> Countries { get; set; }

    #region INotifyPropertyChanged
    public event PropertyChangedEventHandler PropertyChanged;
    protected void OnPropertyChanged(string name)
    {
        PropertyChangedEventHandler handler = PropertyChanged;
        if (handler != null)
            handler(this, new PropertyChangedEventArgs(name));
    }
    #endregion
}

Now you can bind the three-state CheckBox control’s IsChecked property to the Countries property of the view model using a converter. Converters change data from one type to another and provide a way to apply custom logic to a data binding. In the below code, a MultiBinding is used to pass the collection of countries and the name of the continent to a converter class that returns an appropriate value of type System.Nullable<System.Boolean> (bool?) that can be set on the three-state CheckBox control’s IsChecked property. A MultiBinding describes a collection of Binding objects and allows you to bind a dependency property to a list of source properties. You create your own converter by creating a class that implements the System.Windows.Data.IMultiValueConverter interface and its Convert and ConvertBack methods:

public class CountryCollectionToBooleanConverter : IMultiValueConverter
{
    public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
    {
        IEnumerable<Country> countries = values[0] as IEnumerable<Country>;
        string continentName = values[1] as string;
        if (countries != null && continentName != null)
        {
            IEnumerable<Country> countriesOnTheCurrentContinent
                = countries.Where(c => c.ContinentName.Equals(continentName));

            int selectedCountries = countriesOnTheCurrentContinent
                .Where(c => c.IsSelected)
                .Count();

            if (selectedCountries.Equals(countriesOnTheCurrentContinent.Count()))
                return true;

            if (selectedCountries > 0)
                return null;
        }

        return false;
    }

    public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
    {
        throw new NotImplementedException();
    }
}
<CheckBox IsThreeState="True">
    <CheckBox.IsChecked>
        <MultiBinding Converter="{StaticResource converter}"
                      Mode="OneWay">
            <MultiBinding.Bindings>
                <Binding Path="DataContext.Countries" 
                         RelativeSource="{RelativeSource AncestorType={x:Type Window}}"
                         Mode="OneWay"/>
                <Binding Path="Name" Mode="OneWay" />
            </MultiBinding.Bindings>
        </MultiBinding>
    </CheckBox.IsChecked>
</CheckBox>

Selecting all CheckBoxes

There is one last thing you need to do to if you want all countries of a continent to be automatically selected when you check their “parent” three-state CheckBox and deselected when you uncheck it. Add two DelegateCommands – for more information about commands and how to handle events in MVVM, see my last post here – to the view model that each takes the name of the continent as a parameter and sets the IsSelected property of each Country object that belongs to this continent to true or false:

public class ViewModel : INotifyPropertyChanged
{
    ...
    private ICommand _selectCountriesCommand;
    private ICommand _deSelectCountriesCommand;

    public ViewModel()
    {
        ...
        _selectCountriesCommand = new DelegateCommand<string>((continentName) =>
        {
            SetIsSelectedProperty(continentName, true);
        });

        _deSelectCountriesCommand = new DelegateCommand<string>((continentName) =>
        {
            SetIsSelectedProperty(continentName, false);
        });
    }

    private void SetIsSelectedProperty(string continentName, bool isSelected)
    {
        IEnumerable<Country> countriesOnTheCurrentContinent =
                this.Countries.Where(c => c.ContinentName.Equals(continentName));

        foreach (Country country in countriesOnTheCurrentContinent)
        {
            INotifyPropertyChanged c = country as INotifyPropertyChanged;
            c.PropertyChanged -= new PropertyChangedEventHandler(item_PropertyChanged);
            country.IsSelected = isSelected;
            c.PropertyChanged += new PropertyChangedEventHandler(item_PropertyChanged);
        }
    }

    public ICommand SelectCountriesCommand
    {
        get { return _selectCountriesCommand; }
    }

    public ICommand DeSelectCountriesCommand
    {
        get { return _deSelectCountriesCommand; }
    }
    ...
}

You then simply attach these commands to the Checked and Unchecked events of the three-state CheckBox respectively in the view using Expression Blend interaction triggers, as also described in my last post:

<CheckBox IsThreeState="True">
    ...
    <i:Interaction.Triggers>
        <i:EventTrigger EventName="Checked" >
            <i:InvokeCommandAction Command="{Binding DataContext.SelectCountriesCommand,
                RelativeSource={RelativeSource AncestorType={x:Type Window}}}"
                CommandParameter="{Binding Name}"/>
        </i:EventTrigger>
        <i:EventTrigger EventName="Unchecked" >
            <i:InvokeCommandAction Command="{Binding DataContext.DeSelectCountriesCommand,
                RelativeSource={RelativeSource AncestorType={x:Type Window}}}"
                CommandParameter="{Binding Name}"/>
        </i:EventTrigger>
    </i:Interaction.Triggers>
</CheckBox>

Remember to add a reference to the System.Windows.Interactivity.dll for this to compile. With this in place, the CheckBox controls for all countries of a continent should be checked when you check the three-state CheckBox control and unchecked when you uncheck it. The state of the three-state CheckBox should also be updated whenever you check or uncheck a country.

threestatecheckbox


One Comment on “How to bind a three-state CheckBox to some other CheckBoxes in a data-bound ItemsControl in WPF using MVVM”

  1. Thank you for this Post.

    Is it possible to get the whole code as VS Project?


Leave a comment