Friday, October 5, 2018

Three ways to implement a list box with a bindable SelectedItems

As you know by now, none of Microsoft's WPF list controls have a bindable SelectedItems property. This means that if you want to set SelectionMode="Multi" or "Extended" you have to break your MVVM model. This is not cool.

This blog offers three ways to get around this problem. The first two are suitable for a one-off solution, the third is suitable for a solution that can be easily dropped into many pages.

Solution 1: Bind a regular list box to a list of objects that have an IsSelected property. Template the ListBoxItem to add a checkbox to each item. Use LINQ to return a list of Items where IsSelected is true.
    Pros: Easy to implement once. Many users prefer the checkbox style
    Cons: Difficult to implement many times. Not Windows standard.

Solution 2: Bind a regular list box to a list of objects that have an IsSelected property. Bind the ListBoxItem.IsSelected property to the bound object's IsSelected property. Use LINQ to return a list of Items where IsSelected is true.
    Pros: Easy to implement once. Uses Windows standard for multi-select.
    Cons: Difficult to implement many times. Some users struggle with the Windows standard.

Solution 3: Create a custom control based on ListBox that maintains and exposes a dependency property that mirrors the SelectedItems property.
    Pros: Easy to reuse
    Cons: More work up front.

I will implement solutions 1 and 2 at the same time, then solution 3 later. Start a new C# WPF project called MultiSelectListBox.


We will create a grid with three columns and put our first two solutions in columns 1 and 2. The XAML and code for MainWindow look like this.

<Window x:Class="MultiSelectListBox.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:MultiSelectListBox"
        mc:Ignorable="d"
        DataContext="{Binding RelativeSource={RelativeSource Self}}"
        Title="MainWindow" SizeToContent="WidthAndHeight">
    <Window.Resources>
        <RoutedCommand x:Key="Check1"/>
        <RoutedCommand x:Key="Check2"/>
    </Window.Resources>
    <Window.CommandBindings>
        <CommandBinding Command="{StaticResource Check1}" Executed="Check1_Executed"/>
        <CommandBinding Command="{StaticResource Check2}" Executed="Check2_Executed"/>
    </Window.CommandBindings>
    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="*"/>
            <ColumnDefinition Width="*"/>
            <ColumnDefinition Width="*"/>
        </Grid.ColumnDefinitions>
        <Grid.RowDefinitions>
            <RowDefinition Height="100"/>
            <RowDefinition Height="auto"/>
            <RowDefinition Height="auto"/>
        </Grid.RowDefinitions>
       
        <ListBox Grid.Row="0" Grid.Column="0"  ItemsSource="{Binding Statuses1}">
            <ListBox.ItemTemplate>
                <DataTemplate>
                    <StackPanel Orientation="Horizontal">
                        <CheckBox IsChecked="{Binding IsSelected}"/>
                        <TextBlock Text="{Binding StatusCode}"/>
                    </StackPanel>
                </DataTemplate>
            </ListBox.ItemTemplate>
        </ListBox>

        <Button Grid.Row="1" Grid.Column="0" Content="What is checked 1?" Command="{StaticResource Check1}" Width="200"/>
        <TextBox Grid.Row="2" Grid.Column="0" Text="{Binding CheckedList1}"/>

        <ListBox Grid.Row="0" Grid.Column="1"  ItemsSource="{Binding Statuses2}" SelectionMode="Extended">
            <ListBox.ItemTemplate>
                <DataTemplate>
                    <TextBlock Text="{Binding StatusCode}"/>
                </DataTemplate>
            </ListBox.ItemTemplate>
            <ListBox.ItemContainerStyle>
                <Style TargetType="ListBoxItem">
                    <Setter Property="IsSelected" Value="{Binding IsSelected}"/>
                </Style>
            </ListBox.ItemContainerStyle>
        </ListBox>

        <Button Grid.Row="1" Grid.Column="1" Content="What is checked 2?" Command="{StaticResource Check2}" Width="200"/>
        <TextBox Grid.Row="2" Grid.Column="1" Text="{Binding CheckedList2}"/>

    </Grid>
</Window>


 --------------------------------------------------------------

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Windows;

namespace MultiSelectListBox
{
    public partial class MainWindow : Window, INotifyPropertyChanged
    {
        public class cStatus
        {
            public bool IsSelected { get; set; }
            public string StatusCode { get; set; }
        }

        public List<cStatus> Statuses1 { get; set; }
        public List<cStatus> Statuses2 { get; set; }

        public string _CheckedList1;
        public string CheckedList1
        {
            get { return _CheckedList1; }
            set
            {
                _CheckedList1 = value;
                NotifyPropertyChanged("CheckedList1");
            }
        }

        public string _CheckedList2;
        public string CheckedList2
        {
            get { return _CheckedList2; }
            set
            {
                _CheckedList2 = value;
                NotifyPropertyChanged("CheckedList2");
            }
        }

        public MainWindow()
        {
            Statuses1 = new List<cStatus>()
            { new cStatus() {StatusCode = "OPEN" },
              new cStatus() {StatusCode = "APPROVED" },
              new cStatus() {StatusCode = "CLOSED" }
            };

            Statuses2 = new List<cStatus>()
            { new cStatus() {StatusCode = "OPEN" },
              new cStatus() {StatusCode = "APPROVED" },
              new cStatus() {StatusCode = "CLOSED" }
            };

            InitializeComponent();
        }

        private void Check1_Executed(object sender, System.Windows.Input.ExecutedRoutedEventArgs e)
        {
            CheckedList1 = string.Join(",", Statuses1.Where((s) => s.IsSelected).Select((x) => x.StatusCode).ToArray());
        }

        private void Check2_Executed(object sender, System.Windows.Input.ExecutedRoutedEventArgs e)
        {
            CheckedList2 = string.Join(",", Statuses2.Where((s) => s.IsSelected).Select((x) => x.StatusCode).ToArray());
        }

        public event PropertyChangedEventHandler PropertyChanged;
        public void NotifyPropertyChanged(String info)
        {
            if (PropertyChanged != null)
            {
                PropertyChanged(this, new PropertyChangedEventArgs(info));
            }
        }
    }
}


If you run the project now you can see select a few items and click "What is checked?" to see the selected items. You will see the code never interrogates the control itself, only properties bound to the control. Strictly speaking this isn't a SelectedItems property, but it's only one LINQ function away from it.


Now let's add the third solution. We will add a new project to contain the custom listbox control called CustomControls, delete App and MainWindow and add a new user control called ExtendedListBox. Add a reference to the new project to MultiSelectListBox. The solution now looks like this.



Because the new control simply adds a dependency property to the standard ListBox the XAML is trivial.

<ListBox x:Class="WPFCustomControls.ExtendedListBox"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             xmlns:local="clr-namespace:WPFCustomControls"
             mc:Ignorable="d">
</ListBox>


The code isn't very complicated either. We define a new dependency property called SelectedItemsList of type "IList" and keep it synchronized with the ListBox's SelectedItems property via the SelectionChanged event.


using System.Collections;
using System.Collections.Generic;
using System.Windows;
using System.Windows.Controls;

namespace WPFCustomControls
{
    public partial class ExtendedListBox : ListBox
    {
        public ExtendedListBox()
        {
            this.SelectionChanged += ExtendedListBox_SelectionChanged;
        }

        void ExtendedListBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
        {
            var SelectedItemsCasted = SelectedItems as IList<object>;
            if (SelectedItemsCasted == null) return;

            foreach (object addedItem in e.AddedItems)
                SelectedItemsList.Add(addedItem);

            foreach (object removedItem in e.RemovedItems)
                SelectedItemsList.Remove(removedItem);

            SetValue(SelectedItemsListProperty, SelectedItemsList);
        }

        public IList SelectedItemsList
        {
            get { return (IList)GetValue(SelectedItemsListProperty); }
            set { SetValue(SelectedItemsListProperty, value); }
        }

        public static readonly DependencyProperty SelectedItemsListProperty = DependencyProperty.Register("SelectedItemsList", typeof(IList), typeof(ExtendedListBox));

    }
}

The interesting code is in ExtendedListBox_SelectionChanged. It's tempting to simply copy SelectedItems to SelectedItemsList but that creates a new list and breaks the binding so we have to add and remove items one at a time. Once we have finished modifying the list we have to call SetValue to let all bound properties know the list has changed.  

Now we have our custom control, we need to to consume it in our WPF window. Return to MultiSelectListBox.xaml and add some XAML to define a new ListBox. 

Add a reference to the custom controls project.

xmlns:custom="clr-namespace:WPFCustomControls;assembly=CustomControls"

Add these command bindings in the appropriate places.

<RoutedCommand x:Key="Check3"/>
<CommandBinding Command="{StaticResource Check3}" Executed="Check3_Executed"/>

Put this before the grid close tag </Grid>.



<custom:ExtendedListBox Grid.Row="0" Grid.Column="2" ItemsSource="{Binding Statuses3}" SelectedItemsList="{Binding SelectedStatusList, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" SelectionMode="Extended"/>

<Button Grid.Row="1" Grid.Column="2" Content="What is checked 3?" Command="{StaticResource Check3}" Width="200"/>
<TextBox Grid.Row="2" Grid.Column="2" Text="{Binding CheckedList3}"/>


As you can see, using the custom control requires far less XAML than the first two solutions.

We need to go to the code behind and add properties to bind to the ExtendedListBox. We don't need to store the IsSelected state in the source collection any more so we will bind the new ListBox to a list of string.

Add:
public List<String> Statuses3 { get; set; }

public string _CheckedList3;
public string CheckedList3
{
    get { return _CheckedList3; }
    set
    {
        _CheckedList3 = value;
        NotifyPropertyChanged("CheckedList3");
    }
}

We will bind the selected items to a new property.

private List<String> _SelectedStatusList;
public List<String> SelectedStatusList
{
    get { return _SelectedStatusList; }
    set
    {
        _SelectedStatusList = value;
        NotifyPropertyChanged("SelectedStatusList");
    }
}

In the MainWindow constructor add these initializers.

Statuses3 = new List<String>() { "OPEN", "APPROVED", "CLOSED" };
SelectedStatusList = new List<String>();

Lastly add an event handler for the check3 command.

private void Check3_Executed(object sender, System.Windows.Input.ExecutedRoutedEventArgs e)
{
    CheckedList3 = string.Join(",", SelectedStatusList.ToArray());
}

If you run the project now you can see a third ListBox. The new ListBox functionality is the same as the second one as far as the user is concerned, but it's far easier to use from the developer's point of view.

The final result:


No comments:

Post a Comment