XamComboEditor: Adding CheckedMemberPath, ValueMemberPath, and SelectedValue Functionality

Brian Lagunas / Tuesday, November 6, 2012

My job as a Product Manager for Infragistics NetAdvantage for WPF and Silverlight has many aspects. One of those aspects is knowing the good and the bad about my product.  Naturally it is always easier to concentrate on the good, but that’s not my style.  I am constantly trying to improve my product to make your job easier so that you can meet your deadlines, and get your bonuses to help support your family.  I can’t do that if I don’t know where my products need improvement.  That’s where you come in.

I received an email from customer expressing their frustrations using the shared XamComboEditor in multi-select mode.  Here are the summations of their issues:

  1. There is no way to data bind the underlying object to the CheckBox that is represented in the ComboBoxItem.  They would like to have a property called CheckedMemberPath that would let them create a binding between a property on the bound object (maybe called IsSelected) and to the IsChecked property of the CheckBox on the combo box item itself.  This way they could select and unselect items from within their VM.
  2. There is no ValueMemberPath property so they can choose what property on the underlying object they want to use as the bound value.
  3. There is no SelectedValue property that allows them to data bind and gain access to the selected value, based on the ValueMemberPath, of the selected item in the XamComboEditor.  In the case of multi-select, they were wanting this SelectedValue to be represented as a comma delimited list of values controls by the ValueMemberPath.  The use case here is that they would store a comma delimited list of values to represent the multiple items/ids that were saved previously.

Wow, that’s a pretty big list, but it’s not out of the ordinary.  These are features that I would expect to be in our combo box, but they aren’t.  Heck, even Microsoft's ComboBox has a couple of those.  So there’s my challenge.  I need to add all this functionality without the need for modifying the source code.  That means I will be using a Behavior to implement every one of these features.  Yeah, that’s right, a Behavior.  Now, this is not simple code and uses some binding tricks and a little reflection, so I am going to simply paste the code and explain some of the tricks I used.

The Behavior:
public class CheckBoxSelectionBehavior : Behavior<XamComboEditor>
{
    private bool _surpressSelectedValuePropertyChangedCallback;
    private bool _surpressSelectedValueUpdate;

    #region Properties

    public static readonly DependencyProperty SelectedMemberPathProperty = DependencyProperty.Register("CheckedMemberPath", typeof(string), typeof(CheckBoxSelectionBehavior), new UIPropertyMetadata(String.Empty));
    public string CheckedMemberPath
    {
        get { return (string)GetValue(SelectedMemberPathProperty); }
        set { SetValue(SelectedMemberPathProperty, value); }
    }

    public static readonly DependencyProperty ItemsSourceProperty = DependencyProperty.Register("ItemsSource", typeof(IEnumerable), typeof(CheckBoxSelectionBehavior), new UIPropertyMetadata(null, new PropertyChangedCallback(OnItemsSourceChanged)));
    public IEnumerable ItemsSource
    {
        get { return (IEnumerable)GetValue(ItemsSourceProperty); }
    }

    private static void OnItemsSourceChanged(DependencyObject o, DependencyPropertyChangedEventArgs e)
    {
        CheckBoxSelectionBehavior bahavior = o as CheckBoxSelectionBehavior;
        if (bahavior != null)
            bahavior.OnItemsSourceChanged((IEnumerable)e.OldValue, (IEnumerable)e.NewValue);
    }

    private void OnItemsSourceChanged(IEnumerable oldvalue, IEnumerable newValue)
    {
        if (newValue != null)
        {
            _surpressSelectedValuePropertyChangedCallback = true;

            GetInitiallySelectedItems();
            UpdateSelectedValue();

            _surpressSelectedValuePropertyChangedCallback = false;
        }
    }

    public static readonly DependencyProperty SelectedValueProperty = DependencyProperty.Register("SelectedValue", typeof(string), typeof(CheckBoxSelectionBehavior), new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnSelectedValueChanged));
    public string SelectedValue
    {
        get { return (string)GetValue(SelectedValueProperty); }
        set { SetValue(SelectedValueProperty, value); }
    }

    private static void OnSelectedValueChanged(DependencyObject o, DependencyPropertyChangedEventArgs e)
    {
        CheckBoxSelectionBehavior bahavior = o as CheckBoxSelectionBehavior;
        if (bahavior != null)
            bahavior.OnSelectedValueChanged((string)e.OldValue, (string)e.NewValue);
    }

    protected virtual void OnSelectedValueChanged(string oldValue, string newValue)
    {
        if (_surpressSelectedValuePropertyChangedCallback)
            return;

        UpdateSelectedItemsFromSelectedValue();
    }

    public static readonly DependencyProperty ValueMemberPathProperty = DependencyProperty.Register("ValueMemberPath", typeof(string), typeof(CheckBoxSelectionBehavior), new UIPropertyMetadata(String.Empty, OnValueMemberPathChanged));
    public string ValueMemberPath
    {
        get { return (string)GetValue(ValueMemberPathProperty); }
        set { SetValue(ValueMemberPathProperty, value); }
    }

    private static void OnValueMemberPathChanged(DependencyObject o, DependencyPropertyChangedEventArgs e)
    {
        CheckBoxSelectionBehavior bahavior = o as CheckBoxSelectionBehavior;
        if (bahavior != null)
            bahavior.OnValueMemberPathChanged((string)e.OldValue, (string)e.NewValue);
    }

    protected virtual void OnValueMemberPathChanged(string oldValue, string newValue)
    {
        if (!String.IsNullOrWhiteSpace(newValue))
            UpdateSelectedValue();
    }

    #endregion //Properties

    #region Base Class Overrides

    protected override void OnAttached()
    {
        base.OnAttached();

        Binding binding = new Binding("ItemsSource");
        binding.Source = AssociatedObject;
        BindingOperations.SetBinding(this, CheckBoxSelectionBehavior.ItemsSourceProperty, binding);

        AssociatedObject.SelectionChanged += AssociatedObject_SelectionChanged;
    }

    protected override void OnDetaching()
    {
        base.OnDetaching();
        BindingOperations.ClearBinding(this, CheckBoxSelectionBehavior.ItemsSourceProperty);
        AssociatedObject.SelectionChanged -= AssociatedObject_SelectionChanged;
    }

    #endregion //Base Class Overrides

    #region Event Handlers

    void AssociatedObject_SelectionChanged(object sender, EventArgs e)
    {
        if (_surpressSelectedValuePropertyChangedCallback)
            return;

        //update the selected value string but only if we are not surpressing the operation.
        if (!_surpressSelectedValueUpdate)
            UpdateSelectedValue();

        //because we do not know which item was unchecked, and we cannot create a data binding between the checkbox and the SelectedMemberPath,
        //we have to add this ugly slow hack that will iterate through all the objects and update the SelectedMemberPath of the bound object
        //this may reduce performance on large lists
        foreach (var item in AssociatedObject.Items)
        {
            if (!String.IsNullOrEmpty(CheckedMemberPath))
            {
                var property = item.Data.GetType().GetProperty(CheckedMemberPath);
                if (property != null)
                    property.SetValue(item.Data, item.IsSelected, null);
            }
        }
    }

    #endregion //Event Handlers

    #region Methods

    private void ClearSelectedItems()
    {
        if (AssociatedObject != null && AssociatedObject.SelectedItems != null)
            AssociatedObject.SelectedItems.Clear();
    }

    private void GetInitiallySelectedItems()
    {
        if (ItemsSource == null)
            return;

        var list = new List<object>();

        foreach (var item in ItemsSource)
        {
            var property = item.GetType().GetProperty(CheckedMemberPath);
            if (property != null)
            {
                if ((Boolean)property.GetValue(item, null))
                {
                    AssociatedObject.SelectedItems.Add(item);
                }
            }
        }
    }

    protected object ResolveItemByValue(string value)
    {
        if (!String.IsNullOrEmpty(ValueMemberPath))
        {
            if (ItemsSource != null)
            {
                foreach (object item in ItemsSource)
                {
                    var property = item.GetType().GetProperty(ValueMemberPath);
                    if (property != null)
                    {
                        var propertyValue = property.GetValue(item, null);
                        if (value.Equals(propertyValue.ToString(), StringComparison.InvariantCultureIgnoreCase))
                            return item;
                    }
                }
            }
        }

        return value;
    }

    private void UpdateSelectedItemsFromSelectedValue()
    {
        if (AssociatedObject == null)
            return;

        _surpressSelectedValueUpdate = true;

        ClearSelectedItems();

        if (!String.IsNullOrEmpty(SelectedValue))
        {
            string[] values = SelectedValue.Split(new string[] { AssociatedObject.MultiSelectValueDelimiter.ToString() }, StringSplitOptions.RemoveEmptyEntries);
            foreach (string value in values)
            {
                var item = ResolveItemByValue(value);
                if (item != null)
                    AssociatedObject.SelectedItems.Add(item);
            }
        }

        _surpressSelectedValueUpdate = false;
    }

    private void UpdateSelectedValue()
    {
        if (AssociatedObject == null)
            return;

        _surpressSelectedValuePropertyChangedCallback = true;

        string newValue = String.Join(AssociatedObject.MultiSelectValueDelimiter.ToString(), AssociatedObject.SelectedItems.Cast<object>().Select(x => GetItemValue(x)));

        if (String.IsNullOrEmpty(SelectedValue) || !SelectedValue.Equals(newValue))
            SelectedValue = newValue;

        _surpressSelectedValuePropertyChangedCallback = false;
    }

    protected object GetItemValue(object item)
    {
        if (!String.IsNullOrEmpty(ValueMemberPath) && (item != null))
        {
            var property = item.GetType().GetProperty(ValueMemberPath);
            if (property != null)
                return property.GetValue(item, null);
        }

        return item;
    }

    #endregion //Methods
}

The first thing I needed to solve was gaining access to the ItemsSource of the AssociatedObject.  Now, your first thought might be to just use AssociatedObject.ItemsSource property.  Since the AssociatedObject is of type XamComboEditor, this would work perfectly.  Well, that would most definitely work, but there is one issue you will run into with that.  I need to know when the XamComboEditor.ItemsSource changes.  That means I need a way to add a property changed callback handler.  Therefore I will use a little data binding trick.  I will simply create an ItemsSource property on my Behavior and then data bind that to the AssociatedObject.ItemsSource property and define a callback handler.

Binding binding = new Binding("ItemsSource");
binding.Source = AssociatedObject;
BindingOperations.SetBinding(this, CheckBoxSelectionBehavior.ItemsSourceProperty, binding);

Notice this handy little snippet in the constructor of the Behavior.  Pay special attention to the use of the BindingOperations class.  I have to use this class to create a binding because a Behavior does not derive from FrameworkElement and does not provide me with the FrameworkElement.SetBinding method to create a data binding.  Now I can create a binding to the attached XamComboEditor.ItemsSource and react any time the ItemsSource property is updated. 

Now, I want to add the ability to create a “data binding” between a property on the object and the CheckBox.IsChecked property in the combo box item.  So my first step in this process was to add a property called CheckedMemberPath.  This property would represent the name of the property on the underlying data bound object that would represent the state of the CheckBox.  Adding the property was no problem, but creating a data binding to the CheckBox.IsChecked property turned out to be impossible.  Why, you ask?  Well, this is one part that really pissed me off about this control.  Turns out that the “combo box item” in the XamComboEditor is NOT a freaking control!  Yeah, you heard me! It’s a simple class that wraps a control inside of it, which means absolutely no support for any type of data binding.  FACE PALM!!!!!  I would have loved to be in on the meeting in which that decision was made.  So that means I need to add an ugly hack into our Behavior to use reflection to manually check and uncheck all the items in the list every time the SelectedItem changes.  UUUUHHHHHGGGGGG!!!!

Now we need a SelectedValue property.  This property will store a delimited list of values for each of the selected items in the XamComboEditor.  When the ItemsSource is updated, we need to set the initially selected items (because we are supporting data binding now and there may be items that need to be preselected in the control), and then update the SelectedValue property.  In our scenario, the SelectedValue property is represented as a string.  This will be a comma delimited list of values that can be data bound to from within a view model.  We also need to support setting this SelectedValue property and having the items property selected in the XamComboEditor.  So how do we know what values to store in the SelectedValue property?  Well, we need to add another property called ValueMemberPath.  This property will let us now which property to use in order to pull a value from the selected item in the XamComboEditor.

Those are the basic building blocks I used to add the missing features.  Be sure to take a good look at the code, and if you see anything I missed, or that can be done more efficiently, please let me know so I can update the code.

How do you use this Behavior?  First make sure you add a reference to System.Windows.Interactivity and add a namespace in your XAML.  Then simply attached the Behavior and set the necessary properties.

<ig:XamComboEditor Grid.Row="1" Margin="5" VerticalAlignment="Top"
                   ItemsSource="{Binding People}"
                   DisplayMemberPath="{Binding Name}"
                   CheckBoxVisibility="Visible"
                   AllowMultipleSelection="True" >
    <i:Interaction.Behaviors>
        <local:CheckBoxSelectionBehavior CheckedMemberPath="IsSelected"
                                         ValueMemberPath="Id"
                                         SelectedValue="{Binding PeopleValues}" />
    </i:Interaction.Behaviors>
</ig:XamComboEditor>

I am including this sample application for testing purposes.

image

Notice that you have options to change the Delimiter, ValueMemberPath, and SelectedValue.  Keep in mind that the ValueMemberPath property should be unique or bad things will happen.  When you change the SelectedValue with the keyboard, the items will be selected and unselect inside the XamComboEditor as expected.  When you select and unselect items, pay attention to the IsSelected values of the listed objects.  The values should be updated to reflect the correct state of the XamComboEditor selected items, as well as properly update the SelectedValue property value.

I will try to get these features built into the control as soon as I can, but until then you can use my Behavior as a workaround.

As you can see, I am a little different than most Product Managers out there.  I am probably more critical of my controls than you are, but that’s because I want them to be the best controls out there.  When using Infragistics XAML controls; if you find properties that are missing, or features that should be there but aren’t, let me know ASAP.  I can’t fix something if I don’t know about it.

You can download the source code here.  If you have any questions you can contact me on Twitter (@brianlagunas), on my blog, or leave a comment below.