具有过滤器和自动完成功能的 ComboBox

ComboBox with both filter and auto-complete

有没有人成功使用 WPF 的 ComboBox 自动完成和筛选功能?我已经花了几个小时了,但还没能搞定。这是 WPF + MVVM Light。这是我的设置。

虚拟机层

提供以下属性的 ViewModel:

视图层

一个组合框,用户只能从下拉选项中进行选择。但是,应允许用户在文本框区域中键入文本,并且下拉列表应过滤掉不以键入文本开头的项目。第一个匹配项应自动附加到文本框(即自动完成)。这是我的绑定:

IsTextSearchEnabled 设置为 true 以启用自动完成。

此设置的问题在于,一旦用户键入第一个字母,就会触发自动完成并尝试找到第一个匹配的条目,如果找到,则将 SelectedItem 设置为该条目,在 set ComboBoxText 属性 到那个项目,这反过来触发过滤操作,下拉列表只剩下一个完全匹配 Text 的条目,这不是什么应该是这样的。

例如,如果用户键入 "C",自动完成将尝试找到以 "C" 开头的第一个条目。假设第一个匹配条目是 "Customer"。自动完成将 select 该条目,它将 SelectedItem 设置为 "Customer",因此 Text 也将成为“客户。由于绑定,这将调用 FilterText ,这将更新 FilteredItems,现在 return 只有一个条目,而不是 returning 所有以 "C".

开头的条目

我在这里错过了什么?

我觉得你的方法太复杂了。
您可以实现一个简单的附加行为,以在启用自动完成时实现过滤的建议列表。

除了 ComboBox.ItemsSource 的公共源集合外,此示例不需要任何其他属性。过滤是通过使用 ICollectionView.Filter 属性 完成的。这将仅修改 ItemsControl 的内部源集合的视图,而不是底层绑定源集合本身。不需要将 IsTextSearchEnabled 设置为 True 来启用自动完成。

基本思想是在 TextBox.TextChanged 上触发过滤,而不是在 ComboBox.SelectedItemChanged 上触发过滤(或通常在 ComboBox.SelectedItem 上)。

ComboBox.cs

class ComboBox : DependencyObject
{
  #region IsFilterOnAutoCompleteEnabled attached property

  public static readonly DependencyProperty IsFilterOnAutocompleteEnabledProperty =
    DependencyProperty.RegisterAttached(
      "IsFilterOnAutocompleteEnabled",
      typeof(bool),
      typeof(ComboBox),
      new PropertyMetadata(default(bool), ComboBox.OnIsFilterOnAutocompleteEnabledChanged));

  public static void SetIsFilterOnAutocompleteEnabled(DependencyObject attachingElement, bool value) =>
    attachingElement.SetValue(ComboBox.IsFilterOnAutocompleteEnabledProperty, value);

  public static bool GetIsFilterOnAutocompleteEnabled(DependencyObject attachingElement) =>
    (bool)attachingElement.GetValue(ComboBox.IsFilterOnAutocompleteEnabledProperty);

  #endregion

  // Use hash tables for faster lookup
  private static Dictionary<TextBox, System.Windows.Controls.ComboBox> TextBoxComboBoxMap { get; }
  private static Dictionary<TextBox, int> TextBoxSelectionStartMap { get; }
  private static Dictionary<System.Windows.Controls.ComboBox, TextBox> ComboBoxTextBoxMap { get; }
  private static bool IsNavigationKeyPressed { get; set; }

  static ComboBox()
  {
    ComboBox.TextBoxComboBoxMap = new Dictionary<TextBox, System.Windows.Controls.ComboBox>();
    ComboBox.TextBoxSelectionStartMap = new Dictionary<TextBox, int>();
    ComboBox.ComboBoxTextBoxMap = new Dictionary<System.Windows.Controls.ComboBox, TextBox>();
  }

  private static void OnIsFilterOnAutocompleteEnabledChanged(
    DependencyObject attachingElement,
    DependencyPropertyChangedEventArgs e)
  {
    if (!(attachingElement is System.Windows.Controls.ComboBox comboBox
      && comboBox.IsEditable))
    {
      return;
    }

    if (!(bool)e.NewValue)
    {
      ComboBox.DisableAutocompleteFilter(comboBox);
      return;
    }

    if (!comboBox.IsLoaded)
    {
      comboBox.Loaded += ComboBox.EnableAutocompleteFilterOnComboBoxLoaded;
      return;
    }
    ComboBox.EnableAutocompleteFilter(comboBox);
  }

  private static async void FilterOnTextInput(object sender, TextChangedEventArgs e)
  {
    await Application.Current.Dispatcher.InvokeAsync(
      () =>
      {
        if (ComboBox.IsNavigationKeyPressed)
        {
          return;
        }

        var textBox = sender as TextBox;
        int textBoxSelectionStart = textBox.SelectionStart;
        ComboBox.TextBoxSelectionStartMap[textBox] = textBoxSelectionStart;

        string changedTextOnAutocomplete = textBox.Text.Substring(0, textBoxSelectionStart);
        if (ComboBox.TextBoxComboBoxMap.TryGetValue(
          textBox,
          out System.Windows.Controls.ComboBox comboBox))
        {
          comboBox.Items.Filter = item => item.ToString().StartsWith(
            changedTextOnAutocomplete,
            StringComparison.OrdinalIgnoreCase);
        }
      },
      DispatcherPriority.Background);
  }

  private static async void HandleKeyDownWhileFiltering(object sender, KeyEventArgs e)
  {
    var comboBox = sender as System.Windows.Controls.ComboBox;
    if (!ComboBox.ComboBoxTextBoxMap.TryGetValue(comboBox, out TextBox textBox))
    {
      return;
    }

    switch (e.Key)
    {
      case Key.Down 
        when comboBox.Items.CurrentPosition < comboBox.Items.Count - 1 
             && comboBox.Items.MoveCurrentToNext():
      case Key.Up 
        when comboBox.Items.CurrentPosition > 0 
             && comboBox.Items.MoveCurrentToPrevious():
      {
        // Prevent the filter from re-apply as this would override the
        // current selection start index
        ComboBox.IsNavigationKeyPressed = true;

        // Ensure the Dispatcher en-queued delegate 
        // (and the invocation of the SelectCurrentItem() method)
        // executes AFTER the FilterOnTextInput() event handler.
        // This is because key input events have a higher priority
        // than text change events by default. The goal is to make the filtering 
        // triggered by the TextBox.TextChanged event ignore the changes 
        // introduced by this KeyDown event.
        // DispatcherPriority.ContextIdle will force to "override" this behavior.
        await Application.Current.Dispatcher.InvokeAsync(
          () =>
          {
            ComboBox.SelectCurrentItem(textBox, comboBox);
            ComboBox.IsNavigationKeyPressed = false;
          }, 
          DispatcherPriority.ContextIdle);

        break;
      }
    }
  }

  private static void SelectCurrentItem(TextBox textBox, System.Windows.Controls.ComboBox comboBox)
  {
    comboBox.SelectedItem = comboBox.Items.CurrentItem;
    if (ComboBox.TextBoxSelectionStartMap.TryGetValue(textBox, out int selectionStart))
    {
      textBox.SelectionStart = selectionStart;
    }
  }

  private static void EnableAutocompleteFilterOnComboBoxLoaded(object sender, RoutedEventArgs e)
  {
    var comboBox = sender as System.Windows.Controls.ComboBox;
    ComboBox.EnableAutocompleteFilter(comboBox);
  }

  private static void EnableAutocompleteFilter(System.Windows.Controls.ComboBox comboBox)
  {
    if (comboBox.TryFindVisualChildElement(out TextBox editTextBox))
    {
      ComboBox.TextBoxComboBoxMap.Add(editTextBox, comboBox);
      ComboBox.ComboBoxTextBoxMap.Add(comboBox, editTextBox);
      editTextBox.TextChanged += ComboBox.FilterOnTextInput;

      // Need to receive handled KeyDown event
      comboBox.AddHandler(UIElement.PreviewKeyDownEvent, new KeyEventHandler(HandleKeyDownWhileFiltering), true);
    }
  }

  private static void DisableAutocompleteFilter(System.Windows.Controls.ComboBox comboBox)
  {
    if (comboBox.TryFindVisualChildElement(out TextBox editTextBox))
    {
      ComboBox.TextBoxComboBoxMap.Remove(editTextBox);
      editTextBox.TextChanged -= ComboBox.FilterOnTextInput;
    }
  }
}

Extensions.cs

public static class Extensions
{ 
  /// <summary>
  /// Traverses the visual tree towards the leafs until an element with a matching element type is found.
  /// </summary>
  /// <typeparam name="TChild">The type the visual child must match.</typeparam>
  /// <param name="parent"></param>
  /// <param name="resultElement"></param>
  /// <returns></returns>
  public static bool TryFindVisualChildElement<TChild>(this DependencyObject parent, out TChild resultElement)
    where TChild : DependencyObject
  {
    resultElement = null;

    if (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 child)
      {
        resultElement = child;
        return true;
      }

      if (childElement.TryFindVisualChildElement(out resultElement))
      {
        return true;
      }
    }

    return false;
  }
}

用法示例

<ComboBox ItemsSource="{Binding Items}" 
          IsEditable="True"
          ComboBox.IsFilterOnAutocompleteEnabled="True" />