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事件的触发频率。
参考
- c# - Don't raise TextChanged while continuous typing - Stack Overflow
- textbox - C# wait for user to finish typing in a Text Box - Stack Overflow