WPF ListBox SelectedItems TwoWay Binding • Oleksii Holub

For some unclear reasons, WPF’s ListBox control does not allow two-way binding on the SelectedItems property the way it does with SelectedItem. This could have been very useful when using multi-select to bind the whole list of selected items to the view model.

Interestingly, you can still call Add(), Remove(), Clear() methods on ListBox.SelectedItems which updates the selection correctly, so it just comes down to implementing a behavior that makes the property bindable.

Behavior implementation

Here’s the behavior that allows two-way binding on SelectedItems:

public class ListBoxSelectionBehavior<T> : Behavior<ListBox>
{
    public static readonly DependencyProperty SelectedItemsProperty =
        DependencyProperty.Register(
            nameof(SelectedItems),
            typeof(IList),
            typeof(ListBoxSelectionBehavior),
            new FrameworkPropertyMetadata(
                null,
                FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
                OnSelectedItemsChanged
            )
        );

    private static void OnSelectedItemsChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args)
    {
        var behavior = (ListBoxSelectionBehavior) sender;
        if (behavior._modelHandled) return;

        if (behavior.AssociatedObject == null)
            return;

        behavior._modelHandled = true;
        behavior.SelectItems();
        behavior._modelHandled = false;
    }

    private bool _viewHandled;
    private bool _modelHandled;

    public IList SelectedItems
    {
        get => (IList) GetValue(SelectedItemsProperty);
        set => SetValue(SelectedItemsProperty, value);
    }

    // Propagate selected items from model to view
    private void SelectItems()
    {
        _viewHandled = true;
        AssociatedObject.SelectedItems.Clear();
        if (SelectedItems != null)
        {
            foreach (var item in SelectedItems)
                AssociatedObject.SelectedItems.Add(item);
        }
        _viewHandled = false;
    }

    // Propagate selected items from view to model
    private void OnListBoxSelectionChanged(object sender, SelectionChangedEventArgs args)
    {
        if (_viewHandled) return;
        if (AssociatedObject.Items.SourceCollection == null) return;

        SelectedItems = AssociatedObject.SelectedItems.Cast<T>().ToArray();
    }

    // Re-select items when the set of items changes
    private void OnListBoxItemsChanged(object sender, NotifyCollectionChangedEventArgs args)
    {
        if (_viewHandled) return;
        if (AssociatedObject.Items.SourceCollection == null) return;

        SelectItems();
    }

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

        AssociatedObject.SelectionChanged += OnListBoxSelectionChanged;
        ((INotifyCollectionChanged) AssociatedObject.Items).CollectionChanged += OnListBoxItemsChanged;
    }

    /// <inheritdoc />
    protected override void OnDetaching()
    {
        base.OnDetaching();

        if (AssociatedObject != null)
        {
            AssociatedObject.SelectionChanged -= OnListBoxSelectionChanged;
            ((INotifyCollectionChanged) AssociatedObject.Items).CollectionChanged -= OnListBoxItemsChanged;
        }
    }
}

The behavior above defines its own SelectedItems property, identical to the one in ListBox, except it can be bound to and is not read-only.

When the property is changed from the view model, the OnSelectedItemsChanged(...) method is called, which is where the changes are propagated to the view. We do that in the SelectItems() method where we just clear and add new items to the ListBox.SelectedItems collection.

When the change is triggered by the view, we call the OnListBoxSelectionChanged(...) method. To update the selected items on the view model, we copy the items from ListBox.SelectedItems to our own SelectedItems collection.

Note that this behavior is generic because we expect to be able to bind to a collection of an arbitrary type on the view model’s side. WPF doesn’t support generic behaviors, however, so we have to subtype this class for each specific data type:

public class MyObjectListBoxSelectionBehavior : ListBoxSelectionBehavior<MyObject>
{
}

Usage

We can now use this behavior by initializing it in XAML, like this:

<ListBox ItemsSource="{Binding Items}" SelectionMode="Multiple">
    <i:Interaction.Behaviors>
        <behaviors:MyObjectListBoxSelectionBehavior SelectedItems="{Binding SelectedItems}" />
    </i:Interaction.Behaviors>
    <ListBox.ItemTemplate>
        <!-- ... -->
    </ListBox.ItemTemplate>
</ListBox>

Adding support for SelectedValuePath

Another useful feature of ListBox is that you can make a binding proxy using SelectedValuePath and SelectedValue. Setting SelectedValuePath lets you specify a member path to be evaluated by SelectedValue.

The great part about it is that it also works the other way around — changing SelectedValue will use the member path in SelectedValuePath to update SelectedItem with a new reference.

This could also be very useful for multi-select, but unfortunately the plural version, SelectedValues, does not exist. Let’s extend our behavior to add support for it.

public class ListBoxSelectionBehavior<T> : Behavior<ListBox>
{
    public static readonly DependencyProperty SelectedItemsProperty =
        DependencyProperty.Register(
            nameof(SelectedItems),
            typeof(IList),
            typeof(ListBoxSelectionBehavior),
            new FrameworkPropertyMetadata(
                null,
                FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
                OnSelectedItemsChanged
            )
        );

    public static readonly DependencyProperty SelectedValuesProperty =
        DependencyProperty.Register(
            nameof(SelectedValues),
            typeof(IList),
            typeof(ListBoxSelectionBehavior),
            new FrameworkPropertyMetadata(
                null,
                FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
                OnSelectedValuesChanged
            )
        );

    private static void OnSelectedItemsChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args)
    {
        var behavior = (ListBoxSelectionBehavior) sender;
        if (behavior._modelHandled) return;

        if (behavior.AssociatedObject == null)
            return;

        behavior._modelHandled = true;
        behavior.SelectedItemsToValues();
        behavior.SelectItems();
        behavior._modelHandled = false;
    }

    private static void OnSelectedValuesChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args)
    {
        var behavior = (ListBoxSelectionBehavior) sender;
        if (behavior._modelHandled) return;

        if (behavior.AssociatedObject == null)
            return;

        behavior._modelHandled = true;
        behavior.SelectedValuesToItems();
        behavior.SelectItems();
        behavior._modelHandled = false;
    }

    private static object GetDeepPropertyValue(object obj, string path)
    {
        if (string.IsNullOrWhiteSpace(path)) return obj;
        while (true)
        {
            if (path.Contains('.'))
            {
                string[] split = path.Split('.');
                string remainingProperty = path.Substring(path.IndexOf('.') + 1);
                obj = obj.GetType().GetProperty(split[0]).GetValue(obj, null);
                path = remainingProperty;
                continue;
            }
            return obj.GetType().GetProperty(path).GetValue(obj, null);
        }
    }

    private bool _viewHandled;
    private bool _modelHandled;

    public IList SelectedItems
    {
        get => (IList) GetValue(SelectedItemsProperty);
        set => SetValue(SelectedItemsProperty, value);
    }

    public IList SelectedValues
    {
        get => (IList) GetValue(SelectedValuesProperty);
        set => SetValue(SelectedValuesProperty, value);
    }

    // Propagate selected items from model to view
    private void SelectItems()
    {
        _viewHandled = true;
        AssociatedObject.SelectedItems.Clear();
        if (SelectedItems != null)
        {
            foreach (var item in SelectedItems)
                AssociatedObject.SelectedItems.Add(item);
        }
        _viewHandled = false;
    }

    // Update SelectedItems based on SelectedValues
    private void SelectedValuesToItems()
    {
        if (SelectedValues == null)
        {
            SelectedItems = null;
        }
        else
        {
            SelectedItems =
                AssociatedObject.Items.Cast<T>()
                    .Where(i => SelectedValues.Contains(GetDeepPropertyValue(i, AssociatedObject.SelectedValuePath)))
                    .ToArray();
        }
    }

    // Update SelectedValues based on SelectedItems
    private void SelectedItemsToValues()
    {
        if (SelectedItems == null)
        {
            SelectedValues = null;
        }
        else
        {
            SelectedValues =
                SelectedItems.Cast<T>()
                    .Select(i => GetDeepPropertyValue(i, AssociatedObject.SelectedValuePath))
                    .ToArray();
        }
    }

    // Propagate selected items from view to model
    private void OnListBoxSelectionChanged(object sender, SelectionChangedEventArgs args)
    {
        if (_viewHandled) return;
        if (AssociatedObject.Items.SourceCollection == null) return;

        SelectedItems = AssociatedObject.SelectedItems.Cast<object>().ToArray();
    }

    // Re-select items when the set of items changes
    private void OnListBoxItemsChanged(object sender, NotifyCollectionChangedEventArgs args)
    {
        if (_viewHandled) return;
        if (AssociatedObject.Items.SourceCollection == null) return;

        SelectItems();
    }

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

        AssociatedObject.SelectionChanged += OnListBoxSelectionChanged;
        ((INotifyCollectionChanged) AssociatedObject.Items).CollectionChanged += OnListBoxItemsChanged;

        _modelHandled = true;
        SelectedValuesToItems();
        SelectItems();
        _modelHandled = false;
    }

    /// <inheritdoc />
    protected override void OnDetaching()
    {
        base.OnDetaching();

        if (AssociatedObject != null)
        {
            AssociatedObject.SelectionChanged -= OnListBoxSelectionChanged;
            ((INotifyCollectionChanged) AssociatedObject.Items).CollectionChanged -= OnListBoxItemsChanged;
        }
    }
}

I added another dependency property for SelectedValues and a few new methods.

SelectedValuesToItems() and SelectedItemsToValues() convert between SelectedItems and SelectedValues, depending on which property was updated. GetDeepPropertyValue(...) is used to extract the value of the property using an object and a member path, to establish conformity between selected items and their values.

Usage with SelectedValuePath

Now we can specify SelectedValuePath in ListBox and our behavior will allow us to bind the SelectedValues property to the model and vice versa.

<ListBox ItemsSource="{Binding Items}" SelectedValuePath="ID" SelectionMode="Multiple">
    <i:Interaction.Behaviors>
        <behaviors:MyObjectListBoxSelectionBehavior SelectedValues="{Binding SelectedValues}" />
    </i:Interaction.Behaviors>
    <ListBox.ItemTemplate>
        <!-- ... -->
    </ListBox.ItemTemplate>
</ListBox>