WPF 实现一个支持搜索的懒加载下拉列表控件

179 阅读4分钟

前言

因为项目中PC端前端针对基础数据选择时的下拉列表做了懒加载控件,PC端使用现成的组件,为保持两端的选择方式统一,WPF客户端上也需要使用懒加载的下拉选择。

WPF这种懒加载的控件未找到现成可用的组件,于是自己封装了一个懒加载和支持模糊过滤的下拉列表控件,控件使用了虚拟化加载,解决了大数据量时的渲染数据卡顿问题,下面是完整的代码和示例:

一、控件所需的关键实体类

/// <summary>
/// 下拉项
/// </summary>
public class ComboItem
{
    /// <summary>
    /// 实际存储值
    /// </summary>
    public string? ItemValue { get; set; }
    /// <summary>
    /// 显示文本
    /// </summary>
    public string? ItemText { get; set; }
}

/// <summary>
/// 懒加载下拉数据源提供器
/// </summary>
public class ComboItemProvider : ILazyDataProvider<ComboItem>
{
    private readonly List<ComboItem> _all;
    public ComboItemProvider()
    {
        _all = Enumerable.Range(1, 1000000)
                         .Select(i => new ComboItem { ItemValue = i.ToString(), ItemText = $"Item {i}" })
                         .ToList();
    }
    public async Task<PageResult<ComboItem>> FetchAsync(string filter, int pageIndex, int pageSize)
    {
        await Task.Delay(100);
        var q = _all.AsQueryable();
        if (!string.IsNullOrEmpty(filter))
            q = q.Where(x => x.ItemText.Contains(filter, StringComparison.OrdinalIgnoreCase));
        var page = q.Skip(pageIndex * pageSize).Take(pageSize).ToList();
        bool has = q.Count() > (pageIndex + 1) * pageSize;
        return new PageResult<ComboItem> { Items = page, HasMore = has };
    }
}

/// <summary>
/// 封装获取数据的接口
/// </summary>
/// <typeparam name="T"></typeparam>
public interface ILazyDataProvider<T>
{
    Task<PageResult<T>> FetchAsync(string filter, int pageIndex, int pageSize);
}

/// <summary>
/// 懒加载下拉分页对象
/// </summary>
/// <typeparam name="T"></typeparam>
public class PageResult<T>
{
    public IReadOnlyList<T> Items { get; set; }
    public bool HasMore { get; set; }
}

二、懒加载控件视图和数据逻辑

<UserControl
    x:Class="LazyComboBoxFinalDemo.Controls.LazyComboBox"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="clr-namespace:LazyComboBoxFinalDemo.Controls">
    <UserControl.Resources>
        <local:ZeroToVisibleConverter x:Key="ZeroToVisibleConverter" />
        <!--  清除按钮样式:透明背景、图标  -->
        <Style x:Key="ClearButtonStyle" TargetType="Button">
            <Setter Property="Background" Value="Transparent" />
            <Setter Property="BorderThickness" Value="0" />
            <Setter Property="Padding" Value="0" />
            <Setter Property="Cursor" Value="Hand" />
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate TargetType="Button">
                        <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        </Style>
        <!--  ToggleButton 样式  -->
        <Style x:Key="ComboToggleButtonStyle" TargetType="ToggleButton">
            <Setter Property="Background" Value="White" />
            <Setter Property="BorderBrush" Value="#CCC" />
            <Setter Property="BorderThickness" Value="1" />
            <Setter Property="Padding" Value="4" />
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate TargetType="ToggleButton">
                        <Border
                            Padding="{TemplateBinding Padding}"
                            Background="{TemplateBinding Background}"
                            BorderBrush="{TemplateBinding BorderBrush}"
                            BorderThickness="{TemplateBinding BorderThickness}"
                            CornerRadius="4">
                            <Grid>
                                <Grid.ColumnDefinitions>
                                    <ColumnDefinition />
                                    <ColumnDefinition Width="20" />
                                    <ColumnDefinition Width="20" />
                                </Grid.ColumnDefinitions>
                                <!--  按钮文本  -->
                                <ContentPresenter
                                    Grid.Column="0"
                                    Margin="4,0,0,0"
                                    VerticalAlignment="Center"
                                    Content="{TemplateBinding Content}" />
                                <!--  箭头  -->
                                <Path
                                    x:Name="Arrow"
                                    Grid.Column="2"
                                    VerticalAlignment="Center"
                                    Data="M 0 0 L 4 4 L 8 0 Z"
                                    Fill="Gray"
                                    RenderTransformOrigin="0.5,0.5">
                                    <Path.RenderTransform>
                                        <RotateTransform Angle="0" />
                                    </Path.RenderTransform>
                                </Path>
                                <!--  清除按钮  -->
                                <Button
                                    x:Name="PART_ClearButton"
                                    Grid.Column="1"
                                    Width="16"
                                    Height="16"
                                    VerticalAlignment="Center"
                                    Click="OnClearClick"
                                    Style="{StaticResource ClearButtonStyle}"
                                    Visibility="Collapsed">
                                    <Path
                                        Data="M0,0 L8,8 M8,0 L0,8"
                                        Stroke="Gray"
                                        StrokeThickness="2" />
                                </Button>

                            </Grid>
                        </Border>
                        <ControlTemplate.Triggers>
                            <Trigger Property="IsMouseOver" Value="True">
                                <Setter TargetName="PART_ClearButton" Property="Visibility" Value="Visible" />
                            </Trigger>
                            <DataTrigger Binding="{Binding IsOpen, ElementName=PART_Popup}" Value="True">
                                <Setter TargetName="Arrow" Property="RenderTransform">
                                    <Setter.Value>
                                        <RotateTransform Angle="180" />
                                    </Setter.Value>
                                </Setter>
                            </DataTrigger>
                        </ControlTemplate.Triggers>
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        </Style>
        <!--  ListBoxItem 悬停/选中样式  -->
        <Style TargetType="ListBoxItem">
            <Setter Property="HorizontalContentAlignment" Value="Stretch" />
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate TargetType="ListBoxItem">
                        <Border
                            x:Name="Bd"
                            Padding="4"
                            Background="Transparent">
                            <ContentPresenter />
                        </Border>
                        <ControlTemplate.Triggers>
                            <Trigger Property="IsMouseOver" Value="True">
                                <Setter TargetName="Bd" Property="Background" Value="#EEE" />
                            </Trigger>
                            <Trigger Property="IsSelected" Value="True">
                                <Setter TargetName="Bd" Property="Background" Value="#CCC" />
                            </Trigger>
                        </ControlTemplate.Triggers>
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        </Style>
        <!--  Popup 边框  -->
        <Style x:Key="PopupBorder" TargetType="Border">
            <Setter Property="CornerRadius" Value="5" />
            <Setter Property="Background" Value="White" />
            <Setter Property="BorderBrush" Value="#CCC" />
            <Setter Property="BorderThickness" Value="2" />
            <Setter Property="Padding" Value="10" />
        </Style>
        <!--  水印 TextBox  -->
        <Style x:Key="WatermarkTextBox" TargetType="TextBox">
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate TargetType="TextBox">
                        <Grid>
                            <ScrollViewer x:Name="PART_ContentHost" />
                            <TextBlock
                                Margin="4,2,0,0"
                                Foreground="Gray"
                                IsHitTestVisible="False"
                                Text="搜索…"
                                Visibility="{Binding Text.Length, RelativeSource={RelativeSource TemplatedParent}, Converter={StaticResource ZeroToVisibleConverter}}" />
                        </Grid>
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        </Style>
    </UserControl.Resources>
    <Grid>
        <ToggleButton
            x:Name="PART_Toggle"
            Click="OnToggleClick"
            Style="{StaticResource ComboToggleButtonStyle}">
            <Grid>
                <!--  显示文本  -->
                <TextBlock
                    Margin="4,0,24,0"
                    VerticalAlignment="Center"
                    Text="{Binding DisplayText, RelativeSource={RelativeSource AncestorType=UserControl}}" />
                <!--  箭头已在模板内,略  -->
            </Grid>
        </ToggleButton>
        <Popup
            x:Name="PART_Popup"
            AllowsTransparency="True"
            PlacementTarget="{Binding ElementName=PART_Toggle}"
            PopupAnimation="Fade"
            StaysOpen="False">
            <!--  AllowsTransparency 启用透明,PopupAnimation 弹窗动画  -->
            <Border Width="{Binding ActualWidth, ElementName=PART_Toggle}" Style="{StaticResource PopupBorder}">
                <Border.Effect>
                    <DropShadowEffect
                        BlurRadius="15"
                        Opacity="0.7"
                        ShadowDepth="0"
                        Color="#e6e6e6" />
                </Border.Effect>
                <Grid Height="300">
                    <Grid.RowDefinitions>
                        <RowDefinition Height="Auto" />
                        <RowDefinition Height="*" />
                    </Grid.RowDefinitions>
                    <!--  搜索框  -->
                    <TextBox
                        x:Name="PART_SearchBox"
                        Margin="0,0,0,8"
                        VerticalAlignment="Center"
                        Style="{StaticResource WatermarkTextBox}"
                        TextChanged="OnSearchChanged" />
                    <!--  列表  -->
                    <ListBox
                        x:Name="PART_List"
                        Grid.Row="1"
                        DisplayMemberPath="ItemText"
                        ItemsSource="{Binding Items, RelativeSource={RelativeSource AncestorType=UserControl}}"
                        ScrollViewer.CanContentScroll="True"
                        ScrollViewer.ScrollChanged="OnScroll"
                        SelectionChanged="OnSelectionChanged"
                        VirtualizingStackPanel.IsVirtualizing="True"
                        VirtualizingStackPanel.VirtualizationMode="Recycling" />
                </Grid>
            </Border>
        </Popup>
    </Grid>
</UserControl>

public partial class LazyComboBox : UserControl, INotifyPropertyChanged
{
    public static readonly DependencyProperty ItemsProviderProperty =
        DependencyProperty.Register(nameof(ItemsProvider), typeof(ILazyDataProvider<ComboItem>),
            typeof(LazyComboBox), new PropertyMetadata(null));

    public ILazyDataProvider<ComboItem> ItemsProvider
    {
        get => (ILazyDataProvider<ComboItem>)GetValue(ItemsProviderProperty);
        set => SetValue(ItemsProviderProperty, value);
    }

    public static readonly DependencyProperty SelectedItemProperty =
        DependencyProperty.Register(nameof(SelectedItem), typeof(ComboItem),
            typeof(LazyComboBox),
            new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnSelectedItemChanged));

    public ComboItem SelectedItem
    {
        get => (ComboItem)GetValue(SelectedItemProperty);
        set => SetValue(SelectedItemProperty, value);
    }

    private static void OnSelectedItemChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        if (d is LazyComboBox ctrl)
        {
            ctrl.Notify(nameof(DisplayText));
        }
    }

    public ObservableCollection<ComboItem> Items { get; } = new ObservableCollection<ComboItem>();
    private string _currentFilter = "";
    private int _currentPage = 0;
    private const int PageSize = 30;
    public bool HasMore { get; private set; }
    public string DisplayText => SelectedItem?.ItemText ?? "请选择...";

    public LazyComboBox()
    {
        InitializeComponent();
    }

    public event PropertyChangedEventHandler PropertyChanged;
    private void Notify(string prop) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(prop));

    private async void LoadPage(int pageIndex)
    {
        if (ItemsProvider == null) return;
        var result = await ItemsProvider.FetchAsync(_currentFilter, pageIndex, PageSize);
        if (pageIndex == 0) Items.Clear();
        foreach (var it in result.Items) Items.Add(it);
        HasMore = result.HasMore;
        PART_Popup.IsOpen = true;
    }

    private void OnClearClick(object sender, RoutedEventArgs e)
    {
        e.Handled = true;  // 阻止事件冒泡,不触发 Toggle 打开
        SelectedItem = null; // 清空选中
        Notify(nameof(DisplayText)); // 刷新按钮文本
        PART_Popup.IsOpen = false;   // 确保关掉弹窗
    }

    private void OnToggleClick(object sender, RoutedEventArgs e)
    {
        _currentPage = 0;
        LoadPage(0);
        PART_Popup.IsOpen = true;
    }

    private void OnSearchChanged(object sender, TextChangedEventArgs e)
    {
        _currentFilter = PART_SearchBox.Text;
        _currentPage = 0;
        LoadPage(0);
    }

    private void OnScroll(object sender, ScrollChangedEventArgs e)
    {
        if (!HasMore) return;
        if (e.VerticalOffset >= e.ExtentHeight - e.ViewportHeight - 2)
            LoadPage(++_currentPage);
    }

    private void OnSelectionChanged(object sender, SelectionChangedEventArgs e)
    {
        if (PART_List.SelectedItem is ComboItem item)
        {
            SelectedItem = item;
            Notify(nameof(DisplayText));
            PART_Popup.IsOpen = false;
        }
    }
}
/// <summary>
/// 下拉弹窗搜索框根据数据显示专用转换器
/// 用于将0转换为可见
/// </summary>
public class ZeroToVisibleConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
    {
        if (value is int i && i == 0)
            return Visibility.Visible;
        return Visibility.Collapsed;
    }

    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        => throw new NotImplementedException();
}

三、视图页面使用示例

xmlns:ctrl="clr-namespace:LazyComboBoxFinalDemo.Controls"
<Grid Margin="10">
    <ctrl:LazyComboBox
        Width="200"
        Height="40"
        ItemsProvider="{Binding MyDataProvider}"
        SelectedItem="{Binding PartSelectedItem, Mode=TwoWay}" />
</Grid>

对应视图的VM中绑定数据

public ILazyDataProvider<ComboItem> MyDataProvider 
{ get; }= new ComboItemProvider();

/// <summary>
/// 当前选择值
/// </summary>
[ObservableProperty]
private ComboItem partSelectedItem;

四、效果图

总结

本文详细介绍了如何在WPF中实现一个高效的自定义控件。希望本文的内容能为正在寻找类似解决方案的小伙伴提供有价值的参考。如果大家有任何问题或改进建议,欢迎留言交流!

最后

如果你觉得这篇文章对你有帮助,不妨点个赞支持一下!你的支持是我继续分享知识的动力。如果有任何疑问或需要进一步的帮助,欢迎随时留言。

也可以加入微信公众号 [DotNet技术匠] 社区,与其他热爱技术的同行一起交流心得,共同成长!

优秀是一种习惯,欢迎大家留言学习!

作者:流浪阿丁

出处:cnblogs.com/adingfirstlove/p/18855065

声明:网络内容,仅供学习,尊重版权,侵权速删,歉意致谢!