Home > database >  MuliSelectCombobox in WPF/C# to show Displaynames of all selected Items
MuliSelectCombobox in WPF/C# to show Displaynames of all selected Items

Time:04-06

A Combobox binds to a list of custom combobox items where each Item contains a checkbox together with an Image. The user can click either the image or the checkbox to select the item.

Each item contains a relative image path, the Selection-State. The viewmodel generates the list CustomCheckboxItems which the view then binds to.

Now i want to show the Displaynames of all selected items in the Combobox as ... selected items ... together. How can i achieve that? I tried to attach a contentpresenter to the combobox without success since i do not know exactly where to attach it to. Also writing control templates did not do the trick for me.

In the end the combobox should look something like this (Link goes to cdn.syncfusion.com). The ViewModel contains already a comma separated string containing the selected items. How do i have to change the combobox to behave like this?

View:

<ComboBox ItemsSource="{Binding Path=ViewModel.CustomCheckBoxItems, Mode=OneTime}">
  <ComboBox.ItemTemplate>
    <DataTemplate DataType="{x:Type viewModels:CustomCheckBoxItem}">
    <StackPanel Orientation="Horizontal">
      <Image Source="{Binding Path=ImagePath}">
        <Image.InputBindings>
           <MouseBinding Gesture="LeftClick" Command="{Binding SelectItem, Mode=OneWay}" />
        </Image.InputBindings>
      </Image>
      <CheckBox VerticalAlignment="Center" VerticalContentAlignment="Center"IsChecked="Binding Selected, Mode=TwoWay}" >
         <TextBlock Text="{Binding DisplayName}" IsEnabled="False" VerticalAlignment="Center" />
      </CheckBox>
    </StackPanel>
  </DataTemplate>
  </ComboBox.ItemTemplate>
</ComboBox>

The CustomCheckBoxItem Implementation

    //INotifyPropertryChanged implementation and other things excluded for brevity
    public class CustomCheckBoxItem : INotifyPropertyChanged
    {

        public CheckboxItem(ItemType item, string imagePath)
        {
            Item = item;
            try{ImagePath = "/"   currentAssemblyName   ";component/Images/"   imagePath;}catch(Exception){}
        }

        private bool selected;
        public bool Selected
        {
            get => selected;
            set
            {
                selected = value;
                NotifyPropertyChanged();
            }
        }

        public ICommand SelectItem => new RelayCommand(() =>
        {
            if (isInit)
            {
                Selected = !selected;
            }
        },false);

        public string ImagePath { get; }
        public string DisplayName => Item.GetDisplayName();
        
    }

CodePudding user response:

The solution is to disconnect the ContentPresenter, that hosts the selected item in the selection box, from the ComboBox.
The ComboBox sets the selection box value internally. There is no chance to modify this behavior. Since the selection box also serves as input field for the edit or search mode, this logic is quite complex. From this perspective, the design choice to make the complete logic internal makes much sense.

The first option is to simply override the default template of the ComboBox and remove the bindings on the ContentPresenter.ContentTemplate, ContentPresenter.ContentTemplateSelector and ContentPresenter.ContentStringFormat properties.
Then bind the ContentPresenter.Content property to the data source that holds the multi selection display names. The disadvantage is that the complete multi-select logic is outside the ComboBox (in the worst case, it even bleeds into the view model). As the multi-select behavior is part of the control, it should be implemented inside.

The second solution is to extend ComboBox to make the handling of the control more convenient and reusable. You probably don't want your view model to be responsible to participate in the multi-select logic e.g. by tracking selection states in the context of multi-select (which is not of interest from the view model's point of view) and by exposing the display values to represent the multi-select state.

The third option is to implement your custom ComboBox and let it extend MultiSelector. If you require all the ComboBox features, then this solutions can take a while to implement and test properly. If you only need a basic multi-select dropdown control, then the task is quite simple and the cleanest solution.

The following example implements the second solution. The MultiSelectComboBox extends ComboBox. It internally grabs the ContentPresenter, disconnects it from the ComboBox (by overriding its content) and tracks the selection state.

The multi-select state is realized by overriding the ComboBoxItem template to replace the ContentPresenter with a ToggleButton. Additionally we need to introduce a custom IsItemSelected property by implementing it as an attached property. This is necessary because the ComboBoxItem.IsSelected property is controlled by the Selector (the superclass of ComboBox). The Selector is also responsible to ensure that only a single item is selected. But we need multiple items to be selected simultaneously.

You can use the ItemContainerStyle to bind the data model's selection property (if existing) to this attached MultiSelectComboBox.IsItemSelected property. The example does this in order to enable an additional CheckBox in the DataTemplate. Alternatively, you can hardcode the CheckBox by adding it to the ComboBoxItem template.

If you don't define the ItemTemplate, you can control the displayed value using the common DisplayMemberPath (ComboBox default behavior). The MultiSelectComboBox will pick the value from the designated member and concatenate it with the other selected values.
If you want to display different values for the drop-down panel and the selected content box, use the MultiSelectComboBox.SelectionBoxDisplayMemberPath property to specify the item's source property name (in the same manner like the DisplayMemberPath).

This way the complete logic that handles the displayed values was moved from view model to the view, where it belongs:

MultiSelectComboBox.cs

public class MultiSelectComboBox : ComboBox
{
  public static void SetIsItemSelected
    (UIElement attachedElement, bool value)
    => attachedElement.SetValue(IsItemSelectedProperty, value);
  public static bool GetIsItemSelected(UIElement attachedElement)
    => (bool)attachedElement.GetValue(IsItemSelectedProperty);

  public static readonly DependencyProperty IsItemSelectedProperty =
      DependencyProperty.RegisterAttached(
        "IsItemSelected",
        typeof(bool),
        typeof(MultiSelectComboBox),
        new FrameworkPropertyMetadata(default(bool), FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnIsItemSelectedChanged));

  public string SelectionBoxDisplayMemberPath
  {
    get => (string)GetValue(SelectionBoxDisplayMemberPathProperty);
    set => SetValue(SelectionBoxDisplayMemberPathProperty, value);
  }

  public static readonly DependencyProperty SelectionBoxDisplayMemberPathProperty = DependencyProperty.Register(
    "SelectionBoxDisplayMemberPath",
    typeof(string),
    typeof(MultiSelectComboBox),
    new PropertyMetadata(default));

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

  public static readonly DependencyProperty SelectedItemsProperty = DependencyProperty.Register(
    "SelectedItems", 
    typeof(IList<object>), 
    typeof(MultiSelectComboBox), 
    new PropertyMetadata(default));

  private static Dictionary<DependencyObject, ItemsControl> ItemContainerOwnerTable { get; }
  private ContentPresenter PART_ContentSite { get; set; }
  private Dictionary<UIElement, string> SelectionBoxContentValues { get; }

  static MultiSelectComboBox() => MultiSelectComboBox.ItemContainerOwnerTable = new Dictionary<DependencyObject, ItemsControl>();

  public MultiSelectComboBox()
  {
    this.SelectionBoxContentValues = new Dictionary<UIElement, string>();
    this.SelectedItems = new List<object>();
  }

  public override void OnApplyTemplate()
  {
    base.OnApplyTemplate();
    if (TryFindVisualChildElement(this, out ContentPresenter contentPresenter, false))
    {
      contentPresenter.ContentTemplate = null;
      contentPresenter.ContentStringFormat = null;
      contentPresenter.ContentTemplateSelector = null;
      this.PART_ContentSite = contentPresenter;
    }
  }

  protected override void OnItemsSourceChanged(IEnumerable oldValue, IEnumerable newValue)
  {
    base.OnItemsSourceChanged(oldValue, newValue);
    this.SelectedItems.Clear();
    MultiSelectComboBox.ItemContainerOwnerTable.Clear();
    Dispatcher.InvokeAsync(InitializeSelectionBox, DispatcherPriority.Background);
  }

  protected override void OnItemsChanged(NotifyCollectionChangedEventArgs e)
  {
    base.OnItemsChanged(e);

    switch (e.Action)
    {
      case NotifyCollectionChangedAction.Remove:
        foreach (var item in e.OldItems)
        {
          var itemContainer = this.ItemContainerGenerator.ContainerFromItem(item);
          MultiSelectComboBox.ItemContainerOwnerTable.Remove(itemContainer);
        }
        break;
    }
  }

  protected override void OnSelectionChanged(SelectionChangedEventArgs e)
  {
    base.OnSelectionChanged(e);

    this.SelectionBoxContentValues.Clear();
    IEnumerable<(object Item, ComboBoxItem? ItemContainer)>? selectedItemInfos = this.ItemContainerGenerator.Items
      .Select(item => (Item: item, ItemContainer: this.ItemContainerGenerator.ContainerFromItem(item) as ComboBoxItem))
      .Where(selectedItemInfo => GetIsItemSelected(selectedItemInfo.ItemContainer));
    foreach (var selectedItemInfo in selectedItemInfos)
    {
      string memberPath = this.SelectionBoxDisplayMemberPath
        ?? this.DisplayMemberPath
        ?? selectedItemInfo.Item.ToString();
      string itemDisplayValue = selectedItemInfo.Item.GetType().GetProperty(memberPath).GetValue(selectedItemInfo.Item)?.ToString()
        ?? string.Empty;
      this.SelectionBoxContentValues.Add(selectedItemInfo.ItemContainer, itemDisplayValue);
      MultiSelectComboBox.ItemContainerOwnerTable.TryAdd(selectedItemInfo.ItemContainer, this);
      this.SelectedItems.Add(selectedItemInfo.Item);
    }

    UpdateSelectionBox();
  }

  protected override bool IsItemItsOwnContainerOverride(object item) => item is ComboBoxToggleItem;

  protected override DependencyObject GetContainerForItemOverride() => new ComboBoxToggleItem();

  private static void OnIsItemSelectedChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
  {
    var comboBoxItem = d as ComboBoxItem;
    if (MultiSelectComboBox.ItemContainerOwnerTable.TryGetValue(comboBoxItem, out ItemsControl owner))
    {
      var comboBoxItemOwner = owner as MultiSelectComboBox;
      bool isUnselected = !GetIsItemSelected(comboBoxItem);
      if (isUnselected)
      {
        comboBoxItemOwner.SelectionBoxContentValues.Remove(comboBoxItem);        
        comboBoxOwner.SelectedItems.Remove(comboBoxItem);
        UpdateSelectionBox()
      }
    }
  }

  private static void UpdateSelectionBox()
    => this.PART_ContentSite.Content = string.Join(", ", this.SelectionBoxContentValues.Values);

  private void OnItemUnselected(object sender, SelectionChangedEventArgs e)
  {
    foreach (var removedItem in e.RemovedItems)
    {
      this.SelectedItems.Remove(removedItem);
    }
  }

  private void InitializeSelectionBox()
  {
    EnsureItemsLoaded(); 
    UpdateSelectionBox();
  }

  private void EnsureItemsLoaded()
  {
    IsDropDownOpen = true;
    IsDropDownOpen = false;
  }

  private static bool TryFindVisualChildElement<TChild>(DependencyObject parent,
    out TChild resultElement,
    bool isTraversingPopup = true)
    where TChild : FrameworkElement
  {
    resultElement = null;

    if (isTraversingPopup
      && parent is Popup popup)
    {
      parent = popup.Child;
      if (parent == null)
      {
        return false;
      }
    }

    for (var childIndex = 0; childIndex < VisualTreeHelper.GetChildrenCount(parent); childIndex  )
    {
      DependencyObject childElement = VisualTreeHelper.GetChild(parent, childIndex);

      if (childElement is TChild frameworkElement)
      {
        resultElement = frameworkElement;
        return true;
      }

      if (TryFindVisualChildElementByName(childElement, out resultElement, isTraversingPopup))
      {
        return true;
      }
    }

    return false;
  }
}

ComboBoxToggleItem.cs

public class ComboBoxToggleItem : ComboBoxItem
{
  public bool IsCheckBoxEnabled
  {
    get => (bool)GetValue(IsCheckBoxEnabledProperty);
    set => SetValue(IsCheckBoxEnabledProperty, value);
  }

  public static readonly DependencyProperty IsCheckBoxEnabledProperty = DependencyProperty.Register(
    "IsCheckBoxEnabled", 
    typeof(bool), 
    typeof(ComboBoxToggleItem), 
    new PropertyMetadata(default));

  static ComboBoxToggleItem()
  {
    DefaultStyleKeyProperty.OverrideMetadata(typeof(ComboBoxToggleItem), new FrameworkPropertyMetadata(typeof(ComboBoxToggleItem)));
  }
}

Generic.xaml

<Style TargetType="local:ComboBoxToggleItem">
  <Setter Property="Template">
    <Setter.Value>
      <ControlTemplate TargetType="local:ComboBoxToggleItem">
        <ToggleButton x:Name="ToggleButton"
                      HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
                      IsChecked="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=(local:MultiSelectComboBox.IsItemSelected)}">
          <StackPanel Orientation="Horizontal">
            <CheckBox IsChecked="{Binding ElementName=ToggleButton, Path=IsChecked}"
                      Visibility="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=IsCheckBoxEnabled, Converter={StaticResource BooleanToVisibilityConverter}}" />
            <ContentPresenter />
          </StackPanel>
        </ToggleButton>

        <ControlTemplate.Triggers>
          <Trigger SourceName="ToggleButton"
                   Property="IsChecked"
                   Value="True">
            <Setter Property="IsSelected"
                    Value="True" />
          </Trigger>
          <Trigger SourceName="ToggleButton"
                   Property="IsChecked"
                   Value="False">
            <Setter Property="IsSelected"
                    Value="False" />
          </Trigger>
        </ControlTemplate.Triggers>
      </ControlTemplate>
    </Setter.Value>
  </Setter>
</Style>

Usage example

DataItem.cs

class DataItem : INotifyPropertyChanged
{
  string TextData { get; }
  int Id { get; }
  bool IsActive { get; }
}

MainViewModel

class MainViewModel : INotifyPropertyChanged
{
  public ObservableCollection<DataItem> DataItems { get; }
}

MainWIndow.xaml

<Window>
  <Window.DataContext>
    <MainViewModel />
  </Window.DataContext>

  <local:MultiSelectComboBox ItemsSource="{Binding DataItems}"
                             DisplayMemberPath="TextData"
                             SelectionBoxDisplayMemberPath="Id">
    <local:MultiSelectComboBox.ItemContainerStyle>
      <Style TargetType="ComboBoxItem">
        <Setter Property="local:MultiSelectComboBox.IsItemSelected"
                Value="{Binding IsActive}" />
        <Setter Property="IsCheckBoxEnabled"
                Value="True" />
      </Style>
    </local:MultiSelectComboBox.ItemContainerStyle>
  </local:MultiSelectComboBox>
</Window>

CodePudding user response:

If I am understanding correctly:

  1. You just want all the checked items to be moved to top of combobox
  2. You want comma separated list of selected items shown in closed combobox.

If that is correct, the solution is much simpler than you are thinking.

All that is needed is this:

  /// <summary>
  /// Sort the items in the list by selected
  /// </summary>
  private void cmb_DropDownOpened ( object sender, EventArgs e )
    => cmb.ItemsSource = CustomCheckBoxItems
      .OrderByDescending ( c => c.Selected )
      .ThenBy ( c => c.DisplayName );

  /// <summary>
  /// Display comma separated list of selected items
  /// </summary>
  private void cmb_DropDownClosed ( object sender, EventArgs e )
  {
    cmb.IsEditable = true;
    cmb.IsReadOnly = true;
    cmb.Text = string.Join ( ",", CustomCheckBoxItems.Where ( c => c.Selected )
         .OrderBy ( c => c.DisplayName )
         .Select ( c => c.DisplayName )
         .ToList () );
  }

The key to the solution is just to reorder the list every time the combo is opened you present a re-sorted list. Every time it is closed, you gather selected and show.

Complete working example: Given this XAML:

<ComboBox Name="cmb" DropDownOpened="cmb_DropDownOpened">
  <ComboBox.ItemTemplate>
    <DataTemplate>
      <StackPanel Orientation="Horizontal">
        <CheckBox VerticalAlignment="Center" VerticalContentAlignment="Center" IsChecked="{Binding Selected, Mode=TwoWay}" >
          <TextBlock Text="{Binding DisplayName}" IsEnabled="False" VerticalAlignment="Center" />
        </CheckBox>
      </StackPanel>
    </DataTemplate>
  </ComboBox.ItemTemplate>
</ComboBox>

and this class (removed notify for this sample, since not needed)

public class CustomCheckBoxItem
{
  public string Item { get; set; }

  private const string currentAssemblyName = "samplename";


  public CustomCheckBoxItem ( string item, string imagePath )
  {
    Item = item;
    try
    { ImagePath = "/"   currentAssemblyName   ";component/Images/"   imagePath; }
    catch ( Exception ) { }
  }

  public bool Selected { get; set; }

  public string ImagePath { get; }
  public string DisplayName => Item;

}

Then to test I just used this:

  public ObservableCollection<CustomCheckBoxItem> CustomCheckBoxItems { get; set; }
    = new ObservableCollection<CustomCheckBoxItem> ()
    {
      new ( "Item 1", "image1.png" ),
      new ( "Item 2", "image2.png" ),
      new ( "Item 3", "image3.png" ),
      new ( "Item 4", "image4.png" ),
      new ( "Item 5", "image5.png" ),
      new ( "Item 6", "image6.png" ),
      new ( "Item 7", "image7.png" ),

    };

  public MainWindow ()
  {
    InitializeComponent ();

    cmb.ItemsSource = CustomCheckBoxItems;
  }
 
  /// <summary>
  /// Sort the items in the list by selected
  /// </summary>
  private void cmb_DropDownOpened ( object sender, EventArgs e )
    => cmb.ItemsSource = CustomCheckBoxItems
      .OrderByDescending ( c => c.Selected )
      .ThenBy ( c => c.DisplayName );

  /// <summary>
  /// Display comma separated list of selected items
  /// </summary>
  private void cmb_DropDownClosed ( object sender, EventArgs e )
  {
    cmb.IsEditable = true;
    cmb.IsReadOnly = true;
    cmb.Text = string.Join ( ",", CustomCheckBoxItems.Where ( c => c.Selected )
         .OrderBy ( c => c.DisplayName )
         .Select ( c => c.DisplayName )
         .ToList () );
  }
  • Related