Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Don't raise TextChanged while continuous typing

I have a textbox that has a fairly hefty _TextChanged event handler. Under normal typing condition the performance is okay, but it can noticeably lag when the user performs a long continuous action, such as keeping the backspace button pressed to delete a lot of text at once.

For example, the event took 0.2 seconds to complete, but the user is performing one deletion every 0.1 seconds. Thus, it cannot catch up and there will be a backlog of events that needs to be handled, leading to the UI lagging.

However, the event does not need to run for these in-between states, because it only cares about the end result. Is there any way to let the event handler know that it should process only the latest event, and ignore all the previous stale changes?

like image 886
ananda Avatar asked Nov 18 '15 09:11

ananda


4 Answers

I've come across this problem several times, and based on my own experience I found this solution simple and neat so far. It is based on Windows Form but can be converted to WPF easily.

How it works:

When TypeAssistant learns that a text change has happened, it runs a timer. After WaitingMilliSeconds the timer raises Idle event. By handling this event, you can do whatever job you wish (such as processing the entered tex). If another text change occurs in the time frame starting from the time that the timer starts and WaitingMilliSeconds later, the timer resets.

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 Timer(p =>
        {
            Idled(this, EventArgs.Empty);
        });
    }
    public void TextChanged()
    {
        waitingTimer.Change(WaitingMilliSeconds, System.Threading.Timeout.Infinite);
    }
}

Usage:

public partial class Form1 : Form
{
    TypeAssistant assistant;
    public Form1()
    {
        InitializeComponent();
        assistant = new TypeAssistant();
        assistant.Idled += assistant_Idled;          
    }

    void assistant_Idled(object sender, EventArgs e)
    {
        this.Invoke(
        new MethodInvoker(() =>
        {
            // do your job here
        }));
    }

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

Advantages:

  • Simple!
  • Working in WPF and Windows Form
  • Working with .Net Framework 3.5+

Disadvantages:

  • Runs one more thread
  • Needs Invocation instead of direct manipulation of form
like image 190
Alireza Avatar answered Oct 10 '22 04:10

Alireza


One easy way is to use async/await on an inner method or delegate:

private async void textBox1_TextChanged(object sender, EventArgs e) {
    // this inner method checks if user is still typing
    async Task<bool> UserKeepsTyping() {
        string txt = textBox1.Text;   // remember text
        await Task.Delay(500);        // wait some
        return txt != textBox1.Text;  // return that text chaged or not
    }
    if (await UserKeepsTyping()) return;
    // user is done typing, do your stuff    
}

No threading involved here. For C# version older than 7.0, you can declare a delegate:

Func<Task<bool>> UserKeepsTyping = async delegate () {...}

Please note, that this method will not secure you from occasionally processing the same "end reslut" twice. E.g. when user types "ab", and then immediately deletes "b", you might end up processing "a" twice. But these occasions shoud be rare enough. To avoid them, the code could be like this:

// last processed text
string lastProcessed;
private async void textBox1_TextChanged(object sender, EventArgs e) {
    // clear last processed text if user deleted all text
    if (string.IsNullOrEmpty(textBox1.Text)) lastProcessed = null;
    // this inner method checks if user is still typing
    async Task<bool> UserKeepsTyping() {
        string txt = textBox1.Text;   // remember text
        await Task.Delay(500);        // wait some
        return txt != textBox1.Text;  // return that text chaged or not
    }
    if (await UserKeepsTyping() || textBox1.Text == lastProcessed) return;
    // save the text you process, and do your stuff
    lastProcessed = textBox1.Text;   
}
like image 29
lisz Avatar answered Oct 10 '22 04:10

lisz


I also think that the Reactive Extensions are the way to go here. I have a slightly different query though.

My code looks like this:

        IDisposable subscription =
            Observable
                .FromEventPattern(
                    h => textBox1.TextChanged += h,
                    h => textBox1.TextChanged -= h)
                .Select(x => textBox1.Text)
                .Throttle(TimeSpan.FromMilliseconds(300))
                .Select(x => Observable.Start(() => /* Do processing */))
                .Switch()
                .ObserveOn(this)
                .Subscribe(x => textBox2.Text = x);

Now this works precisely the way you were anticipating.

The FromEventPattern translates the TextChanged into an observable that returns the sender and event args. Select then changes them to the actual text in the TextBox. Throttle basically ignores previous keystrokes if a new one occurs within the 300 milliseconds - so that only the last keystroke pressed within the rolling 300 millisecond window are passed on. The Select then calls the processing.

Now, here's the magic. The Switch does something special. Since the select returned an observable we have, before the Switch, an IObservable<IObservable<string>>. The Switch takes only the latest produced observable and produces the values from it. This is crucially important. It means that if the user types a keystroke while existing processing is running it will ignore that result when it comes and will only ever report the result of the latest run processing.

Finally there's a ObserveOn to return the execution to the UI thread, and then there's the Subscribe to actually handle the result - and in my case update the text on a second TextBox.

I think that this code is incredibly neat and very powerful. You can get Rx by using Nuget for "Rx-WinForms".

like image 13
Enigmativity Avatar answered Oct 10 '22 04:10

Enigmativity


You can mark your event handler as async and do the following:

bool isBusyProcessing = false;

private async void textBox1_TextChanged(object sender, EventArgs e)
{
    while (isBusyProcessing)
        await Task.Delay(50);

    try
    {
        isBusyProcessing = true;
        await Task.Run(() =>
        {
            // Do your intensive work in a Task so your UI doesn't hang
        });

    }
    finally
    {
        isBusyProcessing = false;
    }
}

Try try-finally clause is mandatory to ensure that isBusyProcessing is guaranted to be set to false at some point, so that you don't end up in an infinite loop.

like image 4
kkyr Avatar answered Oct 10 '22 04:10

kkyr