Winform里面的TextBox有TextChanged事件,当TextBox的值发生改变时就会触发,在进行一些诸如对用户的输入进行即时校验时可能会用到。但是这个事件在某些情况下触发的过于频繁,会造成一些资源的浪费。假设我们要限制用户在TextBox里面输入的内容为数字,且范围在20-100.00之间。当用户连续输入的时候,每发生一次输入,只要内容发生变化,就会触发一次事件,比如当用户想输入25.22时,在键盘里面的内容变化为“2、25、25.、25.2、25.22” 这个输入会导致触发5次事件。这显然不是我们想要的,我们需要的是,当用户“停止”输入时,再触发,即当用户连续输入完成 25.22时,触发一次事件。

    这个问题,关键在于如何定义用户“停止”输入,一种方案是,我们可以在焦点里开TextBox时触发,比如在 Leave 或 LostFocus 事件里处理,另外一种方案是添加一个定时器,限定在某一个时间段内,只触发一次。在之前的Reactive Extensions入门(5):ReactiveUI MVVM框架 时也遇到过这种情况,当时用的是Rx框架,里面本身提供了一个Throttle扩展方法,这个方法就是限流的意思,这就是第二种方案。它用来忽略一些不必要的频繁操作,我们并不想监听键盘每一次按下事件,当用户输入时,在800毫秒内的输入,我们只触发一次。

this.ObservableForProperty(x => x.SearchTerm)
        .Throttle(TimeSpan.FromMilliseconds(800), RxApp.DeferredScheduler)
        .Select(x => x.Value)
        .DistinctUntilChanged()
        .Where(x => !String.IsNullOrWhiteSpace(x))
        .InvokeCommand(ExecuteSearch);

    这种用法很常见,比如我们在使用搜索引擎时的一些即使提示,用户的每一次输入,如果输入半途中就去请求的话,无疑会浪费网络和计算资源。一般是等待用户数的短暂停顿后,才去请求。

    比如Bing搜索中,当我快速输入"sssssssss",时,实际上Bing不会发送9个Suggestions请求,而是发送了"s"、“ssss”、“sssssss”、“sssssssss”,猜想这就是基于第二种方案的限流。现在就看一下,如何在Winform中实现采用延迟触发TextChanged事件。

Winform中延迟触发TextChanged事件


    这个问题,其实很多人都遇到过,也提供了很多解决方案,其中一个就是采用一个计时器,在限定时间之内只触发一次。这个方法的优点是通用性比较好。也有两种实现,一种是使用帮助类,另外一种是自定义控件。

使用帮助类实现

    首先定义一个帮助类:

public class TypeAssistant
{
    public event EventHandler Idled = delegate { };
    public int WaitingMilliSeconds { get; set; }
    System.Threading.Timer waitingTimer;

    public TypeAssistant(int waitingMilliSeconds = 600)
    {
        WaitingMilliSeconds = waitingMilliSeconds;
        waitingTimer = new System.Threading.Timer(p =>
        {
            Idled(this, EventArgs.Empty);
        });
    }
    public void TextChanged()
    {
        waitingTimer.Change(WaitingMilliSeconds, System.Threading.Timeout.Infinite);
    }
}

    在他的构造函数中,传入触发的时间间隔,默认为600毫秒,触发一次。这个里面,关键是TextChanged方法里的System.Threading.Timer的Change方法,第一个参数是计时器的启动时间,表示多少毫秒后启动,第二个参数表示定时器的触发间隔,这里设置的是Infinite,表示永不触发,就是只在等待时间之后,触发一次。完美的实现了我们需要的功能。

    使用的时候,先定义一个全局的TypeAssistant对象,并注册其Idled事件,这个事件用来处理,当发生TextChanged事件时的操作。

TypeAssistant assistant = new TypeAssistant();
assistant.Idled += assistant_Idled;
private void assistant_Idled(object sender, EventArgs e)
{
    this.Invoke(
    new MethodInvoker(() =>
    {
        double price;
        if (double.TryParse(txtPrice.Text, out price))
        {
            if (price > 100.00)
            {
                txtPrice.Text = 100.00;
            }
            if (price < 20)
            {
                txtPrice.Text = 20;
            }
        }
    }));
}

    在Idle回调中,如果我们要操作UI界面的控件,那么必须使用Invoke,将操作封送到UI线程中。

   在发生TextChanged事件时, 先检查txtPrice里面输入的价格,如果超过100或者小于20我们将其调整为边界值。当然,这个只是客户端的校验,用户实际上,只要速度够快,在600毫秒以内输入,还是能够产生非法值,这里更多时间可能是用在诸如提示,自动补全之类的应用场景。

    在使用的时候,也很简单,只需要在txtPrice的TextChanged事件中,调用以下方法即可:

private void txtPrice_TextChanged(object sender, EventArgs e)
{
    assistant.TextChanged();
}

     至此完美解决,当然还有一个可以优化的地方,是回调中在对TextBox进行赋值时,仍然会触发TextChanged事件,这就造成了一个循环(幸好不是死循环,因为第一次纠正后的值,再次触发的TextChanged的时候因为满足条件,所以不会被再次修改),但最好的方法是对TextBox的赋值进行封装,在进行赋值前,先注销TextChanged事件,然后修改值,然后再添加TextChanged事件,这样就更完美了。

     可以定义一个扩展方法,

static class WinformHelper
{
    public static void SetTextValue(this TextBox txtBox, string text, EventHandler events)
    {
        txtBox.TextChanged -= events;
        txtBox.Text = text;
        txtBox.TextChanged += events;
    }
}

    然后使用的时候,使用扩展方法进行赋值:

txtPrice.SetTextValue(“20”,txtPrice_TextChanged);

    似乎每次都需要传一个回调进去,不够优雅,所以也可以直接写一个私有方法。这里不再赘述了。

使用自定义控件实现

    我们可以继承自微软提供的TextBox控件,然后自定义一个我们自己的TextBox,这里取名叫ThrottleTextBox好了。

[Description("能够支持限流TextChanged事件的TextBox")]
public class ThrottleTextBox : TextBox
{
    private Timer delayedTextChangedTimer;
    [Description("限流时需要触发的事件")]
    public event EventHandler DelayedTextChanged;
    [Description("限流的时间,单位毫秒,默认600毫秒")]
    public int DelayedTextChangedTimeout { get; set; }

    public ThrottleTextBox() : base()
    {
        DelayedTextChangedTimeout = 600;
    }

    protected override void Dispose(bool disposing)
    {
        if (delayedTextChangedTimer != null)
        {
            delayedTextChangedTimer.Stop();
            if (disposing)
            {
                delayedTextChangedTimer.Dispose();
            }
        }

        base.Dispose(disposing);
    }

    protected virtual void OnDelayedTextChanged(EventArgs e)
    {
        DelayedTextChanged?.Invoke(this, e);
    }

    protected override void OnTextChanged(EventArgs e)
    {
        InitializeDelayedTextChangedEvent();
        base.OnTextChanged(e);
    }

    private void InitializeDelayedTextChangedEvent()
    {
        delayedTextChangedTimer?.Stop();

        if (delayedTextChangedTimer == null || delayedTextChangedTimer.Interval != DelayedTextChangedTimeout)
        {
            delayedTextChangedTimer = new Timer();
            delayedTextChangedTimer.Tick += new EventHandler(HandleDelayedTextChangedTimeTick);
            delayedTextChangedTimer.Interval = DelayedTextChangedTimeout;
        }
        delayedTextChangedTimer.Start();
    }

    private void HandleDelayedTextChangedTimeTick(object sender, EventArgs e)
    {
        if (sender is Timer timer) timer.Stop();
        OnDelayedTextChanged(EventArgs.Empty);
    }
}

   上面使用的是System.Windows.Forms.Timer控件,它运行在UI线程上的,所以在其回调里,可以直接操作UI控件。可以通过设置Interval来设置间隔时间,设置完成之后,调用Start()方法,在Interval之后,就会触发第一次事件,触发完成之后就停止,只触发一次。并且在间隔内每次触发,如果发现timer不为空,从新实例化timer。

   ThrottleTextBox是一个自定义控件,在Winform设计窗口可以直接拖到界面上来,然后在“属性”面板上,可以设置间隔时间“DelayedTextChangedTimeout”,以及注册"DelayedTextChanged"事件。

     比如,我这里注册了自带的TextChanged和我们新定义的DelayedTextChanged事件,效果如下:

private void throttleTextBox1_DelayedTextChanged(object sender, EventArgs e)
{
    ThrottleTextBox tx = sender as ThrottleTextBox;
    Console.WriteLine("DelayedTextChanged:" + tx.Text);
}

private void throttleTextBox1_TextChanged(object sender, EventArgs e)
{
    ThrottleTextBox tx = sender as ThrottleTextBox;
    Console.WriteLine("TextChanged:" + tx.Text);
}

   

   以上是在Winform中的实现,实现起来有一些繁琐,在WPF中实现起来就容易的多。

 WPF中的实现


     WPF中支持异步事件,所以可以直接在TextChanged里面等待一段时间,如果发现字符串长度或者内容没有发送变化,则不触发相关,非常简单操作。

private async void txtPrice_OnTextChanged(object sender, TextChangedEventArgs e)
{
    if (sender is TextBox tb)
    {
        string beginContext = tb.Text;
        await Task.Delay(600);
        if (beginContext == tb.Text)
        {
            Console.WriteLine(@"txtPrice_OnTextChanged:" + tb.Text);
        }
    }
}

    当然,不写代码就是最好的,在WPF中有个绑定延迟属性Delay,这个属性可以设置当发生变化时延迟多少秒触发事件。

<Window x:Class="WpfApp1.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:WpfApp1"
        mc:Ignorable="d" Name="MainWindows"
        Title="MainWindow" Height="150" Width="200">
    <Grid>
        <TextBox x:Name="txtPrice" HorizontalAlignment="Left" Height="23" Margin="10,22,0,0" TextWrapping="Wrap" Text="{Binding Path=Title,ElementName=MainWindows, Delay=600,Mode=OneWayToSource, UpdateSourceTrigger=PropertyChanged}"  VerticalAlignment="Top" Width="175"/>
    </Grid>
</Window>

    这里定义了一个TextBox,将它的值绑定到了窗体的Title上面,也就是说在TextBox里面输入的内容会变成窗体的标题,并且设置了Delay为600毫秒,实际的效果就是,当连续很快的在TextBox里面输入内容的时候,窗体的Ttitle不会一直发生变化。这里面是直接用到了WPF里面功能强大的数据绑定,直接将属性绑定到我们要处理的数据上,他没有改变TextChanged事件的触发频率。

 

 参考

  1. c# - Don't raise TextChanged while continuous typing - Stack Overflow
  2. textbox - C# wait for user to finish typing in a Text Box - Stack Overflow