目录
示例截图


调用方法
<Window x:Class="MultiSelectDemo.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:MultiSelectDemo"
xmlns:ctrl="clr-namespace:MultiSelectDemo.Controls"
Title="搜索多选下拉框示例" Height="600" Width="700"
WindowStartupLocation="CenterScreen"
Background="#F5F7FA">
<Window.DataContext>
<local:MainViewModel/>
</Window.DataContext>
<Grid Margin="40">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- 标题 -->
<TextBlock Grid.Row="0"
Text="WPF 搜索多选下拉框控件"
FontSize="24"
FontWeight="Bold"
Foreground="#303133"
Margin="0,0,0,30"/>
<!-- 内容区域 -->
<StackPanel Grid.Row="1">
<!-- 使用控件 -->
<StackPanel Margin="0,0,0,20">
<TextBlock Text="选择城市:" FontSize="14" Foreground="#606266" Margin="0,0,0,8"/>
<!-- 使用封装的控件 -->
<ctrl:MultiSelectComboBox
ItemsSource="{Binding Cities}"
SelectedItems="{Binding SelectedCities, Mode=TwoWay}"
DisplayMemberPath="Label"
ValueMemberPath="Value"
Placeholder="请选择城市..."
MinWidth="300"/>
</StackPanel>
<!-- 显示已选结果 -->
<Border Background="#E8F4FD"
BorderBrush="#409EFF"
BorderThickness="1"
CornerRadius="4"
Padding="16"
Margin="0,0,0,20">
<StackPanel>
<TextBlock Text="已选结果:" FontSize="14" FontWeight="Bold" Foreground="#409EFF" Margin="0,0,0,8"/>
<TextBlock Text="{Binding ResultText}"
Foreground="#606266"
FontSize="13"
TextWrapping="Wrap"/>
</StackPanel>
</Border>
<!-- 说明文字 -->
<Border Background="#FFF7E6"
BorderBrush="#E6A23C"
BorderThickness="1"
CornerRadius="4"
Padding="16">
<StackPanel>
<TextBlock Text="控件特性:" FontSize="14" FontWeight="Bold" Foreground="#E6A23C" Margin="0,0,0,8"/>
<TextBlock Foreground="#606266" FontSize="13" TextWrapping="Wrap">
<Run Text="• ItemsSource 绑定数据源"/>
<LineBreak/>
<Run Text="• SelectedItems 双向绑定获取已选项"/>
<LineBreak/>
<Run Text="• DisplayMemberPath/ValueMemberPath 自定义属性"/>
<LineBreak/>
<Run Text="• Placeholder 设置占位提示"/>
<LineBreak/>
<Run Text="• 搜索过滤 + 全选/清空"/>
</TextBlock>
</StackPanel>
</Border>
</StackPanel>
</Grid>
</Window>
控件代码
using System;
using System.Collections;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Data;
using System.Windows.Media;
using System.Windows.Media.Animation;
namespace MultiSelectDemo.Controls;
public class MultiSelectComboBox : Control
{
static MultiSelectComboBox()
{
DefaultStyleKeyProperty.OverrideMetadata(typeof(MultiSelectComboBox),
new FrameworkPropertyMetadata(typeof(MultiSelectComboBox)));
}
// 依赖属性
public static readonly DependencyProperty ItemsSourceProperty =
DependencyProperty.Register("ItemsSource", typeof(IEnumerable), typeof(MultiSelectComboBox),
new PropertyMetadata(null, OnItemsSourceChanged));
public static readonly DependencyProperty SelectedItemsProperty =
DependencyProperty.Register("SelectedItems", typeof(IList), typeof(MultiSelectComboBox),
new PropertyMetadata(null, OnSelectedItemsChanged));
public static readonly DependencyProperty DisplayMemberPathProperty =
DependencyProperty.Register("DisplayMemberPath", typeof(string), typeof(MultiSelectComboBox),
new PropertyMetadata("Label"));
public static readonly DependencyProperty ValueMemberPathProperty =
DependencyProperty.Register("ValueMemberPath", typeof(string), typeof(MultiSelectComboBox),
new PropertyMetadata("Value"));
public static readonly DependencyProperty PlaceholderProperty =
DependencyProperty.Register("Placeholder", typeof(string), typeof(MultiSelectComboBox),
new PropertyMetadata("请选择..."));
public static readonly DependencyProperty SearchTextProperty =
DependencyProperty.Register("SearchText", typeof(string), typeof(MultiSelectComboBox),
new PropertyMetadata("", OnSearchTextChanged));
public static readonly DependencyProperty IsDropDownOpenProperty =
DependencyProperty.Register("IsDropDownOpen", typeof(bool), typeof(MultiSelectComboBox),
new PropertyMetadata(false));
// 使用 FrameworkPropertyMetadata 并添加 BindsTwoWayByDefault
public static readonly DependencyProperty SelectedTextProperty =
DependencyProperty.Register("SelectedText", typeof(string), typeof(MultiSelectComboBox),
new FrameworkPropertyMetadata("", FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));
// 属性包装器 - 必须有 setter 才能在 XAML 中设置
public IEnumerable ItemsSource
{
get => (IEnumerable)GetValue(ItemsSourceProperty);
set => SetValue(ItemsSourceProperty, value);
}
public IList SelectedItems
{
get => (IList)GetValue(SelectedItemsProperty);
set => SetValue(SelectedItemsProperty, value);
}
public string DisplayMemberPath
{
get => (string)GetValue(DisplayMemberPathProperty);
set => SetValue(DisplayMemberPathProperty, value);
}
public string ValueMemberPath
{
get => (string)GetValue(ValueMemberPathProperty);
set => SetValue(ValueMemberPathProperty, value);
}
public string Placeholder
{
get => (string)GetValue(PlaceholderProperty);
set => SetValue(PlaceholderProperty, value);
}
public bool IsDropDownOpen
{
get => (bool)GetValue(IsDropDownOpenProperty);
set => SetValue(IsDropDownOpenProperty, value);
}
public string SelectedText
{
get => (string)GetValue(SelectedTextProperty);
set => SetValue(SelectedTextProperty, value);
}
public string SearchText
{
get => (string)GetValue(SearchTextProperty);
set => SetValue(SearchTextProperty, value);
}
// 内部集合 - 公开供模板绑定
public ObservableCollection<SelectItemWrapper> InternalItems { get; } = new();
public ObservableCollection<SelectItemWrapper> FilteredItems { get; } = new();
private bool _isSyncing = false;
private static void OnItemsSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var ctrl = (MultiSelectComboBox)d;
ctrl.RebuildInternalItems();
}
private static void OnSelectedItemsChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var ctrl = (MultiSelectComboBox)d;
ctrl.SyncFromExternal();
}
private static void OnSearchTextChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var ctrl = (MultiSelectComboBox)d;
ctrl.ApplyFilter();
}
public override void OnApplyTemplate()
{
base.OnApplyTemplate();
if (GetTemplateChild("PART_Popup") is Popup popup)
{
popup.Opened += OnPopupOpened;
popup.Closed += OnPopupClosed;
}
if (GetTemplateChild("PART_SelectAllBtn") is Button selectAllBtn)
selectAllBtn.Click += (s, e) => SelectAllFiltered();
if (GetTemplateChild("PART_ClearBtn") is Button clearBtn)
clearBtn.Click += (s, e) => ClearAll();
if (GetTemplateChild("PART_SearchBox") is TextBox searchBox)
searchBox.TextChanged += (s, e) => SearchText = searchBox.Text;
}
private void OnPopupOpened(object? sender, EventArgs e)
{
if (GetTemplateChild("PART_SearchBox") is TextBox box)
{
box.Text = SearchText;
box.Focus();
}
if (GetTemplateChild("PART_ArrowRotate") is RotateTransform rotate)
{
var anim = new DoubleAnimation(0, 180, TimeSpan.FromMilliseconds(200));
rotate.BeginAnimation(RotateTransform.AngleProperty, anim);
}
}
private void OnPopupClosed(object? sender, EventArgs e)
{
if (GetTemplateChild("PART_ArrowRotate") is RotateTransform rotate)
{
var anim = new DoubleAnimation(180, 0, TimeSpan.FromMilliseconds(200));
rotate.BeginAnimation(RotateTransform.AngleProperty, anim);
}
}
private void RebuildInternalItems()
{
InternalItems.Clear();
if (ItemsSource == null)
{
UpdateSelectedText();
ApplyFilter();
return;
}
foreach (var item in ItemsSource)
{
var wrapper = new SelectItemWrapper
{
Source = item,
DisplayText = GetDisplayText(item)
};
wrapper.IsSelectedChanged += OnWrapperSelectionChanged;
InternalItems.Add(wrapper);
}
SyncFromExternal();
ApplyFilter();
}
private void OnWrapperSelectionChanged(SelectItemWrapper wrapper)
{
if (_isSyncing) return;
UpdateSelectedText();
SyncToExternal();
}
private void SyncFromExternal()
{
if (SelectedItems == null || InternalItems.Count == 0) return;
_isSyncing = true;
try
{
foreach (var wrapper in InternalItems)
{
wrapper.IsSelected = SelectedItems.Contains(wrapper.Source);
}
}
finally
{
_isSyncing = false;
}
UpdateSelectedText();
}
private void SyncToExternal()
{
if (SelectedItems == null) return;
SelectedItems.Clear();
foreach (var wrapper in InternalItems.Where(w => w.IsSelected))
SelectedItems.Add(wrapper.Source);
}
private void ApplyFilter()
{
FilteredItems.Clear();
var keyword = SearchText?.Trim().ToLower() ?? "";
foreach (var item in InternalItems)
{
if (string.IsNullOrEmpty(keyword) ||
item.DisplayText.ToLower().Contains(keyword))
{
FilteredItems.Add(item);
}
}
}
private void UpdateSelectedText()
{
var selected = InternalItems.Where(w => w.IsSelected).Select(w => w.DisplayText).ToList();
var text = selected.Count == 0 ? Placeholder : string.Join(", ", selected);
SetValue(SelectedTextProperty, text);
}
private string GetDisplayText(object item)
{
if (item == null) return "";
var path = DisplayMemberPath;
if (string.IsNullOrEmpty(path)) return item.ToString() ?? "";
var prop = item.GetType().GetProperty(path);
return prop?.GetValue(item)?.ToString() ?? item.ToString() ?? "";
}
public void SelectAllFiltered()
{
foreach (var item in FilteredItems)
item.IsSelected = true;
}
public void ClearAll()
{
SetValue(SearchTextProperty, "");
foreach (var item in InternalItems)
item.IsSelected = false;
}
}
// 包装类 - 独立文件更清晰
public class SelectItemWrapper : INotifyPropertyChanged
{
private bool _isSelected;
public object? Source { get; set; }
public string DisplayText { get; set; } = "";
public bool IsSelected
{
get => _isSelected;
set
{
if (_isSelected != value)
{
_isSelected = value;
OnPropertyChanged(nameof(IsSelected));
IsSelectedChanged?.Invoke(this);
}
}
}
public event Action<SelectItemWrapper>? IsSelectedChanged;
public event PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged(string name)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
样式文件
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ctrl="clr-namespace:MultiSelectDemo.Controls">
<Style TargetType="{x:Type ctrl:MultiSelectComboBox}">
<Setter Property="Height" Value="36"/>
<Setter Property="MinWidth" Value="200"/>
<Setter Property="Background" Value="White"/>
<Setter Property="BorderBrush" Value="#DCDFE6"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="Foreground" Value="#606266"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type ctrl:MultiSelectComboBox}">
<Grid>
<!-- 输入框按钮 -->
<ToggleButton x:Name="PART_Toggle"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Padding="12,8"
HorizontalContentAlignment="Left"
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}"
Cursor="Hand">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- 显示已选文本 -->
<TextBlock Grid.Column="0"
Text="{Binding SelectedText, Mode=OneWay, RelativeSource={RelativeSource TemplatedParent}}"
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center"
Foreground="{TemplateBinding Foreground}"
FontSize="{TemplateBinding FontSize}"/>
<!-- 下拉箭头 -->
<Path Grid.Column="1"
Data="M0,0 L5,5 L10,0"
Stroke="#909399"
StrokeThickness="1.5"
Stretch="Uniform"
Width="10" Height="10"
Margin="8,0,0,0"
RenderTransformOrigin="0.5,0.5">
<Path.RenderTransform>
<RotateTransform x:Name="PART_ArrowRotate" Angle="0"/>
</Path.RenderTransform>
</Path>
</Grid>
<ToggleButton.Style>
<Style TargetType="ToggleButton">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ToggleButton">
<Border x:Name="Border"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Padding="{TemplateBinding Padding}"
CornerRadius="4">
<ContentPresenter HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Border" Property="BorderBrush" Value="#409EFF"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Border" Property="BorderBrush" Value="#C0C4CC"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ToggleButton.Style>
</ToggleButton>
<!-- 下拉弹出框 -->
<Popup x:Name="PART_Popup"
AllowsTransparency="True"
Placement="Bottom"
PlacementTarget="{Binding ElementName=PART_Toggle}"
StaysOpen="False"
IsOpen="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}">
<Border Background="White"
BorderBrush="#E4E7ED"
BorderThickness="1"
CornerRadius="6"
Margin="0,4,0,0"
MinWidth="{TemplateBinding MinWidth}">
<Border.Effect>
<DropShadowEffect BlurRadius="12" ShadowDepth="2" Opacity="0.2"/>
</Border.Effect>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- 搜索框 -->
<Border Grid.Row="0"
BorderBrush="#E4E7ED"
BorderThickness="0,0,0,1"
Padding="12,10">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="🔍" FontSize="14" Foreground="#C0C4CC" Margin="0,0,8,0"/>
<TextBox Grid.Column="1"
x:Name="PART_SearchBox"
BorderThickness="0"
FontSize="14"
Background="Transparent"
Foreground="#606266"
VerticalAlignment="Center"
Padding="0"/>
</Grid>
</Border>
<!-- 操作按钮 -->
<Border Grid.Row="1"
BorderBrush="#E4E7ED"
BorderThickness="0,0,0,1"
Padding="12,8">
<StackPanel Orientation="Horizontal">
<Button x:Name="PART_SelectAllBtn"
Content="全选"
FontSize="12"
Padding="10,4"
Margin="0,0,8,0"
Cursor="Hand"
Foreground="#409EFF">
<Button.Template>
<ControlTemplate TargetType="Button">
<Border Background="Transparent"
BorderBrush="#DCDFE6"
BorderThickness="1"
CornerRadius="3"
Padding="{TemplateBinding Padding}"
x:Name="BtnBorder">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="BtnBorder" Property="Background" Value="#ECF5FF"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Button.Template>
</Button>
<Button x:Name="PART_ClearBtn"
Content="清空"
FontSize="12"
Padding="10,4"
Cursor="Hand"
Foreground="#909399">
<Button.Template>
<ControlTemplate TargetType="Button">
<Border Background="Transparent"
BorderBrush="#DCDFE6"
BorderThickness="1"
CornerRadius="3"
Padding="{TemplateBinding Padding}"
x:Name="BtnBorder">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="BtnBorder" Property="Background" Value="#F5F7FA"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Button.Template>
</Button>
</StackPanel>
</Border>
<!-- 选项列表 - 使用 CheckBox -->
<ScrollViewer Grid.Row="2"
MaxHeight="280"
VerticalScrollBarVisibility="Auto"
Padding="0,4">
<ItemsControl ItemsSource="{Binding FilteredItems, RelativeSource={RelativeSource TemplatedParent}}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="ctrl:SelectItemWrapper">
<!-- 使用 CheckBox 并自定义样式 -->
<CheckBox IsChecked="{Binding IsSelected, Mode=TwoWay}"
Padding="12,10"
Cursor="Hand"
Content="{Binding DisplayText}">
<CheckBox.Style>
<Style TargetType="CheckBox">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="CheckBox">
<Border Background="Transparent"
Padding="{TemplateBinding Padding}"
x:Name="ItemBorder">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- 勾选框 -->
<Border Grid.Column="0"
Width="18" Height="18"
BorderBrush="#DCDFE6"
BorderThickness="1"
CornerRadius="3"
Background="White"
Margin="0,0,10,0"
x:Name="CheckBorder">
<!-- 勾选图标 -->
<Path x:Name="CheckPath"
Data="M3,8 L8,13 L14,4"
Stroke="White"
StrokeThickness="2.5"
Stretch="Uniform"
Visibility="Collapsed"
Margin="3"/>
</Border>
<!-- 文字 -->
<ContentPresenter Grid.Column="1"
VerticalAlignment="Center"
TextBlock.FontSize="14"
TextBlock.Foreground="#606266"/>
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="CheckBorder" Property="Background" Value="#409EFF"/>
<Setter TargetName="CheckBorder" Property="BorderBrush" Value="#409EFF"/>
<Setter TargetName="CheckPath" Property="Visibility" Value="Visible"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="ItemBorder" Property="Background" Value="#F5F7FA"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</CheckBox.Style>
</CheckBox>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
</Border>
</Popup>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>