65.9K
CodeProject 正在变化。 阅读更多。
Home

延迟 ListCollectionView 过滤器更新以实现响应式 UI

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.90/5 (11投票s)

2009 年 1 月 11 日

CPOL

5分钟阅读

viewsIcon

63477

downloadIcon

1810

解释了如何在用户键入时更新搜索结果,同时保持 UI 的响应性。

SnappyFiltering.png

引言

本文讨论了如何实现一个带有搜索文本框的 WPF 应用程序,该文本框会实时显示搜索结果。实现此功能的一种方法是使用 ICollectionViewFilter 属性,并在每次输入字符时使用适当的过滤器进行更新。在某些情况下,这可能会导致应用程序使用起来令人沮丧,因为每次输入的字符都会导致昂贵的刷新。此处提出的解决方案是推迟对 Filter 属性的更新,直到用户很可能已完成键入。诀窍在于找到一种简单的方法来猜测何时完成。

ICollectionView 过滤的背景

当我们把控件绑定到列表数据时,我们实际上是把控件绑定到了一个集合视图。集合视图(由 ICollectionView 接口表示)字面意思上是数据的视图,因为它包装了数据并控制着控件如何呈现数据。这个接口的属性之一是 Filter,它的类型是 Predicate<object>,这是一个委托,它接受一个 object 并返回一个 bool,指示控件是否应该显示该对象。了解这一点后,至少有一种显而易见的实现搜索文本框的方法会浮现在脑海中。我们只需将 ListBox 绑定到完整的数据列表,订阅 TextBoxTextChanged 事件,并在每次输入字符时更新 ICollectionViewFilter 属性。

为了演示这一点,在下面的 XAML 代码片段中,我创建了一个 TextBox 用于输入搜索条件,以及一个 ListBox 用于显示结果。此片段假定数据上下文已设置为 List<string> 实例,该实例包含要搜索的完整数据集。

<TextBox x:Name="searchTextBox" TextChanged="searchTextBox_TextChanged"/>
<ListBox Grid.Row="1" ItemsSource="{Binding}" />

在代码隐藏中,我有一个 TextChanged 事件的处理程序,负责更新视图的过滤器。用户每输入一个字符,此方法都会被调用,以确保结果始终是最新的。该代码使用 CollectionViewSource.GetDefaultView 来获取包装数据的视图。然后更新过滤器,使其在数据包含搜索文本框中的文本时返回 true

private void searchTextBox_TextChanged(object sender, TextChangedEventArgs e)
{
    ICollectionView view = 
      CollectionViewSource.GetDefaultView(this.DataContext);

    if (view != null)
    {
        string text = this.searchTextBox.Text;
        view.Filter = (obj) => ((string)obj).Contains(text);
    }
}

这种实现方式的一个常见抱怨是,如果正在搜索的数据集非常大,UI 在输入字符时可能会变得不响应。在这种情况下,每次输入一个字符后,整个 UI 都会冻结,直到 ListBox 完成刷新。总的来说,当刷新成本很高时,这个问题就会显现出来。我最初在将搜索框应用于 WPF ToolkitDataGrid 时遇到了这个问题,这显然比这里的 ListBox 刷新成本更高。

由于这个问题,人们通常会选择在用户按下 Enter 键或单击“搜索”按钮时更新过滤器。然而,这个问题有一个非常简单的解决方案。

日文字典演示

为了演示本文背后的思想,我创建了一个演示日语词典应用程序,该应用程序使用了 EDICT 词典文件。该应用程序可以使用完整的 EDICT 词典,或者 EDICT 子集 词典,后者要小得多,只包含最常用的 22,000 个日语单词。完整的 EDICT 词典由于其庞大的体积,最能体现刷新的成本。可以在菜单中选择要使用的词典;但是,我只打包了 EDICT 子集词典,所以 EDICT 词典是灰色的。有兴趣的人可以下载完整的 EDICT 词典并与演示一起使用。

Dictionary.png

搜索框右侧有一个复选框,用于在每次输入字符时更新过滤器(未选中时)和我将要描述的技术(选中时,默认为选中)之间切换。

DeferredAction 类

与其在每次输入字符时更新搜索结果,不如在用户停止键入一段时间后才更新它们。这样,他们在键入时就不会受到昂贵的刷新干扰。通常,用户对中间结果并不感兴趣。

为了实现这种行为,我创建了 DeferredAction 类。

DeferredAction.png

DeferredAction 实例由一个 Action 创建,Action 只是一个不接受参数也不返回值的委托。调用 Defer 并传入一个 TimeSpan 会导致操作在经过该时间后被调用。在操作被调用之前再次调用 Defer 会导致其被重新安排。

因此,如果我们考虑将更新 Filter 属性作为我们的操作,那么在每次输入字符时调用 Defer 就能给我们想要的行为。

让我们看看 DeferredAcion 的实现。当 DeferredAction 初始化时,它会创建一个 Timer 实例,其回调使用应用程序的 Dispatcher 来调用操作。必须使用 Dispatcher,因为设置 Filter 属性本质上会更新 UI,而且我们知道所有 UI 更新都必须在适当的线程上进行。Timer 的回调不一定会在该线程上调用。请注意,最初,该操作并未安排被调用。

/// <summary>
/// Creates a new DeferredAction.
/// </summary>
/// <param name="action">
/// The action that will be deferred. It is not performed until 
/// after <see cref="Defer"/> is called.
/// </param>
public static DeferredAction Create(Action action)
{
    if (action == null)
    {
        throw new ArgumentNullException("action");
    }

    return new DeferredAction(action);
}

private DeferredAction(Action action)
{
    this.timer = new Timer(new TimerCallback(delegate
    {
        Application.Current.Dispatcher.Invoke(action);
    }));
}

Defer 方法使用 TimerChange 方法来安排回调在指定时间过去后被调用。

/// <summary>
/// Defers performing the action until after time elapses. 
/// Repeated calls will reschedule the action
/// if it has not already been performed.
/// </summary>
/// <param name="delay">
/// The amount of time to wait before performing the action.
/// </param>
public void Defer(TimeSpan delay)
{
    // Fire action when time elapses (with no subsequent calls).
    this.timer.Change(delay, TimeSpan.FromMilliseconds(-1));
}

使用 DeferredAction

现在我们已经实现了 DeferredAction,我们只需要定义要执行的操作。ApplySearchCriteria 获取正在显示的数据的 ICollectionView,并将其过滤器设置为一个委托,当条目或定义包含文本框中的文本时该委托返回 true。因此,我们可以按日语或英语进行搜索。

private void ApplySearchCriteria()
{
    ICollectionView view = 
        (ICollectionView)CollectionViewSource.GetDefaultView(
            this.entriesListBox.ItemsSource);
            
    if (view != null)
    {
        string text = this.searchBox.Text.ToLowerInvariant();

        view.Filter = delegate(object obj)
        {
            DictionaryEntry entry = obj as DictionaryEntry;

            if (entry != null)
            {
                return entry.Entry.ToLowerInvariant().Contains(text) 
                       || entry.Definition.ToLowerInvariant().Contains(text);
            }

            return false;
        };
    }
}

当在文本框中输入文本时,会发生以下两种情况之一。如果复选框未选中,则每次输入字符时都会应用搜索条件。如果复选框已选中,则仅在用户未输入任何文本的指定时间 searchDelay 过去后才应用搜索条件。对于演示,我使用了 0.25 秒的延迟,这似乎是一个不错的平衡点。

private void searchBox_TextChanged(object sender, TextChangedEventArgs e)
{
    if (fastCheckBox.IsChecked == true)
    {
        if (this.deferredAction == null)
        {
            this.deferredAction = DeferredAction.Create(() => ApplySearchCriteria());
        }

        // Defer applying search criteria until time has elapsed.
        this.deferredAction.Defer(searchDelay);
    }
    else
    {
        // Apply search criteria immediately. This makes the UI less responsive
        // since the list is updated on every character input.
        ApplySearchCriteria();
    }
}

历史

  • 2009 年 1 月 11 日 - 初始版本。
© . All rights reserved.