Friday, March 1, 2024

A converter to filter lists before binding to them

A member of my team has a user control with two dropdown lists on it. The dropdown lists were to display the same items except that one of them was to exclude one of the items. He could have created two different collections and bound each dropdown list to its own collection, but he decided to write a converter for one of them that would exclude the unwanted item. In my opinion, this is the superior solution.

But he wrote a converter that was specific to this one requirement and it occurred to me that it should be possible to write a more generic converter using reflection. So I took a crack at it and this is my solution.

The idea is that you bind the dropdown list's ItemSource to a collection and pass a filter in as the converter parameter. Something like 'ID <> 0'. The converter will only return items whose ID is not zero, thus removing the item whose ID is zero.

Start a new Visual Studio C# project called FilteredEnumerableConverterDemo. 

Add a class called Converters and add the FilteredEnumerableConverter like this. It only supports Lists and ObservableCollections. The parameter has very strict syntax and only supports basic comparisons. Feel free to use it as a starting point to add ranges, lists, startswith, etc.

using System.Globalization;
using System.Reflection;
using System.Windows.Data;

namespace FilteredEnumerableConverterDemo
{
    public class FilteredEnumerableConverter : IValueConverter
    {
        /// <summary>
        ///     Returns only the enumerable members with properties that match the filter specified in the parameter
        /// </summary>
        /// <param name="value">A List or ObservableCollection</param>
        /// <param name="targetType"></param>
        /// <param name="parameter">filter in the form <propertyname> <op> <value> </param>
        /// <param name="culture"></param>
        /// <returns>An Enumerable containing only the members that match the filter</returns>
        /// <exception cref="NotImplementedException"></exception>
        public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
        {
            if (string.IsNullOrWhiteSpace(parameter?.ToString())) return value;
            if (value == null) return null;

            try
            {
                if (new[] { "List`1", "ObservableCollection`1" }.Contains(value.GetType().Name))
                    return FilterList((System.Collections.IEnumerable)value, parameter.ToString());
                else
                    return value;
            }
            catch (Exception ex)
            {
                throw new Exception("FilteredEnumerableConverter:" + ex.Message);
            }
        }

        private System.Collections.IEnumerable FilterList(System.Collections.IEnumerable list, string filter)
        {
            List<object> newList = new List<object>();
            String[] filterParts = filter.Split(' ');

            if (filterParts.Length != 3) return list;

            String propertyName = filterParts[0];
            String op = filterParts[1];
            String targetValue = filterParts[2];
            String sourceValue;
            bool useElement;

            Type T = list.GetType().GetGenericArguments()[0];
            PropertyInfo PI = T.GetProperty(propertyName);
            if (PI == null) return list;

            foreach (object element in list)
            {
                useElement = false;
                sourceValue = PI.GetValue(element).ToString();
                switch (op)
                {
                    case "=": useElement = (sourceValue == targetValue); break;
                    case ">": useElement = (sourceValue.CompareTo(targetValue) > 0); break;
                    case "<": useElement = (sourceValue.CompareTo(targetValue) < 0); break;
                    case ">=": useElement = (sourceValue.CompareTo(targetValue) >= 0); break;
                    case "<=": useElement = (sourceValue.CompareTo(targetValue) <= 0); break;
                    case "!=":
                    case "<>": useElement = (sourceValue != targetValue); break;
                }
                if (useElement)
                    newList.Add(element);
            }

            return newList;
        }

        public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        {
            return value;
        }
    }
}

Now let's consume this converter.

Change MainWindow to look like this...

<Window x:Class="FilteredEnumerableConverterDemo.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:local="clr-namespace:FilteredEnumerableConverterDemo"
        mc:Ignorable="d"
        DataContext="{Binding RelativeSource={RelativeSource Self}}"
        Title="MainWindow" Height="450" Width="800">
    <Window.Resources>
        <local:FilteredEnumerableConverter x:Key="FilteredEnumerableConverter"/>
    </Window.Resources>
    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="100"/>
            <ColumnDefinition Width="100"/>
        </Grid.ColumnDefinitions>
        <Grid.RowDefinitions>
            <RowDefinition Height="auto"/>
            <RowDefinition Height="auto"/>
            <RowDefinition Height="auto"/>
            <RowDefinition Height="auto"/>
            <RowDefinition Height="auto"/>
        </Grid.RowDefinitions>

        <TextBlock Grid.Row="0" Grid.Column="0" Text="All animals"/>
        <ComboBox Grid.Row="0" Grid.Column="1" ItemsSource="{Binding Animals}" DisplayMemberPath="Name" HorizontalAlignment="Stretch"/>

        <TextBlock Grid.Row="1" Grid.Column="0" Text="Dangerous animals"/>
        <ComboBox Grid.Row="1" Grid.Column="1" DisplayMemberPath="Name" HorizontalAlignment="Stretch"
                  ItemsSource="{Binding Animals, Converter={StaticResource FilteredEnumerableConverter}, ConverterParameter='IsDangerous = True'}"/>

        <TextBlock Grid.Row="2" Grid.Column="0" Text="Edible animals"/>
        <ComboBox Grid.Row="2" Grid.Column="1" DisplayMemberPath="Name" HorizontalAlignment="Stretch"
                  ItemsSource="{Binding Animals, Converter={StaticResource FilteredEnumerableConverter}, ConverterParameter='IsEdible = True'}"/>
    </Grid>
</Window>

----------------------------------------------------------
using System.Windows;

namespace FilteredEnumerableConverterDemo
{
    public partial class MainWindow : Window
    {
        public class cAnimal
        {
            public String Name { get; set; }
            public bool IsDangerous { get; set; }
            public bool IsEdible {  get; set; }
        }

        public List<cAnimal> Animals { get; set; } = new List<cAnimal>()
        {
            new cAnimal() {Name="Lion", IsDangerous=true, IsEdible=false},
            new cAnimal() {Name="Cockroach", IsDangerous=false, IsEdible=false},
            new cAnimal() {Name="Cow", IsDangerous=false, IsEdible=true},
            new cAnimal() {Name="Rattlesnake", IsDangerous=true, IsEdible=true}
        };

        public MainWindow()
        {
            InitializeComponent();
        }
    }
}

If you run the application you can see three dropdown lists, all populated from the same collection, but with different lists.




Note: < and > operators have to be XML encoded. eg.

        <TextBlock Grid.Row="3" Grid.Column="0" Text="A-M only"/>
        <ComboBox Grid.Row="3" Grid.Column="1" DisplayMemberPath="Name" HorizontalAlignment="Stretch"
                  ItemsSource="{Binding Animals, Converter={StaticResource FilteredEnumerableConverter}, ConverterParameter='Name &lt;= M'}"/>