3

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();
        
    }
theDrifter
  • 1,631
  • 6
  • 24
  • 40
  • Do you just need to change the binding to twoway, so it changes the properties on click? https://learn.microsoft.com/en-us/dotnet/api/system.windows.data.bindingmode?view=windowsdesktop-6.0#system-windows-data-bindingmode-twoway – Netferret Apr 05 '22 at 09:51
  • You might want to take a look at this: https://stackoverflow.com/questions/27753790/checkcombobox-how-to-prevent-a-combobox-from-closing-after-a-selection-is-clic – radoslawik Apr 05 '22 at 10:01

3 Answers3

3

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 related logic of the ComboBox 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. You view model should not care about display values or multi-select.

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.

Other options you may find, like hacking the edit mode to set the ComboBox.Text property, are not recommended as they interfere with the internal behavior: the logic around the IsEditable and IsReadOnly is quite complex. So better don't mess around with to prevent unexpected behavior.
Also, you lose many features like search and edit. If you don't care about those feature, then the best and cleanest solution is the third introduced option to implement a Custom control that extends MultiSelector.

Implementation

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 removing the internal templating) and tracks the selection state. The advantage of this solution is that you don't have to "hack" the edit mode of the ComboBox. Therefore, the edit mode feature remains untouched. This solution simply changes the displayed values in the default toggle box. Even the default single.select behavior remains intact.

The multi-select state is realized by overriding the ComboBoxItem template to replace the ContentPresenter with a ToggleButton or alternatively, create a ComboBoxToggleItem (like in the example below) that extends ComboBoxItem to make everything reusable and MVVM ready.
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). And by making it attached, we can avoid a tight coupling between the MultiSelectComboBox logic and the ComboBoxToggleItem Everything still works with the default ComboBoxItem or any other item container. The Selector is also responsible to ensure that only a single item is selected. But we need multiple items to be selected simultaneously.
This way we can easily track selected items and expose them via a public SelectedItems property.

You can use the ItemContainerStyle to bind the data model's selection property (if existing) to this attached MultiSelectComboBox.IsItemSelected property.

By implementing a custom ComboBoxItem like the ComboBoxToggleItem and hard-coding the CheckBox into its ControlTemplate you are no longer forced to track the visual state in your view model. This offers a clean separation. The visibility of the CheckBox in this example is able to be toggled by handling the ComboBoxToggleItem.IsCheckBoxEnabled property.
Because the ComboBoxToggleItem is basically a ToggleButton, you can select the item without clicking the CheckBox. The CheckBox is now an optional feature that only serves to provide another visual feedback.

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).
If you don't set neither DisplayMemberPath nor the SelectionBoxDisplayMemberPath, the MultiSelectComboBox will call object.ToString on the data item. This way you can even generate a computed value by overriding ToString on the model. This gives you three options to control the selection box display value, while the MultiSelectComboBox concatenates and displays them.

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);
          this.SelectedItems.Remove(item);
        }
        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()
        ?? selectedItemInfo.Item.ToString();
      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()
  { 
    string selectionBoxContent = string.Join(", ", this.SelectionBoxContentValues.Values);
    if (this.IsEditable)
    {
      this.Text = selectionBoxContent;
    }
    else
    {
      this.PART_ContentSite.Content = selectionBoxContent;
    }
  }

  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 (TryFindVisualChildElement(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)));
  }
        
  // Add text search selection support
  protected override void OnSelected(RoutedEventArgs e)
  {
    base.OnSelected(e);
    MultiSelectComboBox.SetIsItemSelected(this, true);
  }
}

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="local:ComboBoxToggleItem">
        <Setter Property="local:MultiSelectComboBox.IsItemSelected"
                Value="{Binding IsActive}" />
        <Setter Property="IsCheckBoxEnabled"
                Value="True" />
      </Style>
    </local:MultiSelectComboBox.ItemContainerStyle>

    <local:MultiSelectComboBox.ItemTemplate>
      <DataTemplate DataType="{x:Type local:DataItem}">
        <TextBlock Text="{Binding TextData}" />
      </DataTemplate>
    </local:MultiSelectComboBox.ItemTemplate>
  </local:MultiSelectComboBox>
</Window>
BionicCode
  • 1
  • 4
  • 28
  • 44
  • Thanks for the walk-through and the detailed explanation! I learned a lot. The selected items show up perfectly I do not get the ItemContainerStyle to work properly - its either empty or shows only the DataItem Object names. – theDrifter Apr 07 '22 at 07:17
  • Could you share the implementation of the `TryFindVisualChildElementByName` - method? – theDrifter Apr 07 '22 at 07:39
  • Alright. Nope that you can avoid the extra ComboBoxToggleItem implementation of you don't care about reusability. Then simply move the Style from Generic.xaml to the ItemContainerStyle and remove the IsItemItsOwnContainerOverride() and GetContainerForItemOverride() from the MultiSelectComboBox . Yes, I will share it. Books on while I look it up. – BionicCode Apr 07 '22 at 07:40
  • I have to appoligize. I was write a big portio of this code here in the editor without compiler support. I renamed the `TryFindVisualChildElementByName` to `TryFindVisualChildElement`. I forget that the method is recursive, so I forgot to rename the recursive call. The method `TryFindVisualChildElement` is there. I fixed the code. I also added a line to the `OnItemsChanged` handler (inside the switch case). – BionicCode Apr 07 '22 at 07:50
  • Just updated the example. Last change: since the MultiSelectComboBox should also keep all the default features like the IsEditable support, I had to add a state check to the `UpdateSelectionBox()` method. Now you can enable editing e.g. to search for items by entering text. – BionicCode Apr 07 '22 at 07:59
  • The idea behind the `ComboBoxToggleItem` is to integrate an optional CheckBox and to make the complete item a click target to select the item, so that you don't have to click the CheckBox. The CheckBox is only an optional feature to give a visual feedback. You can disable the CheckBox by setting `ComboBoxToggleItem.IsCheckBoxEnabled`. You can still get the selection state to your data model by binding the attached property `local:MultiSelectComboBox.IsItemSelected` to your model's property inside the ItemContainerStyle. The `ComboBoxToggleItem` just adds more convenience. – BionicCode Apr 07 '22 at 08:09
  • Without the ComboBoxToggleItem, you are forced to track the visual state in your view model, which is not a good design. – BionicCode Apr 07 '22 at 08:18
  • Thanks for the updates. Excellent, i want to use it that way `and to make the complete item a click target to select the item, so that you don't have to click the CheckBox` this is what i also was struggling with, that on clicking not the checkbox of the text the popup closes. Your solution works also there but what i am still do not get to work is to set the `ItemContainerStyle` to show the text and the cbxbox. – theDrifter Apr 07 '22 at 11:29
  • The CheckBox is displayed automatically. It's part of the `ComboBoxToggleItem` (make sure that you have added the controil's Style to the Generic.xaml resources). The CheckBox is disabled by default. You must enable it by adding a `Setter` to the ItemContainerStyle to set the `ComboBoxToggleItem.IsCheckBoxEnabled` property to `true` - see the example. – BionicCode Apr 07 '22 at 11:53
  • The basic behavior has not changed: to specify the what is displayed in the drop-down use the `ComboBox.DisplayMemeberPath` or define a `DataTemplate`. To control the value that is displayed in the selection box use the `MultiSelectComboBox.SelectionBoxDisplayMemberPath` (see example) or if the value in the selection box is the same as the DisplayMemberPath, than it's sufficient to specify DisplayMemberPath alone. MultiSelectComboBox will first check SelectionBoxDisplayMemberPath, if null it checks DisplayMemberPath and if null it calls ToString() on the item (pretty much the default behavior) – BionicCode Apr 07 '22 at 11:53
  • The usage example shows how to use and configure the MultiSelectComboBox. Of course, if you still have questions feel free to ask. – BionicCode Apr 07 '22 at 11:58
  • 1
    For example if you need to compute the value of the item that is displayed in the selection box, simply define a DataTemplate for the items (it is used for the drop-down panel) and override ToString on the item model. In this case the SelectionBoxDisplayMemberPath and the DisplayMemberPath will return null and the the MultiSelectComboBox will call the model's ToString() to get the selection box display value. This gives you three options how to define the item's selection box display value. **Please note: I have updated the `OnSelectionChanged` method to fix a related behavior.** – BionicCode Apr 07 '22 at 12:15
  • 1
    I have updated the usage example to also show an example `DataTemplate` to display some text of the item. **Please note: I have updated the `MultiSelectComboBox.OnSelectionChanged` method to fix a related behavior.** – BionicCode Apr 07 '22 at 12:22
  • 1
    I have also updated the `ComboBoxToggleItem` code to add an `OnSelected` override. This allows selecting the items using the edit mode (text search). – BionicCode Apr 07 '22 at 13:29
  • Oook slowly i am getting it ;) Again your explanation is gold! – theDrifter Apr 07 '22 at 16:08
  • Your are welcome. I guess some parts can be tricky to understand if you come across them the first time. Don't worry. – BionicCode Apr 07 '22 at 16:24
3

Inspired by the post linked in the comments, I wondered how far I'd get using the base WPF building blocks, but without messing with the ComboBox' template itself. The answer from Heena uses a WrapPanel to show a (filtered) SelectedItems collection that updates on the fly.

<ListBox Background="Transparent" IsHitTestVisible="False" BorderBrush="Transparent" ScrollViewer.HorizontalScrollBarVisibility="Hidden" ScrollViewer.VerticalScrollBarVisibility="Disabled"  BorderThickness="0" ItemsSource="{Binding ElementName=lst,Path=SelectedItems}">
    <ListBox.ItemsPanel>
        <ItemsPanelTemplate>
            <WrapPanel  Orientation="Horizontal"></WrapPanel>
        </ItemsPanelTemplate>
    </ListBox.ItemsPanel>
    <ListBox.ItemTemplate>
        <DataTemplate>
            <TextBlock Text="{Binding ContentData}"/>
        </DataTemplate>
    </ListBox.ItemTemplate>
</ListBox>

I figured a DataTemplateSelector, to differentiate between the SelectedItemTemplate and the DropdownItemsTemplate, in combination with a CollectionView, that has a filter applied, should get us there.

smileys

CheckBoxItem.cs

public class CheckboxItem : ViewModelBase
{
    public CheckboxItem(string name, string imagePath)
    {
        DisplayName = name;
        ImagePath = imagePath;
        SelectItem = new DummyCommand();
    }

    public string ImagePath { get; }
    public string DisplayName { get; }
    public ICommand SelectItem { get; }

    private bool selected;
    public bool Selected
    {
        get => selected;
        set
        {
            selected = value;
            OnPropertyChanged();
            ItemSelectionChanged?.Invoke(this, new EventArgs());
        }
    }

    public event EventHandler ItemSelectionChanged;
}

MainViewModel.cs

public class MainViewModel : ViewModelBase
{
    public MainViewModel()
    {
        Items = new ObservableCollection<CheckboxItem>
        {
            new CheckboxItem("smile", "/Images/Smiley.png"),
            new CheckboxItem("wink", "/Images/Wink.png"),
            new CheckboxItem("shocked", "/Images/Shocked.png"),
            new CheckboxItem("teeth", "/Images/Teeth.png"),
        };

        SelectedItems = (CollectionView)new CollectionViewSource { Source = Items }.View;
        SelectedItems.Filter = o => o is CheckboxItem item && item.Selected;

        foreach(var item in Items)
        {
            item.ItemSelectionChanged += (_, _) => SelectedItems.Refresh();
        }

        // Set a (dummy) SelectedItem for the TemplateSelector to kick in OnLoad.
        SelectedItem = Items.First();
    }

    public ObservableCollection<CheckboxItem> Items { get; }
    public CollectionView SelectedItems { get; }
    public CheckboxItem SelectedItem { get; }
}

ComboBoxTemplateSelector.cs

public class ComboBoxTemplateSelector : DataTemplateSelector
{
    public DataTemplate SelectedItemTemplate { get; set; }
    public DataTemplate DropdownItemsTemplate { get; set; }

    public override DataTemplate SelectTemplate(object item, DependencyObject container)
    {
        var itemToCheck = container;

        // Search up the visual tree, stopping at either a ComboBox or a ComboBoxItem (or null).
        // This will determine which template to use.
        while (itemToCheck is not null and not ComboBox and not ComboBoxItem)
            itemToCheck = VisualTreeHelper.GetParent(itemToCheck);

        // If you stopped at a ComboBoxItem, you're in the dropdown.
        return itemToCheck is ComboBoxItem ? DropdownItemsTemplate : SelectedItemTemplate;
    }
}

MainWindow.xaml

<Window x:Class="WpfApp.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:WpfApp"
        mc:Ignorable="d"
        Title="MultiSelectComboBox" Height="350" Width="400">
    <Window.DataContext>
        <local:MainViewModel/>
    </Window.DataContext>
    <Window.Resources>
        <local:ComboBoxTemplateSelector x:Key="ComboBoxTemplateSelector">
            <local:ComboBoxTemplateSelector.SelectedItemTemplate>
                <DataTemplate>
                    <ListBox ItemsSource="{Binding DataContext.SelectedItems, RelativeSource={RelativeSource AncestorType={x:Type ComboBox}}}"
                             BorderThickness="0" IsHitTestVisible="False" BorderBrush="Transparent" Background="Transparent" 
                             ScrollViewer.HorizontalScrollBarVisibility="Hidden" ScrollViewer.VerticalScrollBarVisibility="Disabled">
                        <ListBox.ItemsPanel>
                            <ItemsPanelTemplate>
                                <WrapPanel Orientation="Horizontal"></WrapPanel>
                            </ItemsPanelTemplate>
                        </ListBox.ItemsPanel>
                        <ListBox.ItemTemplate>
                            <DataTemplate DataType="{x:Type local:CheckboxItem}">
                                <TextBlock Text="{Binding DisplayName}" />
                            </DataTemplate>
                        </ListBox.ItemTemplate>
                    </ListBox>
                </DataTemplate>
            </local:ComboBoxTemplateSelector.SelectedItemTemplate>
            <local:ComboBoxTemplateSelector.DropdownItemsTemplate>
                <DataTemplate DataType="{x:Type local:CheckboxItem}">
                    <StackPanel Orientation="Horizontal">
                        <Image Source="{Binding Path=ImagePath}" Height="50">
                            <Image.InputBindings>
                                <MouseBinding Gesture="LeftClick" Command="{Binding SelectItem, Mode=OneWay}" />
                            </Image.InputBindings>
                        </Image>
                        <CheckBox VerticalAlignment="Center" VerticalContentAlignment="Center" Margin="20 0"
                                  IsChecked="{Binding Selected, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" >
                            <TextBlock Text="{Binding DisplayName}" VerticalAlignment="Center" />
                        </CheckBox>
                    </StackPanel>
                </DataTemplate>
            </local:ComboBoxTemplateSelector.DropdownItemsTemplate>
        </local:ComboBoxTemplateSelector>
    </Window.Resources>
    <Grid>
        <ComboBox ItemsSource="{Binding Path=Items}"
                  SelectedItem="{Binding SelectedItem, Mode=OneTime}"
                  ItemTemplateSelector="{StaticResource ComboBoxTemplateSelector}"
                  Height="30" Width="200" Margin="0 30 0 0"
                  VerticalAlignment="Top" HorizontalAlignment="Center">
        </ComboBox>
    </Grid>
</Window>

The ViewModel contains already a comma separated string containing the selected items. How do i have to change the combobox to behave like this?

You could swap

<local:ComboBoxTemplateSelector.SelectedItemTemplate>
    <DataTemplate>
        <ListBox ItemsSource="{Binding DataContext.SelectedItems, RelativeSource={RelativeSource AncestorType={x:Type ComboBox}}}"
                         BorderThickness="0" IsHitTestVisible="False" BorderBrush="Transparent" Background="Transparent" 
                         ScrollViewer.HorizontalScrollBarVisibility="Hidden" ScrollViewer.VerticalScrollBarVisibility="Disabled">
            <ListBox.ItemsPanel>
                <ItemsPanelTemplate>
                    <WrapPanel Orientation="Horizontal"></WrapPanel>
                </ItemsPanelTemplate>
            </ListBox.ItemsPanel>
            <ListBox.ItemTemplate>
                <DataTemplate DataType="{x:Type local:CheckboxItem}">
                    <TextBlock Text="{Binding DisplayName}" />
                </DataTemplate>
            </ListBox.ItemTemplate>
        </ListBox>
    </DataTemplate>
</local:ComboBoxTemplateSelector.SelectedItemTemplate>

With

<local:ComboBoxTemplateSelector.SelectedItemTemplate>
    <DataTemplate>
        <TextBlock Text="{Binding DataContext.CommaSeperatedString, RelativeSource={RelativeSource AncestorType={x:Type ComboBox}}}" />
    </DataTemplate>
</local:ComboBoxTemplateSelector.SelectedItemTemplate>

You could also do without the CommaSeperatedString property and combine the CollectionView with Greg M's string.Join approach inside a converter:

<local:ComboBoxTemplateSelector.SelectedItemTemplate>
    <DataTemplate>
        <TextBlock Text="{Binding DataContext.SelectedItems, 
                   RelativeSource={RelativeSource AncestorType={x:Type ComboBox}}, 
                   Converter={StaticResource ItemsToCommaSeparatedStringConverter}}" />
    </DataTemplate>
</local:ComboBoxTemplateSelector.SelectedItemTemplate>

Resources:

Funk
  • 10,976
  • 1
  • 17
  • 33
2

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 () );
  }
Greg M
  • 676
  • 3
  • 3