WPF/C# 中的多选组合框以显示所有选定项目的显示名称

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

组合框绑定到自定义组合框项目列表,其中每个项目都包含一个复选框和一个图像。用户可以单击图像或复选框以 select 项目。

每个项目都包含一个相对图像路径,即选择状态。视图模型生成列表 CustomCheckboxItems,然后视图将绑定到该列表。

现在我想将组合框中所有 selected 项目的显示名称显示为... selected 项目 ...。我怎样才能做到这一点?我试图将 contentpresenter 附加到组合框但没有成功,因为我不知道将它附加到哪里。编写控件模板对我来说也没有用。

最后,组合框应该看起来像 this(Link 转到 cdn.syncfusion.com)。 ViewModel 已经包含一个逗号分隔的字符串,其中包含 selected 项。我如何才能将组合框更改为这样的行为?

查看:

<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>

CustomCheckBoxItem 实现

    //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();
        
    }

解决方案是从 ComboBox.
断开在 selection box 中承载 selected 项目的 ContentPresenter ComboBox 在内部设置 selection box 值。没有机会修改此行为。由于 selection 框同时作为编辑或搜索模式的输入框,这个逻辑相当复杂。从这个角度来看,将ComboBox内部的完整相关逻辑做的设计选择是很有意义的。

第一个选项是简单地覆盖 ComboBox 的默认模板并删除 ContentPresenter.ContentTemplateContentPresenter.ContentTemplateSelectorContentPresenter.ContentStringFormat 属性的绑定。
然后将 ContentPresenter.Content 属性 绑定到包含多个 selection 显示名称的数据源。缺点是完整的 multi-select 逻辑在 ComboBox 之外(在最坏的情况下,它甚至会渗入视图模型)。由于 multi-select 行为是控件的一部分,它应该在内部实现。

第二个解决方案是扩展ComboBox以使控件的处理更加方便和可重用。您可能不希望您的视图模型负责参与 multi-select 逻辑,例如通过跟踪 multi-select 上下文中的 select 离子状态(从视图模型的角度来看,这不是我们感兴趣的),并通过公开显示值来表示 multi-select 状态。您的视图模型不应该关心显示值或 multi-select.

第三个选项是实施您的自定义 ComboBox 并让它扩展 MultiSelector。如果您需要所有 ComboBox 功能,那么此解决方案可能需要一段时间才能正确实施和测试。如果您只需要一个基本的 multi-select 下拉控件,那么该任务非常简单,也是最干净的解决方案。

不推荐您可能会发现的其他选项,例如破解编辑模式以设置 ComboBox.Text 属性,因为它们会干扰内部行为:围绕 IsEditable 的逻辑IsReadOnly 非常复杂。所以最好不要乱来,以防止出现意外行为。
此外,您会失去许多功能,例如搜索和编辑。如果您不关心这些功能,那么最好和最干净的解决方案是第三个引入的选项来实现扩展 MultiSelector.

的自定义控件

实施

以下示例实现了第二种解决方案。 MultiSelectComboBox 扩展了 ComboBox。它在内部获取 ContentPresenter,将其与 ComboBox 断开连接(通过覆盖其内容并删除内部模板)并跟踪 selection 状态。此解决方案的优点是您不必“破解”ComboBox 的编辑模式。因此,编辑模式功能保持不变。此解决方案只是更改默认切换框中的显示值。即使是默认的 single.select 行为也保持不变。

multi-select 状态是通过覆盖 ComboBoxItem 模板来实现的,用 ToggleButton 替换 ContentPresenter 或者创建一个 ComboBoxToggleItem (比如在下面的示例中)扩展 ComboBoxItem 以使所有内容都可重用并准备好 MVVM。
此外,我们需要通过将其实现为附加 属性 来引入自定义 IsItemSelected 属性。这是必要的,因为 ComboBoxItem.IsSelected 属性 由 SelectorComboBox 的超类)控制。通过附加它,我们可以避免 MultiSelectComboBox 逻辑和 ComboBoxToggleItem 之间的紧密耦合。一切仍然适用于默认的 ComboBoxItem 或任何其他项目容器。 Selector 还负责确保只有一个项目被 selected。但是我们需要同时 select 编辑多个项目。
这样我们就可以轻松地跟踪 selected 项并通过 public SelectedItems 属性.

公开它们

您可以使用 ItemContainerStyle 将数据模型的 selection 属性(如果存在)绑定到此附加的 MultiSelectComboBox.IsItemSelected 属性。

通过将 ComboBoxToggleItem 和 hard-coding CheckBox 之类的自定义 ComboBoxItem 实现到其 ControlTemplate 中,您不再被迫跟踪视觉状态在你的视图模型中。这提供了一个干净的分离。本例中 CheckBox 的可见性可以通过处理 ComboBoxToggleItem.IsCheckBoxEnabled 属性.
来切换 因为 ComboBoxToggleItem 基本上是 ToggleButton,您可以 select 项目而无需单击 CheckBoxCheckBox 现在是一项可选功能,仅用于提供另一种视觉反馈。

如果您没有定义 ItemTemplate,您可以使用常见的 DisplayMemberPathComboBox 默认行为)控制显示的值。 MultiSelectComboBox 将从指定成员中选取值并将其与其他 selected 值连接。
如果要为 drop-down 面板和 selected 内容框显示不同的值,请使用 MultiSelectComboBox.SelectionBoxDisplayMemberPath 属性 指定项目的来源 属性 名称(以与 DisplayMemberPath).
相同的方式 如果您既不设置 DisplayMemberPath 也不设置 SelectionBoxDisplayMemberPathMultiSelectComboBox 将在数据项上调用 object.ToString。这样,您甚至可以通过覆盖模型上的 ToString 来生成计算值。这为您提供了三个选项来控制 selection 框显示值,而 MultiSelectComboBox 连接并显示它们。

这个是的,处理显示值的完整逻辑已从视图模型移动到它所属的视图:

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>

使用示例

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>

如果我理解正确的话:

  1. 您只想将所有选中的项目移动到组合框的顶部
  2. 您希望在关闭的组合框中显示以逗号分隔的所选项目列表。

如果那是正确的,那么解决方案比您想象的要简单得多。

只需要这个:

  /// <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 () );
  }

解决方案的关键是每次打开组合时重新排序列表,您会显示一个 re-sorted 列表。 每次关闭,你收集选择并显示。

完整的工作示例: 鉴于此 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>

和这个 class(删除了这个示例的通知,因为不需要)

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;

}

然后为了测试我只用了这个:

  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 () );
  }

受 Heena 的 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 启发,使用 WrapPanel 显示动态更新的(过滤后的)SelectedItems 集合。

<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>

我想 DataTemplateSelector 来区分 SelectedItemTemplateDropdownItemsTemplate,结合应用了过滤器的 CollectionView,应该让我们那里。

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?

你可以交换

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

您也可以不使用 CommaSeperatedString 属性 并将 CollectionView 与 Greg M 的 string.Join 方法结合在一个转换器中:

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

资源:

  • WrapPanel 思路:
  • 使用不同的模板: Can I use a different Template for the selected item in a WPF ComboBox than for the items in the dropdown part?
  • 过滤ItemsSource
  • 笑脸: https://pngimg.com/images/miscellaneous/smiley