Friday, January 15, 2021

DependencyProperty as List of unknown type

I wanted to improve an earlier ListBox control that exposed the SelectedItems as a dependency property. The previous attempt had assumed the SelectedItems would be bound to a List<String> but Microsoft's ItemSource property is an IEnumerable so I thought I should be able to make my DependencyProperty an IList, but it's more complicated than I expected.

You have to cast the collection to a List<T> where T is the List member class the DependencyProperty is bound to. To do this you have to capture T and you have to cast List<object> to List<T> in the DependencyProperty setter.

In addition I want to tweak the ListBox ItemTemplate to insert a check box in each row, making it easier for users to toggle the row selected states. I decided to use the FrameworkElementFactory even though it is not recommended. It still does everything I need and the alternative of creating XAML in code and parsing it is a total bodge.

Start with a new User Control project in Visual Studio 2019, C#, .Net Core. Call it MultiSelectListBox.

The code will start by defining the default style, the SelectedItemsList DependencyProperty, its getter and setter, and somewhere to store the element type the DependencyProperty is bound to.

using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Media;
 
namespace MultiSelectListbox
{
    public class MultiSelectListbox : ListBox
    {
        private Type boundType;
 
        static MultiSelectListbox()
        {
            DefaultStyleKeyProperty.OverrideMetadata(typeof(MultiSelectListbox), new FrameworkPropertyMetadata(typeof(ListBox)));
        }
 
        public static readonly DependencyProperty SelectedItemsListProperty = DependencyProperty.Register("SelectedItemsList"typeof(IList), typeof(MultiSelectListbox));
 
        public static IList GetSelectedItemsList(DependencyObject obj)
        {
            return obj.GetValue(SelectedItemsListProperty) as IList;
        }
 
        public static void SetSelectedItemsList(DependencyObject objIList value)
        {
            Type boundType = (obj as MultiSelectListbox).boundType;
            Type genericType = typeof(List<>);
            Type returnType = genericType.MakeGenericType(boundType);
 
            IList castValue = System.Activator.CreateInstance(returnTypeas IList;
            foreach (object o in value)
                castValue.Add(Convert.ChangeType(oboundType));
 
            obj.SetValue(SelectedItemsListProperty, castValue);
        }
 
    }
}

The setter contains the first interesting bit of code. We have to convert value from IList to List<boundType>. We will populate boundType from the binding source during the Loaded event. By doing this, we ensure the source can be updated. If we don't do this, the source will always be null.

Next we write the constructor. It simply assigns some event handlers.

public MultiSelectListbox() 
{
    this.Loaded += MultiSelectListbox_Loaded;
    this.SelectionChanged += MultiSelectListbox_SelectionChanged;
}
 

Whever the list boxes SelectedItems collection changes we need to make the same changes to our DependencyProperty. We cannot simply assign the new SelectedItems collection to our DependencyProperty because that would break the binding. For each item that was added we add it to our DependencyProperty and check the associated check box. For each item that was removed we remove it from our DependencyProperty and uncheck the associated check box. Once we have our new Selected collection we call SetSelectedItemsList to convert it and SetValue.

private static void MultiSelectListbox_SelectionChanged(object senderSelectionChangedEventArgs e)
{
    MultiSelectListbox lb = sender as MultiSelectListbox;
    IList SelectedItemsList = GetSelectedItemsList(lb);
    if (lb.SelectedItems == nullreturn;
    if (SelectedItemsList == nullSelectedItemsList = new List<object>();
 
    foreach (object addedItem in e.AddedItems)
    {
        SelectedItemsList.Add(addedItem);
        ListBoxItem lbi = lb.ItemContainerGenerator.ContainerFromItem(addedItemas ListBoxItem;
        FindChildrenOfType<CheckBox>(lbi as DependencyObject).First().IsChecked = true;
    }
 
    foreach (object removedItem in e.RemovedItems)
    {
        SelectedItemsList.Remove(removedItem);
        ListBoxItem lbi = lb.ItemContainerGenerator.ContainerFromItem(removedItemas ListBoxItem;
        FindChildrenOfType<CheckBox>(lbi as DependencyObject).First().IsChecked = false;
    }
 
    SetSelectedItemsList(lbSelectedItemsList); 

}

The Loaded event handler is mainly concerned with replacing the DisplayMemberPath with a new ItemTemplate which is built in code. It uses the FrameworkElementFactory which works well enough for our purposes. Note how we start by using reflection to get the binding's generic argument type and store it in boundType.

private static void MultiSelectListbox_Loaded(object senderRoutedEventArgs e)
{
    MultiSelectListbox lb = sender as MultiSelectListbox;
    BindingExpression be = lb.GetBindingExpression(SelectedItemsListProperty);
    FrameworkElement rs = be.ResolvedSource as FrameworkElement;
    PropertyInfo lpi = rs.DataContext.GetType().GetProperty(be.ResolvedSourcePropertyName);
    lb.boundType = lpi.PropertyType.GetGenericArguments().FirstOrDefault();
 
    DataTemplate dt = new DataTemplate(typeof(MultiSelectListbox));
    FrameworkElementFactory fef = new FrameworkElementFactory(typeof(StackPanel)) { Name = "ItemTemplate" };
    fef.SetValue(StackPanel.OrientationProperty, Orientation.Horizontal);
 
    FrameworkElementFactory cb = new FrameworkElementFactory(typeof(CheckBox));
    cb.AddHandler(CheckBox.CheckedEvent, new RoutedEventHandler(CheckedChanged));
    cb.AddHandler(CheckBox.UncheckedEvent, new RoutedEventHandler(CheckedChanged));
    fef.AppendChild(cb);
 
    FrameworkElementFactory tb = new FrameworkElementFactory(typeof(TextBlock));
    tb.SetBinding(TextBlock.TextProperty, new Binding(lb.DisplayMemberPath));
    fef.AppendChild(tb);
 
    dt.VisualTree = fef;
 
    lb.DisplayMemberPath = null;
    lb.ItemTemplate = dt;
 
    if (be.ParentBinding.Mode != BindingMode.TwoWay)
        throw new NotSupportedException("SelectedItemsList Binding Mode must be TwoWay");
}

When a check box is checked or unchecked we need to update the IsSelected property of its parent row which will call MultiSelectListbox_SelectionChanged and update the DependencyProperty.

private static void CheckedChanged(object senderRoutedEventArgs e)
{
    CheckBox cb = sender as CheckBox;
    ListBoxItem lbi = GetParentOfType<ListBoxItem>(cb);
    lbi.IsSelected = cb.IsChecked.Value; 

}

Between them MultiSelectListbox_SelectionChanged and CheckedChanged keep the check boxes and list box items synchronized.

We also need a couple of VisualTree helper methods that you probably already have somewhere.

private static T GetParentOfType<T>(DependencyObject controlwhere T : System.Windows.DependencyObject
{
    DependencyObject ParentControl = control;
 
    do
        ParentControl = VisualTreeHelper.GetParent(ParentControl);
    while (ParentControl != null && !(ParentControl is T));
 
    return ParentControl as T;
}
 
private static List<TFindChildrenOfType<T>(DependencyObject depObjwhere T : DependencyObject
{
    List<TChildren = new List<T>();
    if (depObj != null)
    {
        for (int i = 0; i < VisualTreeHelper.GetChildrenCount(depObj); i++)
        {
            DependencyObject child = VisualTreeHelper.GetChild(depObji);
            if (child != null && child is T)
                Children.Add(child as T);
            Children.AddRange(FindChildrenOfType<T>(child));
        }
    }
    return Children;
}

Let's see how we would consume our new ListBox. Add a WPF project called MultiSelectListBoxTest and make it the startup project.

The XAML is very simple.

<local:BaseWindow x:Class="MultiSelectListboxTest.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:custom="clr-namespace:MultiSelectListbox;assembly=MultiSelectListbox"
        xmlns:local="clr-namespace:MultiSelectListboxTest"
        mc:Ignorable="d"
        DataContext="{Binding RelativeSource={RelativeSource Self}}"
        Title="MainWindow" Height="450" Width="800">
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="auto"/>
            <RowDefinition Height="auto"/>
        </Grid.RowDefinitions>
        <custom:MultiSelectListbox Grid.Row="0" ItemsSource="{Binding Selectables}" SelectedItemsList="{Binding Selecteds, Mode=TwoWay}" DisplayMemberPath="Name" SelectionMode="Extended"/>
        <TextBlock Grid.Row="1" Text="{Binding SelectedsString}"/>
    </Grid>
</local:BaseWindow>

The code behind is mainly concerned with creating a Selectables and Selecteds property with type List<cSelectableItem>. When Selecteds is changed we concatenate the names and display them so you can see how the SelectedItemsList DependencyProperty works.

using System.Collections;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
 
namespace MultiSelectListboxTest
{
    public partial class MainWindow : BaseWindow
    {
        public class cSelectableItem : BaseClass
        {
            public string Name { getset; }
        }
 
        private List<cSelectableItem> _Selectables;
        public List<cSelectableItem> Selectables
        {
            get { return _Selectables; }
            set { SetProperty(ref _Selectables, value); }
        }
 
        private List<cSelectableItem> _Selecteds;
        public List<cSelectableItem> Selecteds
        {
            get { return _Selecteds; }
            set
            {
                SetProperty(ref _Selecteds, value);
                SelectedsString = string.Join(",", Selecteds.Select(s => s.Name));
            }
        }
 
        private string _SelectedsString;
        public string SelectedsString
        {
            get { return _SelectedsString; }
            set { SetProperty(ref _SelectedsString, value); }
        }
 
        public MainWindow()
        {
            Selectables = new List<cSelectableItem>() { new cSelectableItem() { Name = "Alpha" }, new cSelectableItem() { Name = "Beta" }, new cSelectableItem() { Name = "Gamma" } };
            InitializeComponent();
        }
    }
}




No comments:

Post a Comment